Skip to content
Merged
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 @@ -140,6 +140,7 @@ tasks:
- 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/galactic-gateway ./cmd/galactic-gateway
- 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
109 changes: 109 additions & 0 deletions cmd/galactic-gateway/gateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright 2026 Datum Cloud, Inc.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"fmt"
"net/netip"

"github.com/cilium/ebpf/link"
"github.com/prometheus/client_golang/prometheus"

"go.datum.net/galactic/internal/gateway"
"go.datum.net/galactic/internal/plumbing/ebpf/edgeattach"
"go.datum.net/galactic/internal/plumbing/ebpf/edgemetrics"
"go.datum.net/galactic/internal/plumbing/ebpf/edgeprog"
)

// gatewayDatapathKeepAlive holds the loaded *edgeprog.EdgenatObjects and
// the attached link.Link for the life of this process, once
// setupGatewayDatapath's attach path succeeds. Neither is Closed anywhere
// in this file — see setupGatewayDatapath's doc comment for why — but a
// value that isn't stored somewhere reachable is exactly as good as
// Closed: cilium/ebpf's *ebpf.Program, *ebpf.Map, and link.Link types all
// register a runtime finalizer that closes their underlying fd once the
// garbage collector determines nothing reachable still points at them,
// with no error surfaced anywhere when that happens. gateway.KernelDatapath
// only keeps objs.RuleTable (via edgemap.KernelTable) alive on its own, so
// without this package-level var, objs.EdgeNat (the program) and the
// link.Link returned by Attach — the two things actually keeping this
// node's XDP attachment live on the wire — would eventually get GC'd and
// silently detached, with every control-plane signal (the DaemonSet pod
// healthy, ApplyRule succeeding, rule_table metrics populated) still
// looking completely normal. Confirmed live: this is exactly what happened
// the first time this path was ever exercised against a real interface
// (ingress traffic for a registered rule was never intercepted at all,
// bouncing between this node and its transit-facing peer via ordinary
// kernel routing instead) — no unit test exercises this path with a real
// attach for a GC cycle to occur during, and Phase D's "manifests and the
// live pod/eBPF path" validation predates any live underlay BGP peering
// that would have delivered real traffic to notice the gap.
//
// This var, and the rest of this file, moved here unchanged from
// cmd/galactic-router/gateway.go: the edge NAT+LB gateway datapath now
// lives in its own process rather than sharing one with the tenant BGP
// reconcilers.
var gatewayDatapathKeepAlive struct {
objs *edgeprog.EdgenatObjects
link link.Link
}

// setupGatewayDatapath loads and attaches the edge NAT+LB eBPF datapath to
// publicInterface and returns the gateway.Datapath this node's Engine
// should use. Unlike cmd/galactic-router's identically-named predecessor
// (now removed), publicInterface and
// srv6Address are both required here, not a jointly-optional pair with a
// gateway.NoopDatapath{} fallback: config.GatewayConfig.Validate already
// rejects either being empty before runCmd ever calls this function, since
// this binary only exists to run the gateway role.
//
// The loaded *edgeprog.EdgenatObjects and the returned link.Link are
// stashed in gatewayDatapathKeepAlive (see that var's doc comment for why)
// rather than Closed here: they, and the XDP attachment itself, must
// survive for the life of this process — same convention as
// internal/plumbing/ebpf/attach.Start's identical choice for the SRv6 uSID
// datapath.
//
// metricsReg additionally gets an edgemetrics.Collector registered against
// it once objs is loaded, reading rule_table/conn_table/drop_reasons live
// at every scrape — see that package's doc comment for why this is a
// pull-based Collector rather than incrementally-updated Gauges.
func setupGatewayDatapath(
publicInterface, srv6Address string, metricsReg prometheus.Registerer,
) (gateway.Datapath, error) {
gwAddr, err := netip.ParseAddr(srv6Address)
if err != nil {
return nil, fmt.Errorf("parse gateway SRv6 address %q: %w", srv6Address, err)
}

objs, err := edgeattach.Load(edgeattach.PinDir)
if err != nil {
return nil, fmt.Errorf("load edge gateway eBPF datapath: %w", err)
}

xdpLink, err := edgeattach.Attach(objs.EdgeNat, publicInterface)
if err != nil {
_ = objs.Close()
return nil, fmt.Errorf("attach edge gateway datapath to public interface %q: %w", publicInterface, err)
}

datapath, err := gateway.NewKernelDatapath(objs, gwAddr)
if err != nil {
_ = xdpLink.Close()
_ = objs.Close()
return nil, fmt.Errorf("construct kernel datapath: %w", err)
}

if err := metricsReg.Register(edgemetrics.NewCollectorFromObjects(objs)); err != nil {
_ = xdpLink.Close()
_ = objs.Close()
return nil, fmt.Errorf("register edge gateway metrics collector: %w", err)
}

gatewayDatapathKeepAlive.objs = objs
gatewayDatapathKeepAlive.link = xdpLink

return datapath, nil
}
24 changes: 24 additions & 0 deletions cmd/galactic-gateway/gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2026 Datum Cloud, Inc.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"testing"

