Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ tasks:
- go build -ldflags "{{.LDFLAGS}}" -o bin/galactic-tap-cni ./cmd/galactic-tap-cni
- go build -ldflags "{{.LDFLAGS}}" -o bin/galactic-ipam ./cmd/galactic-ipam
- go build -ldflags "{{.LDFLAGS}}" -o bin/galactic-bgp ./cmd/galactic-bgp
- go build -ldflags "{{.LDFLAGS}}" -o bin/galactic-route ./cmd/galactic-route
- go build -ldflags "{{.LDFLAGS}}" -o bin/galactic-router ./cmd/galactic-router
- go build -ldflags "{{.LDFLAGS}}" -o bin/vmtap-cni ./cmd/vmtap-cni
- GOBIN={{.LOCALBIN}} go install github.com/containernetworking/plugins/plugins/main/host-device@v1.9.1
Expand Down
126 changes: 126 additions & 0 deletions cmd/galactic-route/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright 2026 Datum Cloud, Inc.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"

"github.com/containernetworking/cni/pkg/version"
"github.com/spf13/cobra"
"golang.org/x/term"

"go.datum.net/galactic/internal/cniroute"
"go.datum.net/galactic/internal/metadata"
)

const (
appName = "galactic-route"

appDesc = `Galactic Route CNI Plugin

The termination-route plugin in the galactic CNI chain — chained after
galactic-cni/galactic-tap-cni and before galactic-bgp per conflist order,
never run standalone, and optional (only present for attachments with
terminations to install). Has no Kubernetes dependency at all: it only
installs kernel routes into the VRF routing table the master plugin
already created.

Find more information at: https://www.datum.net/docs`
)

func newRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: appName,
Short: strings.Split(appDesc, "\n")[0],
Long: appDesc,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cniroute.InitCNIConfig()
confFile, _ := cmd.Flags().GetString("conf-file")
if confFile != "" {
cniroute.ConfFile = confFile
}
return nil
},
RunE: func(cmd *cobra.Command, _ []string) error {
if ok, _ := cmd.Flags().GetBool("build-info"); ok {
fmt.Println(metadata.BuildInfo(appName))
return nil
}
if ok, _ := cmd.Flags().GetBool("version"); ok {
fmt.Printf("%s version %s\n", appName, metadata.Version)
return nil
}
if os.Getenv("CNI_COMMAND") == "VERSION" {
return version.All.Encode(os.Stdout)
}

// Real CNI runtimes always pipe the network config JSON on
// stdin and close it. If stdin is an interactive terminal
// instead, no config will ever arrive and skel's blocking
// stdin read would hang forever — print version info instead.
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Printf("%s version %s\n", appName, metadata.Version)
fmt.Printf("CNI protocol versions supported: %s\n", strings.Join(version.All.SupportedVersions(), ", "))
return nil
}

// Unlike galactic-cni/galactic-tap-cni, this plugin never talks
// to the API server. It does, however, run natively in whatever
// netns CNI_NETNS points at rather than entering it — for a
// veth-mode attachment CNI_NETNS is the container's netns, which
// differs from this process's own ambient (host) netns, so the
// CNI library's same-netns rejection check never fires. For a
// tap-mode attachment, though, CNI_NETNS is deliberately set to
// the host's own root netns (there's no per-VM netns to enter),
// which does equal this process's ambient netns — so the same
// peek-and-repipe dance and CNI_NETNS_OVERRIDE galactic-cni uses
// for tap mode are needed here too, or the library rejects every
// tap-mode ADD/DEL after the route is already installed.
stdinData, _ := io.ReadAll(os.Stdin)
r, w, _ := os.Pipe()
go func() {
_, _ = w.Write(stdinData)
_ = w.Close()
}()
oldStdin := os.Stdin
os.Stdin = r
defer func() { os.Stdin = oldStdin }()

if isTapMode(stdinData) {
_ = os.Setenv("CNI_NETNS_OVERRIDE", "true")
}

cniroute.RunPlugin()
return nil
},
}

cmd.PersistentFlags().String("conf-file", cniroute.ConfFile, "Path to CNI conflist file")
cmd.Flags().Bool("build-info", false, "Print build information and exit")
cmd.Flags().BoolP("version", "V", false, "Print version and exit")
return cmd
}

// isTapMode returns true when the CNI config requests tap interface type.
// Only a minimal JSON parse is needed — full validation happens later in
// parseConf inside cmdAdd/cmdDel.
func isTapMode(stdinData []byte) bool {
var cfg struct {
InterfaceType string `json:"interface_type"`
}
_ = json.Unmarshal(stdinData, &cfg)
return cfg.InterfaceType == "tap"
}

