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
22 changes: 13 additions & 9 deletions cmd/galactic-router/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ func runCmd(cfg *config.RouterConfig) error {
// controller process before informer reconciliation starts. A missing map is
// tolerated because the datapath loader may start after galactic-router;
// the first Apply retries initialization.
if err := setupServiceRouteController(mgr, nodeName, cfg.GCNamespace, serviceRouteMetrics); err != nil {
if err := setupServiceRouteController(mgr, nodeName, cfg.GCNamespace, cfg.ServiceFrontendEnabled,
serviceRouteMetrics); err != nil {
return fmt.Errorf("setup ServiceRoutePolicy controller: %w", err)
}

Expand Down Expand Up @@ -351,7 +352,7 @@ func runCmd(cfg *config.RouterConfig) error {

func setupServiceRouteController(
mgr ctrl.Manager,
nodeName, bgpNamespace string,
nodeName, bgpNamespace string, frontendEnabled bool,
serviceRouteMetrics *serviceroute.Metrics,
) error {
programmer := &serviceroute.EBPFRouteProgrammer{}
Expand All @@ -360,13 +361,14 @@ func setupServiceRouteController(
}

return (&controller.ServiceRoutePolicyReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
NodeName: nodeName,
BGPNamespace: bgpNamespace,
Programmer: programmer,
Metrics: serviceRouteMetrics,
Applied: make(map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent),
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
NodeName: nodeName,
BGPNamespace: bgpNamespace,
Programmer: programmer,
Metrics: serviceRouteMetrics,
FrontendEnabled: frontendEnabled,
Applied: make(map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent),
}).SetupWithManager(mgr)
}

Expand Down Expand Up @@ -415,6 +417,8 @@ func newRootCommand() *cobra.Command {
cmd.Flags().DurationP("gc-interval", "",
config.DefaultRouterGCInterval,
"Cleanup interval")
cmd.Flags().Bool("service-frontend-enabled", false,
"Enable consumer-facing private-service address translation")
cmd.Flags().Bool("webhook-enabled", false,
"Enable the NetworkRule admission webhook (requires TLS cert material; see config/webhook/)")
cmd.Flags().IntP("webhook-port", "",
Expand Down
1 change: 1 addition & 0 deletions config/galactic-router/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ rules:
verbs: ["get", "update", "patch"]
- apiGroups: ["cloud.datumapis.com"]
resources:
- vpcs
- vpcattachments
verbs: ["get", "list", "watch"]
- apiGroups: [""]
Expand Down
34 changes: 24 additions & 10 deletions docs/router/private-service-routes.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Private service routes

`ServiceEndpoint` uses a direct-endpoint contract. Its declared
`ServiceEndpoint` declares the producer destination. Its declared
`spec.address`, `spec.protocol`, and `spec.port` are the exact destination tuple
that the selected producer attachment must accept. Galactic does not interpret
the address as a Kubernetes Service frontend: it does not discover
EndpointSlices, select a backend address, or translate the destination before
delivery.
EndpointSlices or select a Kubernetes backend. A translated policy uses
`spec.frontend.address` inside the consumer VPC and translates it to the
endpoint address before delivery.

The controller that publishes the `ServiceEndpoint` owns the service
implementation and health. It must ensure every attachment selected by
Expand All @@ -30,15 +31,12 @@ carries the original packet through the SRv6 service tunnel. In both cases the
producer receives the declared destination and the original consumer source
unchanged.

The [Private Service Connect proposal](../enhancements/networking/private-service-connect/README.md)
describes the product capability and proposed frontend integration.

## No consumer-visible route

Galactic does not add the service address or a service-specific route to a
consumer, and does not inject a route into a consumer guest. Consumers send the
packet through their ordinary default gateway. The attachment's TC-eBPF
classifier matches the exact service tuple and redirects it to the selected
classifier matches the exact service or frontend tuple and redirects it to the selected
producer; unauthorized ports are dropped. Galactic also does not install a
host route for the service address. The producer service may still need to
configure or bind the address locally so its own network stack or listener
Expand All @@ -63,6 +61,22 @@ does not reference a `ServiceVIPBinding`, and the private service-route
dataplane does not consult that translation to discover a backend. Reusing the
same address in both APIs does not connect the two mechanisms.

If a service needs a distinct frontend and backend address or port, its API
must identify that backend explicitly and define its selection and health
semantics. `ServiceEndpoint` intentionally carries no such fields.
## Consumer service frontends

Frontend translation is disabled by default. Enable it on participating routers
with `--service-frontend-enabled` or `GALACTIC_ROUTER_SERVICE_FRONTEND_ENABLED=true`.
The publishing controller supplies the producer address in `ServiceEndpoint`;
`ServiceRoutePolicy.spec.frontend.address` supplies the consumer address.
Both addresses must use the same IP family. Ports are not translated.

A translated policy must pin the consumer VPC by name and UID and include an
`authorization.validUntil` deadline within the next two minutes. Selected
attachments must match that VPC's allocated network identity. Kernel checks
stop new requests and established replies when the authorization expires, even
if the controller or its API is unavailable. Renew authorization explicitly to
restore access.

Policies claiming the same frontend address, protocol, and port on one consumer
attachment are rejected together. Separate VPC attachments can reuse that tuple.
The publisher owns authorization renewal and service health; Galactic enforces
network access and forwards packets. It does not interpret DNS records or zones.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/vishvananda/netlink v1.3.2-0.20261002201547-1d1c62507e3c
go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c
go.datum.net/network v0.3.1-0.20261006185716-0f099eb3b0ea
go.datum.net/network v0.3.1-0.20261008185758-e391e93cf3de
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c h1:k0P5980+kS90zCa0M4Moh
go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c/go.mod h1:HTeaepXL0EqiqNsQ13K4/kGEwv+VLJ//BGrUfVIFfEA=
go.datum.net/network v0.3.1-0.20261006185716-0f099eb3b0ea h1:q/8ZPV2fjLwFTpTepNn/9HigADL9c9nQHWKKGWMRGHQ=
go.datum.net/network v0.3.1-0.20261006185716-0f099eb3b0ea/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE=
go.datum.net/network v0.3.1-0.20261008185758-e391e93cf3de h1:vGtnfrEwHrnH2YipcqgobhfPRFaA6OEtDFlvKYC+z9E=
go.datum.net/network v0.3.1-0.20261008185758-e391e93cf3de/go.mod h1:LawI34k+ddtv3kRbmjiss0Lb6QKpSr60YP3fX1IG5RU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
Expand Down
35 changes: 20 additions & 15 deletions internal/config/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ var RouterBMPPolicies = []string{"pre-policy", "post-policy", "local-rib", "all"
// --- Router environment variable keys --------------------------------------

const (
EnvRouterNodeName = "GALACTIC_ROUTER_NODE_NAME"
EnvRouterReflector = "GALACTIC_ROUTER_REFLECTOR"
EnvRouterBGPListenPort = "GALACTIC_ROUTER_BGP_LISTEN_PORT"
EnvRouterBGPLocalAddr = "GALACTIC_ROUTER_BGP_LOCAL_ADDRESS"
EnvRouterMetricsPort = "GALACTIC_ROUTER_METRICS_PORT"
EnvRouterGRPCHealthPort = "GALACTIC_ROUTER_GRPC_HEALTH_PORT"
EnvRouterGCNamespace = "GALACTIC_ROUTER_GC_NAMESPACE"
EnvRouterGCInterval = "GALACTIC_ROUTER_GC_INTERVAL"
EnvRouterNodeName = "GALACTIC_ROUTER_NODE_NAME"
EnvRouterReflector = "GALACTIC_ROUTER_REFLECTOR"
EnvRouterBGPListenPort = "GALACTIC_ROUTER_BGP_LISTEN_PORT"
EnvRouterBGPLocalAddr = "GALACTIC_ROUTER_BGP_LOCAL_ADDRESS"
EnvRouterMetricsPort = "GALACTIC_ROUTER_METRICS_PORT"
EnvRouterGRPCHealthPort = "GALACTIC_ROUTER_GRPC_HEALTH_PORT"
EnvRouterGCNamespace = "GALACTIC_ROUTER_GC_NAMESPACE"
EnvRouterGCInterval = "GALACTIC_ROUTER_GC_INTERVAL"
EnvRouterServiceFrontendEnabled = "GALACTIC_ROUTER_SERVICE_FRONTEND_ENABLED"

// EnvRouterWebhookEnabled gates the NetworkRule admission webhook. It
// defaults to false: enabling it requires TLS cert material this repo does
Expand Down Expand Up @@ -93,13 +94,14 @@ type RouterConfig struct {
// mesh. A distinct signal from the listen port: whether a node accepts
// inbound connections is not the same property as whether it is the
// fabric's route reflector.
Reflector bool
BGPListenPort int
BGPLocalAddr string
MetricsPort int
GRPCHealthPort int
GCNamespace string
GCInterval time.Duration
Reflector bool
BGPListenPort int
BGPLocalAddr string
MetricsPort int
GRPCHealthPort int
GCNamespace string
GCInterval time.Duration
ServiceFrontendEnabled bool

// WebhookEnabled, WebhookPort, and WebhookCertDir configure the NetworkRule
// admission webhook. Disabled by default.
Expand Down Expand Up @@ -131,6 +133,7 @@ func NewRouterConfig() *RouterConfig {
v.SetDefault(KeyGRPCHealthPort, DefaultRouterGRPCHealthPort)
v.SetDefault("gc_namespace", DefaultRouterGCNamespace)
v.SetDefault("gc_interval", DefaultRouterGCInterval.String())
v.SetDefault("service_frontend_enabled", false)
v.SetDefault("webhook_enabled", false)
v.SetDefault("webhook_port", DefaultRouterWebhookPort)
v.SetDefault("webhook_cert_dir", "")
Expand Down Expand Up @@ -161,6 +164,7 @@ func (c *RouterConfig) BindFlags(flags *pflag.FlagSet) {
{FlagGRPCHealthPort, KeyGRPCHealthPort},
{"gc-namespace", "gc_namespace"},
{"gc-interval", "gc_interval"},
{"service-frontend-enabled", "service_frontend_enabled"},
{"webhook-enabled", "webhook_enabled"},
{"webhook-port", "webhook_port"},
{"webhook-cert-dir", "webhook_cert_dir"},
Expand Down Expand Up @@ -189,6 +193,7 @@ func (c *RouterConfig) readFields() {
c.GRPCHealthPort = c.v.GetInt(KeyGRPCHealthPort)
c.GCNamespace = c.v.GetString("gc_namespace")
c.GCInterval = c.v.GetDuration("gc_interval")
c.ServiceFrontendEnabled = c.v.GetBool("service_frontend_enabled")
c.WebhookEnabled = c.v.GetBool("webhook_enabled")
c.WebhookPort = c.v.GetInt("webhook_port")
c.WebhookCertDir = c.v.GetString("webhook_cert_dir")
Expand Down
10 changes: 10 additions & 0 deletions internal/config/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ func TestRouterConfigDefaults(t *testing.T) {
if cfg.Reflector {
t.Error("Reflector = true, want false")
}
if cfg.ServiceFrontendEnabled {
t.Error("service frontends must be disabled by default")
}
if cfg.WebhookEnabled {
t.Error("WebhookEnabled = true, want false (disabled by default)")
}
Expand Down Expand Up @@ -309,3 +312,10 @@ func TestParseBMPStation(t *testing.T) {
})
}
}

func TestRouterConfigServiceFrontendEnv(t *testing.T) {
t.Setenv(EnvRouterServiceFrontendEnabled, testBoolTrue)
if !NewRouterConfig().ServiceFrontendEnabled {
t.Fatal("explicit service frontend enablement was ignored")
}
}
84 changes: 84 additions & 0 deletions internal/controller/serviceroute_access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2026 Datum Cloud, Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later

package controller

import (
"context"
"errors"
"fmt"
"net"
"time"

"k8s.io/apimachinery/pkg/types"

cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1"
"go.datum.net/galactic/internal/serviceroute"
networkv1alpha1 "go.datum.net/network/api/v1alpha1"
)

const maxServiceAuthorizationDuration = 2 * time.Minute

func (r *ServiceRoutePolicyReconciler) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}

func (r *ServiceRoutePolicyReconciler) validateAuthorization(policy *networkv1alpha1.ServiceRoutePolicy) error {
if policy.Spec.Frontend == nil {
return nil
}
if policy.Spec.Authorization == nil {
return errors.New("frontend translation requires authorization.validUntil")
}
deadline := policy.Spec.Authorization.ValidUntil.Time
now := r.now()
if !deadline.After(now) || deadline.After(now.Add(maxServiceAuthorizationDuration)) {
return errors.New("network authorization deadline must be within the next two minutes")
}
return nil
}

func (r *ServiceRoutePolicyReconciler) conflictingPolicies(
ctx context.Context, attachments []*cloudv1alpha1.VPCAttachment,
) (map[types.NamespacedName]bool, error) {
policies := &networkv1alpha1.ServiceRoutePolicyList{}
if err := r.List(ctx, policies); err != nil {
return nil, fmt.Errorf("list service policy conflicts: %w", err)
}
desired := map[types.NamespacedName][]serviceroute.RouteIntent{}
translated := map[types.NamespacedName]bool{}
nodes := map[string]bool{}
for _, a := range attachments {
nodes[a.Status.Node] = true
}
for i := range policies.Items {
p := &policies.Items[i]
if p.DeletionTimestamp != nil || r.validateAuthorization(p) != nil || (p.Spec.Frontend != nil && !r.FrontendEnabled) {
continue
}
endpoint := &networkv1alpha1.ServiceEndpoint{}
if err := r.Get(ctx, types.NamespacedName{
Namespace: p.Namespace,
Name: p.Spec.ServiceRef.Name,
}, endpoint); err != nil {
continue
}
identity, err := r.consumerVPCIdentity(ctx, p)
if err != nil {
continue
}
key := types.NamespacedName{Namespace: p.Namespace, Name: p.Name}
translated[key] = p.Spec.Frontend != nil
for node := range nodes {
intents, err := serviceroute.CompileForConsumerVPC(p, endpoint, attachments, node,
func(*cloudv1alpha1.VPCAttachment) (net.IP, error) { return net.ParseIP("fd00::1"), nil }, identity)
if err == nil {
desired[key] = append(desired[key], intents...)
}
}
}
return serviceroute.ConflictingPolicies(desired, translated), nil
}
Loading
Loading