From 96305ad6f35b5c3f2a2d8147f34fbb559b52a91d Mon Sep 17 00:00:00 2001 From: ca110us Date: Mon, 9 Feb 2026 17:23:13 +0700 Subject: [PATCH 1/2] feat: add hub-spoke ACL policy for privileged networks Add PRIVILEGED_NETWORKS config (comma-separated Headscale usernames) that switches from pure isolation to hub-spoke ACL model. Privileged networks get full access to all nodes; normal networks remain isolated from each other. On startup, full ACL is rebuilt; new WonderNets use incremental append since privileged *:* rules already cover them. Co-Authored-By: Claude Opus 4.6 --- cmd/wonder/commands/coordinator.go | 15 ++++ internal/app/coordinator/config.go | 4 + internal/app/coordinator/server.go | 2 +- internal/app/coordinator/service/wondernet.go | 10 ++- pkg/headscale/acl.go | 66 +++++++++++++++ pkg/headscale/acl_test.go | 81 +++++++++++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 pkg/headscale/acl_test.go diff --git a/cmd/wonder/commands/coordinator.go b/cmd/wonder/commands/coordinator.go index 095bd3c..f301f81 100644 --- a/cmd/wonder/commands/coordinator.go +++ b/cmd/wonder/commands/coordinator.go @@ -3,6 +3,7 @@ package commands import ( "log/slog" "os" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -44,6 +45,7 @@ func NewCoordinatorCmd() *cobra.Command { _ = viper.BindEnv("coordinator.keycloak_client_secret", "KEYCLOAK_CLIENT_SECRET") _ = viper.BindEnv("coordinator.enable_admin_api", "ENABLE_ADMIN_API") _ = viper.BindEnv("coordinator.admin_api_auth_token", "ADMIN_API_AUTH_TOKEN") + _ = viper.BindEnv("coordinator.privileged_networks", "PRIVILEGED_NETWORKS") return cmd } @@ -66,6 +68,15 @@ func runCoordinator(cmd *cobra.Command, args []string) { cfg.EnableAdminAPI = viper.GetBool("coordinator.enable_admin_api") cfg.AdminAPIAuthToken = viper.GetString("coordinator.admin_api_auth_token") + if networks := viper.GetString("coordinator.privileged_networks"); networks != "" { + for _, n := range strings.Split(networks, ",") { + n = strings.TrimSpace(n) + if n != "" { + cfg.PrivilegedNetworks = append(cfg.PrivilegedNetworks, n) + } + } + } + if cfg.HeadscaleURL == "" { cfg.HeadscaleURL = coordinator.DefaultHeadscaleURL } @@ -101,6 +112,10 @@ func runCoordinator(cmd *cobra.Command, args []string) { slog.Info("admin API enabled") } + if len(cfg.PrivilegedNetworks) > 0 { + slog.Info("privileged networks configured", "networks", cfg.PrivilegedNetworks) + } + server, err := coordinator.BootstrapNewServer(&cfg) if err != nil { slog.Error("create server", "error", err) diff --git a/internal/app/coordinator/config.go b/internal/app/coordinator/config.go index 3d5091e..d8f1a11 100644 --- a/internal/app/coordinator/config.go +++ b/internal/app/coordinator/config.go @@ -33,6 +33,10 @@ type Config struct { // AdminAPIAuthToken is the bearer token for admin API authentication. // Required if EnableAdminAPI is true. Must be at least 32 characters. AdminAPIAuthToken string `mapstructure:"admin_api_auth_token"` + + // PrivilegedNetworks is the list of Headscale usernames that have access to all + // WonderNets (hub-spoke ACL model). When empty, pure isolation policy is used. + PrivilegedNetworks []string } const ( diff --git a/internal/app/coordinator/server.go b/internal/app/coordinator/server.go index 3d3b9d8..8a3c557 100644 --- a/internal/app/coordinator/server.go +++ b/internal/app/coordinator/server.go @@ -114,7 +114,7 @@ func BootstrapNewServer(config *Config) (*Server, error) { meshBackend := tailscale.NewTailscaleMesh(headscaleClient, config.PublicURL) // Create services - wonderNetService := service.NewWonderNetService(wonderNetRepository, wonderNetManager, aclManager, config.PublicURL) + wonderNetService := service.NewWonderNetService(wonderNetRepository, wonderNetManager, aclManager, config.PublicURL, config.PrivilegedNetworks) workerService := service.NewWorkerService(tokenGenerator, config.JWTSecret, wonderNetRepository, meshBackend) nodesService := service.NewNodesService(meshBackend) apiKeyService := service.NewAPIKeyService(apiKeyRepository, wonderNetRepository) diff --git a/internal/app/coordinator/service/wondernet.go b/internal/app/coordinator/service/wondernet.go index 7041ba0..29d913b 100644 --- a/internal/app/coordinator/service/wondernet.go +++ b/internal/app/coordinator/service/wondernet.go @@ -23,6 +23,7 @@ type WonderNetService struct { wonderNetManager *headscale.WonderNetManager aclManager *headscale.ACLManager publicURL string + privilegedNetworks []string } // NewWonderNetService creates a new WonderNetService. @@ -31,12 +32,14 @@ func NewWonderNetService( wonderNetManager *headscale.WonderNetManager, aclManager *headscale.ACLManager, publicURL string, + privilegedNetworks []string, ) *WonderNetService { return &WonderNetService{ wonderNetRepository: wonderNetRepository, wonderNetManager: wonderNetManager, aclManager: aclManager, publicURL: publicURL, + privilegedNetworks: privilegedNetworks, } } @@ -93,8 +96,13 @@ func (s *WonderNetService) GetPublicURL() string { return s.publicURL } -// InitializeACLPolicy rebuilds the ACL policy from all existing WonderNets to enforce isolation. +// InitializeACLPolicy rebuilds the full ACL policy from all existing Headscale users. +// When a privileged network is configured, a hub-spoke policy is used; +// otherwise, pure isolation policy is applied. func (s *WonderNetService) InitializeACLPolicy(ctx context.Context) error { + if len(s.privilegedNetworks) > 0 { + return s.aclManager.SetHubSpokePolicy(ctx, s.privilegedNetworks) + } return s.aclManager.SetWonderNetIsolationPolicy(ctx) } diff --git a/pkg/headscale/acl.go b/pkg/headscale/acl.go index 5916b66..60d7239 100644 --- a/pkg/headscale/acl.go +++ b/pkg/headscale/acl.go @@ -41,6 +41,38 @@ func GenerateWonderNetIsolationPolicy(usernames []string) *ACLPolicy { } } +// GenerateHubSpokePolicy generates an ACL policy where privileged namespaces +// can access all nodes, and all nodes can reply to them, while normal namespaces +// are isolated from each other. +func GenerateHubSpokePolicy(privilegedUsers []string, normalUsers []string) *ACLPolicy { + rules := make([]ACLRule, 0, 2*len(privilegedUsers)+len(normalUsers)) + + for _, user := range privilegedUsers { + rules = append(rules, + ACLRule{ + Action: "accept", + Sources: []string{user + "@"}, + Destinations: []string{"*:*"}, + }, + ACLRule{ + Action: "accept", + Sources: []string{"*"}, + Destinations: []string{user + "@:*"}, + }, + ) + } + + for _, username := range normalUsers { + rules = append(rules, ACLRule{ + Action: "accept", + Sources: []string{username + "@"}, + Destinations: []string{username + "@:*"}, + }) + } + + return &ACLPolicy{ACLs: rules} +} + // ACLManager manages ACL policies in Headscale type ACLManager struct { client v1.HeadscaleServiceClient @@ -78,6 +110,40 @@ func (am *ACLManager) SetWonderNetIsolationPolicy(ctx context.Context) error { return err } +// SetHubSpokePolicy sets an ACL policy where privileged namespaces can access +// all nodes while normal namespaces are isolated from each other. +func (am *ACLManager) SetHubSpokePolicy(ctx context.Context, privilegedUsers []string) error { + am.mu.Lock() + defer am.mu.Unlock() + + resp, err := am.client.ListUsers(ctx, &v1.ListUsersRequest{}) + if err != nil { + return fmt.Errorf("list users: %w", err) + } + + privilegedSet := make(map[string]struct{}, len(privilegedUsers)) + for _, u := range privilegedUsers { + privilegedSet[u] = struct{}{} + } + + var normalUsers []string + for _, u := range resp.GetUsers() { + name := u.GetName() + if _, ok := privilegedSet[name]; !ok { + normalUsers = append(normalUsers, name) + } + } + + policy := GenerateHubSpokePolicy(privilegedUsers, normalUsers) + policyJSON, err := json.Marshal(policy) + if err != nil { + return fmt.Errorf("marshal policy: %w", err) + } + + _, err = am.client.SetPolicy(ctx, &v1.SetPolicyRequest{Policy: string(policyJSON)}) + return err +} + // AddWonderNetToPolicy adds a wonder net to the isolation policy func (am *ACLManager) AddWonderNetToPolicy(ctx context.Context, username string) error { am.mu.Lock() diff --git a/pkg/headscale/acl_test.go b/pkg/headscale/acl_test.go new file mode 100644 index 0000000..7d25430 --- /dev/null +++ b/pkg/headscale/acl_test.go @@ -0,0 +1,81 @@ +package headscale + +import ( + "testing" +) + +func TestGenerateWonderNetIsolationPolicy(t *testing.T) { + policy := GenerateWonderNetIsolationPolicy([]string{"user1", "user2"}) + + if len(policy.ACLs) != 2 { + t.Fatalf("expected 2 rules, got %d", len(policy.ACLs)) + } + + assertRule(t, policy.ACLs[0], "accept", []string{"user1@"}, []string{"user1@:*"}) + assertRule(t, policy.ACLs[1], "accept", []string{"user2@"}, []string{"user2@:*"}) +} + +func TestGenerateHubSpokePolicy(t *testing.T) { + policy := GenerateHubSpokePolicy([]string{"zeabur"}, []string{"uuid1", "uuid2"}) + + // 2 rules for privileged (outbound + inbound) + 2 rules for normal users + if len(policy.ACLs) != 4 { + t.Fatalf("expected 4 rules, got %d", len(policy.ACLs)) + } + + assertRule(t, policy.ACLs[0], "accept", []string{"zeabur@"}, []string{"*:*"}) + assertRule(t, policy.ACLs[1], "accept", []string{"*"}, []string{"zeabur@:*"}) + assertRule(t, policy.ACLs[2], "accept", []string{"uuid1@"}, []string{"uuid1@:*"}) + assertRule(t, policy.ACLs[3], "accept", []string{"uuid2@"}, []string{"uuid2@:*"}) +} + +func TestGenerateHubSpokePolicy_MultiplePrivileged(t *testing.T) { + policy := GenerateHubSpokePolicy([]string{"zeabur", "admin"}, []string{"uuid1"}) + + // 2 rules per privileged user (2*2=4) + 1 normal user + if len(policy.ACLs) != 5 { + t.Fatalf("expected 5 rules, got %d", len(policy.ACLs)) + } + + assertRule(t, policy.ACLs[0], "accept", []string{"zeabur@"}, []string{"*:*"}) + assertRule(t, policy.ACLs[1], "accept", []string{"*"}, []string{"zeabur@:*"}) + assertRule(t, policy.ACLs[2], "accept", []string{"admin@"}, []string{"*:*"}) + assertRule(t, policy.ACLs[3], "accept", []string{"*"}, []string{"admin@:*"}) + assertRule(t, policy.ACLs[4], "accept", []string{"uuid1@"}, []string{"uuid1@:*"}) +} + +func TestGenerateHubSpokePolicy_NoNormalUsers(t *testing.T) { + policy := GenerateHubSpokePolicy([]string{"zeabur"}, nil) + + if len(policy.ACLs) != 2 { + t.Fatalf("expected 2 rules, got %d", len(policy.ACLs)) + } + + assertRule(t, policy.ACLs[0], "accept", []string{"zeabur@"}, []string{"*:*"}) + assertRule(t, policy.ACLs[1], "accept", []string{"*"}, []string{"zeabur@:*"}) +} + +func assertRule(t *testing.T, rule ACLRule, action string, src, dst []string) { + t.Helper() + if rule.Action != action { + t.Errorf("expected action %q, got %q", action, rule.Action) + } + if len(rule.Sources) != len(src) { + t.Errorf("expected %d sources, got %d", len(src), len(rule.Sources)) + return + } + for i := range src { + if rule.Sources[i] != src[i] { + t.Errorf("source[%d]: expected %q, got %q", i, src[i], rule.Sources[i]) + } + } + if len(rule.Destinations) != len(dst) { + t.Errorf("expected %d destinations, got %d", len(dst), len(rule.Destinations)) + return + } + for i := range dst { + if rule.Destinations[i] != dst[i] { + t.Errorf("destination[%d]: expected %q, got %q", i, dst[i], rule.Destinations[i]) + } + } +} From d53f32bdcdaae80e2fcca10eadf283dca9b2f57e Mon Sep 17 00:00:00 2001 From: ca110us Date: Tue, 10 Feb 2026 13:06:41 +0700 Subject: [PATCH 2/2] fix: address PR review comments - Remove wildcard ingress rule into privileged namespaces; Tailscale ACLs are directional and reply traffic flows back over established connections - Add --privileged-networks cobra flag (StringArray, repeatable) Co-Authored-By: Claude Opus 4.6 --- cmd/wonder/commands/coordinator.go | 31 ++++++++++++++++++++++-------- pkg/headscale/acl.go | 25 ++++++++++-------------- pkg/headscale/acl_test.go | 28 ++++++++++++--------------- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/cmd/wonder/commands/coordinator.go b/cmd/wonder/commands/coordinator.go index f301f81..a6b60e7 100644 --- a/cmd/wonder/commands/coordinator.go +++ b/cmd/wonder/commands/coordinator.go @@ -25,12 +25,14 @@ func NewCoordinatorCmd() *cobra.Command { cmd.Flags().String("db-driver", "sqlite", "Database driver (sqlite or postgres)") cmd.Flags().String("db-dsn", "", "Database connection string") cmd.Flags().Bool("enable-admin-api", false, "Enable admin API endpoints") + cmd.Flags().StringArray("privileged-networks", nil, "Headscale usernames with hub-spoke access to all WonderNets (repeatable)") _ = viper.BindPFlag("coordinator.listen", cmd.Flags().Lookup("listen")) _ = viper.BindPFlag("coordinator.public_url", cmd.Flags().Lookup("public-url")) _ = viper.BindPFlag("coordinator.database_driver", cmd.Flags().Lookup("db-driver")) _ = viper.BindPFlag("coordinator.database_dsn", cmd.Flags().Lookup("db-dsn")) _ = viper.BindPFlag("coordinator.enable_admin_api", cmd.Flags().Lookup("enable-admin-api")) + _ = viper.BindPFlag("coordinator.privileged_networks", cmd.Flags().Lookup("privileged-networks")) _ = viper.BindEnv("coordinator.listen", "LISTEN") _ = viper.BindEnv("coordinator.public_url", "PUBLIC_URL") @@ -68,14 +70,7 @@ func runCoordinator(cmd *cobra.Command, args []string) { cfg.EnableAdminAPI = viper.GetBool("coordinator.enable_admin_api") cfg.AdminAPIAuthToken = viper.GetString("coordinator.admin_api_auth_token") - if networks := viper.GetString("coordinator.privileged_networks"); networks != "" { - for _, n := range strings.Split(networks, ",") { - n = strings.TrimSpace(n) - if n != "" { - cfg.PrivilegedNetworks = append(cfg.PrivilegedNetworks, n) - } - } - } + cfg.PrivilegedNetworks = parseStringSlice(viper.Get("coordinator.privileged_networks")) if cfg.HeadscaleURL == "" { cfg.HeadscaleURL = coordinator.DefaultHeadscaleURL @@ -126,3 +121,23 @@ func runCoordinator(cmd *cobra.Command, args []string) { slog.Error("shutdown error", "error", err) } } + +// parseStringSlice converts a viper value to []string. +// Handles []string from cobra StringArray flags and comma-separated string from env vars. +func parseStringSlice(val any) []string { + switch v := val.(type) { + case []string: + return v + case string: + var result []string + for _, n := range strings.Split(v, ",") { + n = strings.TrimSpace(n) + if n != "" { + result = append(result, n) + } + } + return result + default: + return nil + } +} diff --git a/pkg/headscale/acl.go b/pkg/headscale/acl.go index 60d7239..2430917 100644 --- a/pkg/headscale/acl.go +++ b/pkg/headscale/acl.go @@ -42,24 +42,19 @@ func GenerateWonderNetIsolationPolicy(usernames []string) *ACLPolicy { } // GenerateHubSpokePolicy generates an ACL policy where privileged namespaces -// can access all nodes, and all nodes can reply to them, while normal namespaces -// are isolated from each other. +// can initiate connections to all nodes, while normal namespaces are isolated +// from each other. Tailscale ACLs are directional and control connection +// initiation only; reply traffic flows back over established connections +// without needing a separate rule. func GenerateHubSpokePolicy(privilegedUsers []string, normalUsers []string) *ACLPolicy { - rules := make([]ACLRule, 0, 2*len(privilegedUsers)+len(normalUsers)) + rules := make([]ACLRule, 0, len(privilegedUsers)+len(normalUsers)) for _, user := range privilegedUsers { - rules = append(rules, - ACLRule{ - Action: "accept", - Sources: []string{user + "@"}, - Destinations: []string{"*:*"}, - }, - ACLRule{ - Action: "accept", - Sources: []string{"*"}, - Destinations: []string{user + "@:*"}, - }, - ) + rules = append(rules, ACLRule{ + Action: "accept", + Sources: []string{user + "@"}, + Destinations: []string{"*:*"}, + }) } for _, username := range normalUsers { diff --git a/pkg/headscale/acl_test.go b/pkg/headscale/acl_test.go index 7d25430..7552b08 100644 --- a/pkg/headscale/acl_test.go +++ b/pkg/headscale/acl_test.go @@ -18,41 +18,37 @@ func TestGenerateWonderNetIsolationPolicy(t *testing.T) { func TestGenerateHubSpokePolicy(t *testing.T) { policy := GenerateHubSpokePolicy([]string{"zeabur"}, []string{"uuid1", "uuid2"}) - // 2 rules for privileged (outbound + inbound) + 2 rules for normal users - if len(policy.ACLs) != 4 { - t.Fatalf("expected 4 rules, got %d", len(policy.ACLs)) + // 1 rule for privileged (outbound only) + 2 rules for normal users + if len(policy.ACLs) != 3 { + t.Fatalf("expected 3 rules, got %d", len(policy.ACLs)) } assertRule(t, policy.ACLs[0], "accept", []string{"zeabur@"}, []string{"*:*"}) - assertRule(t, policy.ACLs[1], "accept", []string{"*"}, []string{"zeabur@:*"}) - assertRule(t, policy.ACLs[2], "accept", []string{"uuid1@"}, []string{"uuid1@:*"}) - assertRule(t, policy.ACLs[3], "accept", []string{"uuid2@"}, []string{"uuid2@:*"}) + assertRule(t, policy.ACLs[1], "accept", []string{"uuid1@"}, []string{"uuid1@:*"}) + assertRule(t, policy.ACLs[2], "accept", []string{"uuid2@"}, []string{"uuid2@:*"}) } func TestGenerateHubSpokePolicy_MultiplePrivileged(t *testing.T) { policy := GenerateHubSpokePolicy([]string{"zeabur", "admin"}, []string{"uuid1"}) - // 2 rules per privileged user (2*2=4) + 1 normal user - if len(policy.ACLs) != 5 { - t.Fatalf("expected 5 rules, got %d", len(policy.ACLs)) + // 1 rule per privileged user (2) + 1 normal user + if len(policy.ACLs) != 3 { + t.Fatalf("expected 3 rules, got %d", len(policy.ACLs)) } assertRule(t, policy.ACLs[0], "accept", []string{"zeabur@"}, []string{"*:*"}) - assertRule(t, policy.ACLs[1], "accept", []string{"*"}, []string{"zeabur@:*"}) - assertRule(t, policy.ACLs[2], "accept", []string{"admin@"}, []string{"*:*"}) - assertRule(t, policy.ACLs[3], "accept", []string{"*"}, []string{"admin@:*"}) - assertRule(t, policy.ACLs[4], "accept", []string{"uuid1@"}, []string{"uuid1@:*"}) + assertRule(t, policy.ACLs[1], "accept", []string{"admin@"}, []string{"*:*"}) + assertRule(t, policy.ACLs[2], "accept", []string{"uuid1@"}, []string{"uuid1@:*"}) } func TestGenerateHubSpokePolicy_NoNormalUsers(t *testing.T) { policy := GenerateHubSpokePolicy([]string{"zeabur"}, nil) - if len(policy.ACLs) != 2 { - t.Fatalf("expected 2 rules, got %d", len(policy.ACLs)) + if len(policy.ACLs) != 1 { + t.Fatalf("expected 1 rule, got %d", len(policy.ACLs)) } assertRule(t, policy.ACLs[0], "accept", []string{"zeabur@"}, []string{"*:*"}) - assertRule(t, policy.ACLs[1], "accept", []string{"*"}, []string{"zeabur@:*"}) } func assertRule(t *testing.T, rule ACLRule, action string, src, dst []string) {