func main() {
if err := newRootCommand().Execute(); err != nil {
log.Fatalf("error: %v", err)
}
}
16 changes: 16 additions & 0 deletions containers/galactic-cni/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
-X go.datum.net/galactic/internal/metadata.GitURL=${GIT_URL}" \
-o galactic-bgp cmd/galactic-bgp/main.go

# Build galactic-route, the termination-route plugin in the galactic CNI
# chain. Ships in this same image/binary set for the same reason
# galactic-tap-cni/galactic-ipam/galactic-bgp do — see their own comments
# above.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
-ldflags "-s -w \
-X go.datum.net/galactic/internal/metadata.Version=${VERSION} \
-X go.datum.net/galactic/internal/metadata.GitCommit=${GIT_COMMIT} \
-X go.datum.net/galactic/internal/metadata.GitTreeState=${GIT_TREE_STATE} \
-X go.datum.net/galactic/internal/metadata.BuildDate=${BUILD_DATE} \
-X go.datum.net/galactic/internal/metadata.SPDXLicense=${SPDX_LICENSE} \
-X go.datum.net/galactic/internal/metadata.GitURL=${GIT_URL}" \
-o galactic-route cmd/galactic-route/main.go

# Build vmtap-cni. It ships in this image rather than one of its own so the
# vmtap DaemonSet (config/vmtap/) can reference the same published
# ghcr.io/datum-cloud/galactic-cni image instead of a second, separately
Expand Down Expand Up @@ -125,6 +139,7 @@ COPY --from=builder /workspace/galactic-cni /galactic-cni
COPY --from=builder /workspace/galactic-tap-cni /galactic-tap-cni
COPY --from=builder /workspace/galactic-ipam /galactic-ipam
COPY --from=builder /workspace/galactic-bgp /galactic-bgp
COPY --from=builder /workspace/galactic-route /galactic-route
COPY --from=builder /workspace/vmtap-cni /vmtap-cni
COPY --from=builder /workspace/host-device /host-device
COPY --from=builder /var/run/galactic-cni /var/run/galactic-cni
Expand All @@ -140,6 +155,7 @@ COPY --from=production /galactic-cni /galactic-cni
COPY --from=production /galactic-tap-cni /galactic-tap-cni
COPY --from=production /galactic-ipam /galactic-ipam
COPY --from=production /galactic-bgp /galactic-bgp
COPY --from=production /galactic-route /galactic-route
COPY --from=production /vmtap-cni /vmtap-cni
COPY --from=production /host-device /host-device
COPY --from=production /var/run/galactic-cni /var/run/galactic-cni
Expand Down
63 changes: 45 additions & 18 deletions docs/cni/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ the standard CNI `PluginConf` with Galactic-specific fields.
| `vpcattachment` | **Yes** | `string` | Base62-encoded VPC attachment identifier (16-bit value). Paired with `vpc` for deterministic VRF/BGP naming. |
| `interface_type` | No | `string` | Interface mode: `"veth"` (default, for containers) or `"tap"` (for VMs such as Kata, Firecracker, QEMU). Both modes run IPAM and SRv6/BGP publish; `tap` mode only skips host-device delegation and guest-netns configuration (see the Tap mode section below). |
| `mtu` | No | `int` | MTU for the host-side interface. For `veth` mode this applies to both veth endpoints; for `tap` mode it applies to the tap interface. |
| `terminations` | No | `[]Termination` | Array of static routes to add on the host side (see Termination sub-fields below). |
| `namespace` | No | `string` | Kubernetes namespace used to look up the `BGPRouter` CRD. Resolution order: this field → `GALACTIC_CNI_NAMESPACE` env → `HostConf.Namespace` (conflist) → `galactic-system`. See [Runtime Configuration](#runtime-configuration) above. |
| `ipam` | No* | `IPAM` | Legacy static-IP / local-IPAM configuration block (see IPAM sub-fields below). Only `type: "static"` still drives its own allocation path; `type: "pool"` is otherwise superseded by `ipv6_subnet`/`ipv4_subnet` below. *Required unless `GALACTIC_CNI_ENABLE_LOCAL_IPAM`, `ipv6_subnet`, or `ipv4_subnet` is set — applies identically in `veth` and `tap` mode. In `tap` mode `cmdAdd` (`internal/cni/ops_add.go`) calls `allocateIPAM` unconditionally (unlike `veth` mode, which checks first), so a config satisfying none of those currently produces a nil-pointer panic in `tap` mode rather than a clean validation error — always set one of them for tap. |
| `ipv6_subnet` | No* | `string` | Region IPv6 pool CIDR for the NAD-driven pool-IPAM path; endpoints allocate a `/96` from it by default. Setting this field or `ipv4_subnet` (or both) opts a config into pool IPAM directly — no `ipam` block needed. See [Pool IPAM via `ipv6_subnet`/`ipv4_subnet`](#pool-ipam-via-ipv6_subnetipv4_subnet) below. |
Expand All @@ -121,6 +120,13 @@ ADD fail for every attachment in the chain. Every config in this doc already
uses `"1.0.0"`; keep it that way for any config authored outside these
examples.

`terminations` (static routes to add on the host side) is **not** a
`galactic-cni`/`galactic-tap-cni` field — it belongs to `galactic-route`, the
chained plugin invoked after the master plugin per conflist order (see
[Termination Fields](#termination-fields) below). Putting a `terminations`
array in the master's own stanza does nothing: `galactic-cni`'s slimmer
`PluginConf` silently drops the unknown field on unmarshal.

### Interface Types

#### `veth` (default)
Expand Down Expand Up @@ -201,15 +207,22 @@ this fallback is IPv6-only; there is no default IPv4 pool.

### Termination Fields

Each entry in the `terminations` array has the following fields:
`terminations` is a field of `galactic-route`'s own conflist stanza — the
chained CNI plugin invoked after `galactic-cni`/`galactic-tap-cni` and before
`galactic-bgp` per conflist order, present only for attachments that need
static routes installed on the host side. Each entry in the array has the
following fields:

| Field | Required | Type | Description |
| --------- | -------- | -------- | ------------------------------------------------------------------------------------------ |
| `network` | **Yes** | `string` | CIDR prefix for a static route (e.g. `"fd00::/48"`). |
| `via` | No | `string` | Next-hop gateway IP. If omitted, a link-local route is installed via the host-side device. |

Used in `cmdAdd` to install routes into the VRF table for each termination
entry. Deleted in `cmdDel` in reverse order.
`galactic-route`'s `cmdAdd` installs routes into the VRF table for each
termination entry. `cmdDel` is a no-op — routes are keyed by
`(vpc, vpcattachment)` and may still be in use by another pod/VM sharing the
same attachment, so cleanup is left entirely to `galactic-router`'s GC
controller.

## Example Configurations

Expand All @@ -225,8 +238,10 @@ entry. Deleted in `cmdDel` in reverse order.
}
```

Omits `namespace` (defaults to `galactic-system`), `ipam`, and `terminations`.
Without `GALACTIC_CNI_ENABLE_LOCAL_IPAM` set, no IP address is assigned to the
Omits `namespace` (defaults to `galactic-system`) and `ipam`, and has no
`galactic-route` chain entry (see [Configuration with terminations](#configuration-with-terminations)
below) since there are no static routes to install. Without
`GALACTIC_CNI_ENABLE_LOCAL_IPAM` set, no IP address is assigned to the
guest interface. With `GALACTIC_CNI_ENABLE_LOCAL_IPAM` set, a subnet is allocated
from the built-in pool.

Expand Down Expand Up @@ -304,26 +319,38 @@ carries only the IPv4 `/32` prefix.

### Configuration with terminations

`terminations` goes in `galactic-route`'s own stanza of the conflist's
`plugins` array, not the master plugin's:

```json
{
"cniVersion": "1.0.0",
"name": "galactic",
"type": "galactic-cni",
"vpc": "1",
"vpcattachment": "1",
"terminations": [
{ "network": "fd00::/48", "via": "fe80::1" },
{ "network": "fd01::/48" }
],
"ipam": {
"type": "pool",
"pool": "fd00:1:ff01::/48"
}
"plugins": [
{
"type": "galactic-cni",
"vpc": "1",
"vpcattachment": "1",
"ipam": {
"type": "pool",
"pool": "fd00:1:ff01::/48"
}
},
{
"type": "galactic-route",
"vpc": "1",
"vpcattachment": "1",
"terminations": [
{ "network": "fd00::/48", "via": "fe80::1" },
{ "network": "fd01::/48" }
]
}
]
}
```

The first termination installs a specific next-hop route; the second installs
a link-local route via the host-side device.
an on-link route via the host-side device.

### Tap interface configuration (VM workloads)

Expand Down
3 changes: 0 additions & 3 deletions internal/cni/cni_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -825,9 +825,6 @@ func TestResourceTrackerFieldsSet(t *testing.T) {
if tracker.vrfCreated {
t.Error("vrfCreated should be false by default")
}
if tracker.routesCreated != 0 {
t.Error("routesCreated should be zero by default")
}
}

// ---- cmdStatus ---------------------------------------------------------
Expand Down
13 changes: 2 additions & 11 deletions internal/cni/ops_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/vishvananda/netlink"

"go.datum.net/galactic/internal/cni/nadpatch"
"go.datum.net/galactic/internal/cni/route"
"go.datum.net/galactic/internal/cni/veth"
"go.datum.net/galactic/internal/plumbing/intf"
"go.datum.net/galactic/internal/plumbing/vrf"
Expand Down Expand Up @@ -114,16 +113,8 @@ func cmdAdd(args *skel.CmdArgs) (err error) {
return fmt.Errorf("annotate NAD: %w", err)
}

dev := hostName
for _, termination := range pluginConf.Terminations {
if err := route.Add(pluginConf.VPC, pluginConf.VPCAttachment, termination.Network, termination.Via, dev); err != nil {
return fmt.Errorf("add route %s: %w", termination.Network, err)
}
tracker.routesCreated++
}
if tracker.routesCreated > 0 {
slog.Debug("ADD: termination routes installed", "count", tracker.routesCreated, "dev", dev)
}
// Termination routes are galactic-route's job now — chained next after
// this plugin, when the attachment has any (see internal/cniroute).

guestName := intf.GenerateInterfaceNameGuest(pluginConf.VPC, pluginConf.VPCAttachment)
return buildVethResult(args, pluginConf, hostName, guestName, hostMac, hostMTU)
Expand Down
57 changes: 3 additions & 54 deletions internal/cni/ops_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ func cmdCheck(args *skel.CmdArgs) error {
errs = append(errs, fmt.Errorf("guest interface %q: %w", guestName, err))
}

// Verify termination routes exist in the VRF table.
if err := checkTerminationRoutes(pluginConf.VPC, pluginConf.VPCAttachment, pluginConf.Terminations); err != nil {
errs = append(errs, fmt.Errorf("termination routes: %w", err))
}
// Termination routes are galactic-route's own CHECK now (see
// internal/cniroute's checkTerminationRoutes) — this plugin's CHECK no
// longer verifies them.

// Validate kernel state against prevResult (CNI spec §4.3).
if pluginConf.RawPrevResult != nil {
Expand Down Expand Up @@ -210,56 +209,6 @@ func checkGuestInterface(netnsPath, ifName string) error {
})
}

// checkTerminationRoutes verifies that all termination routes exist in the
// VRF table for the given VPC/VPCAttachment pair.
func checkTerminationRoutes(vpc, vpcAttachment string, terminations []Termination) error {
tableID, err := vrf.TableID(vpc, vpcAttachment)
if err != nil {
return fmt.Errorf("get VRF table ID: %w", err)
}

handle, err := netlink.NewHandle()
if err != nil {
return fmt.Errorf("create netlink handle: %w", err)
}
defer handle.Close() //nolint:errcheck // netlink cleanup on teardown

routes, err := handle.RouteListFiltered(
netlink.FAMILY_V6,
&netlink.Route{Table: int(tableID)},
netlink.RT_FILTER_TABLE,
)
if err != nil {
return fmt.Errorf("list routes: %w", err)
}

dev := intf.GenerateInterfaceNameHost(vpc, vpcAttachment)
for _, term := range terminations {
viaIP := net.ParseIP(term.Via)
if viaIP == nil {
return fmt.Errorf("invalid termination gateway %q", term.Via)
}
found := false
for _, r := range routes {
if r.Dst != nil &&
r.Dst.String() == term.Network &&
r.Gw != nil &&
r.Gw.Equal(viaIP) &&
r.LinkIndex > 0 {
// Verify the link name matches (defers to the veth/tap device).
if link, linkErr := handle.LinkByIndex(r.LinkIndex); linkErr == nil && link.Attrs().Name == dev {
found = true
break
}
}
}
if !found {
return fmt.Errorf("missing route %s via %s in VRF table %d", term.Network, term.Via, tableID)
}
}
return nil
}

// checkPrevResult validates that kernel state matches the interfaces and IPs
// recorded in the prevResult returned by the most recent ADD. Per the CNI spec
// §4.3, CHECK must verify that managed resources have not drifted.
Expand Down
Loading