"github.com/prometheus/client_golang/prometheus"
)

// TestSetupGatewayDatapath_InvalidAddressIsError covers the address-parse
// failure path, which runs before any kernel/eBPF interaction -- no root
// needed. Unlike cmd/galactic-router's removed identically-named test
// file, there is no "empty interface is a no-op" case to cover here: this
// binary's config.GatewayConfig.Validate rejects an empty
// PublicInterface/SRv6Address before setupGatewayDatapath is ever called.
func TestSetupGatewayDatapath_InvalidAddressIsError(t *testing.T) {
_, err := setupGatewayDatapath("eth0", "not-an-ip-address", prometheus.NewRegistry())
if err == nil {
t.Error("setupGatewayDatapath with an invalid SRv6 address: want an error, got nil")
}
}
104 changes: 104 additions & 0 deletions cmd/galactic-gateway/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2026 Datum Cloud, Inc.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

// Command galactic-gateway is the edge XDP NAT+LB gateway control plane of
// the Galactic data plane. It loads and attaches the edge NAT+LB eBPF
// program (internal/plumbing/ebpf/edgeprog) to a gateway node's public/
// underlay-facing uplink interface and drives it via the
// NetworkGateway/NetworkRule reconcilers.
//
// This binary was split out of galactic-router so that a crash on either
// side (tenant BGP vs. the XDP-holding gateway engine) no longer takes the
// other down with it. Tenant BGP (the embedded GoBGP server and the
// BGPRouter/BGPPeer/
// BGPAdvertisement/BGPPolicy/BGPVRFInstance reconcilers) still runs in
// galactic-router, co-located on the same gateway node.
package main

import (
"context"
"os"
"time"

authorizationv1 "k8s.io/api/authorization/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
networkAPIGroup = "network.datumapis.com"
networkAPIVersion = "v1alpha1"

resourceNetworkGateways = "networkgateways"
resourceNetworkRules = "networkrules"
resourceBGPAdvertisements = "bgpadvertisements"
resourceBGPRouters = "bgprouters"
)

func main() {
cmd := newRootCommand()
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}

// checkWatchPermissions issues a SelfSubjectAccessReview for each resource
// type the manager watches, checking the watch verb. If any review denies
// the request the informer cache will never sync and all reconcilers will
// be silently blocked; this logs a clear, actionable message at startup so
// the problem is immediately obvious. Mirrors
// cmd/galactic-router/main.go's identically-named function, scoped to this
// binary's own resource set instead of the BGP-family CRDs.
//
// resourceBGPRouters is included even though this binary has no BGP
// client of its own: internal/controller/usidresolver.go's
// buildBackendSIDIndex lists
// BGPRouter CRDs directly (alongside BGPAdvertisement) to resolve a
// NetworkRule backend's SRv6 uSID, so read access to bgprouters is a real
// RBAC requirement here regardless of not needing a BGP runtime.
func checkWatchPermissions(mgr ctrl.Manager) {
c, err := client.New(mgr.GetConfig(), client.Options{Scheme: mgr.GetScheme()})
if err != nil {
ctrl.Log.Error(err, "RBAC pre-flight: cannot create client, skipping check")
return
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

logger := ctrl.Log.WithName("rbac-preflight")

resources := []struct {
group string
version string
resource string
}{
{group: networkAPIGroup, version: networkAPIVersion, resource: resourceNetworkGateways},
{group: networkAPIGroup, version: networkAPIVersion, resource: resourceNetworkRules},
{group: networkAPIGroup, version: networkAPIVersion, resource: resourceBGPAdvertisements},
{group: networkAPIGroup, version: networkAPIVersion, resource: resourceBGPRouters},
}

for _, r := range resources {
review := &authorizationv1.SelfSubjectAccessReview{
Spec: authorizationv1.SelfSubjectAccessReviewSpec{
ResourceAttributes: &authorizationv1.ResourceAttributes{
Verb: "watch",
Group: r.group,
Version: r.version,
Resource: r.resource,
},
},
}
if err := c.Create(ctx, review); err != nil {
logger.Error(err, "RBAC pre-flight: failed to submit access review for "+r.resource, "verb", "watch")
continue
}
if review.Status.Allowed {
continue
}
logger.Error(nil, "missing watch RBAC for "+r.resource,
"verb", "watch", "detail", "informer cache will not sync; add resource to ServiceAccount ClusterRole and restart")
}
}
Loading