diff --git a/BUILD.md b/BUILD.md index 6886977..fd87060 100644 --- a/BUILD.md +++ b/BUILD.md @@ -64,6 +64,9 @@ Implement the server against the proto; do not fork it. - `RestartService` — last-resort restart of one protocol's service. - `WatchEvents` — **server-stream**: handshake up/down, peer connect/disconnect, errors. `helm` holds this open; this is what makes the admin UI live. +- `SetNetworkConfig` — apply the node's traffic-handling policy (DESIGN §3, + decision 16): kernel IP forwarding, source NAT, client-to-client isolation. + Idempotent — a replay of the last applied policy returns `applied=false`. ### AmneziaWG obfuscation @@ -87,6 +90,22 @@ buoy restarts and `awg` reloads — `helm` caches them. The data-plane writer (milestone B2) renders them into the `[Interface]` section of `awg0.conf` and applies it; `GetStatus` reports the same persisted set. +### Network policy + +`SetNetworkConfig` (decision 16) carries three booleans — `forwarding`, +`masquerade`, `isolation` — on `NetworkConfig`. Buoy applies them via an +**nftables** table named `pharos_buoy` (atomic `add table; delete table; +table { … }` reset, idempotent across runs) plus the `/proc/sys` forwarding +switches. The wire contract is the booleans; the choice of `nftables` vs +`iptables` vs other is buoy's. Helm's `netpolicy.Policy.Validate` already +rejects `masquerade`/`isolation` without `forwarding`; buoy repeats the +check defensively. + +Idempotency lives in `internal/netpolicy.Manager`: an in-memory +last-applied snapshot returns `applied=false` for a duplicate call. +State is not persisted — nftables rules don't survive reboot either, so a +restart correctly re-applies on the next call. + ### Data plane Manages `awg-quick@awg0` (UDP 443) and `xray.service` (TCP 443). Peer diff --git a/internal/cli/run.go b/internal/cli/run.go index 829324c..523ade1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -12,6 +12,7 @@ import ( "github.com/PharosVPN/buoy/internal/awg" "github.com/PharosVPN/buoy/internal/config" "github.com/PharosVPN/buoy/internal/control" + "github.com/PharosVPN/buoy/internal/netpolicy" "github.com/spf13/cobra" ) @@ -62,6 +63,8 @@ func newRunCmd() *cobra.Command { "conf_path", awg.DefaultConfPath, "applied_revision", awgManager.AppliedRevision()) + netPolicy := netpolicy.NewManager(netpolicy.NewNftApplier()) + srv, err := control.NewServer(control.Options{ ListenAddr: cfg.Control.ListenAddr, NodeCertPath: cfg.NodeCertPath(), @@ -70,6 +73,7 @@ func newRunCmd() *cobra.Command { Version: version, AWGNode: awgNode, AWGManager: awgManager, + NetPolicy: netPolicy, Log: log, }) if err != nil { diff --git a/internal/control/server.go b/internal/control/server.go index f03b856..70c320f 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -22,6 +22,7 @@ import ( "github.com/PharosVPN/buoy/internal/awg" buoyv1 "github.com/PharosVPN/buoy/internal/gen/pharos/buoy/v1" + "github.com/PharosVPN/buoy/internal/netpolicy" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) @@ -50,6 +51,9 @@ type Options struct { // AWGManager owns the AmneziaWG data plane (awg0.conf, live state) and // backs AddPeer / RemovePeer / ListPeers and the AmneziaWG ServiceStatus. AWGManager *awg.Manager + // NetPolicy applies the node's forwarding / masquerade / isolation + // policy on SetNetworkConfig (DESIGN §3, decision 16). + NetPolicy *netpolicy.Manager // Log receives server diagnostics. Log *slog.Logger } @@ -63,7 +67,7 @@ func NewServer(opts Options) (*Server, error) { } gs := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg))) - buoyv1.RegisterNodeControlServer(gs, newService(opts.Version, opts.AWGNode, opts.AWGManager)) + buoyv1.RegisterNodeControlServer(gs, newService(opts.Version, opts.AWGNode, opts.AWGManager, opts.NetPolicy)) return &Server{addr: opts.ListenAddr, grpc: gs, log: opts.Log}, nil } diff --git a/internal/control/server_test.go b/internal/control/server_test.go index 0f25fce..722d933 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -23,6 +23,7 @@ import ( "github.com/PharosVPN/buoy/internal/awg" buoyv1 "github.com/PharosVPN/buoy/internal/gen/pharos/buoy/v1" + "github.com/PharosVPN/buoy/internal/netpolicy" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -72,12 +73,12 @@ func TestServeAcceptsMutualTLS(t *testing.T) { t.Errorf("amneziawg.obfuscation invalid: %+v", obf) } - // Unimplemented RPCs still return a clean Unimplemented over mTLS. - // SetNetworkConfig (decision 16) is a later-milestone RPC. - _, err = buoyv1.NewNodeControlClient(conn).SetNetworkConfig(context.Background(), - &buoyv1.SetNetworkConfigRequest{Config: &buoyv1.NetworkConfig{}}) + // RestartService is still Unimplemented — used here as the canary that + // proves unwired RPCs still return a clean Unimplemented over mTLS. + _, err = buoyv1.NewNodeControlClient(conn).RestartService(context.Background(), + &buoyv1.RestartServiceRequest{Protocol: buoyv1.Protocol_PROTOCOL_AMNEZIAWG}) if status.Code(err) != codes.Unimplemented { - t.Errorf("SetNetworkConfig: got %v, want Unimplemented", err) + t.Errorf("RestartService: got %v, want Unimplemented", err) } } @@ -148,10 +149,18 @@ func testOptions(t *testing.T, dir, addr string) Options { Version: "test-version", AWGNode: node, AWGManager: mgr, + NetPolicy: netpolicy.NewManager(&nopApplier{}), Log: discardLogger(), } } +// nopApplier accepts every Policy without touching the system — control +// tests exercise the RPC surface, not the firewall. The dedicated nft +// renderer + Manager idempotency tests live under internal/netpolicy. +type nopApplier struct{} + +func (nopApplier) Apply(context.Context, netpolicy.Policy) error { return nil } + // stubRuntime is a minimal awg.Runtime for control-level tests: it reports // the interface as down and answers Show with no peers. The data-plane // orchestration is tested under package awg with a richer fake. diff --git a/internal/control/service.go b/internal/control/service.go index e929617..f22746c 100644 --- a/internal/control/service.go +++ b/internal/control/service.go @@ -10,6 +10,7 @@ import ( "github.com/PharosVPN/buoy/internal/awg" buoyv1 "github.com/PharosVPN/buoy/internal/gen/pharos/buoy/v1" + "github.com/PharosVPN/buoy/internal/netpolicy" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -32,15 +33,17 @@ type service struct { started time.Time awgNode *awg.Node awgManager *awg.Manager + netPolicy *netpolicy.Manager } // newService returns a NodeControl service implementation. -func newService(version string, awgNode *awg.Node, awgManager *awg.Manager) *service { +func newService(version string, awgNode *awg.Node, awgManager *awg.Manager, netPolicy *netpolicy.Manager) *service { return &service{ version: version, started: time.Now(), awgNode: awgNode, awgManager: awgManager, + netPolicy: netPolicy, } } @@ -81,6 +84,33 @@ func (s *service) GetStatus(ctx context.Context, _ *buoyv1.GetStatusRequest) (*b }, nil } +// SetNetworkConfig applies the node's traffic-handling policy: kernel IP +// forwarding, source NAT for forwarded traffic, and client-to-client +// isolation (DESIGN §3, decision 16). The wire contract is just three +// booleans on NetworkConfig — buoy installs them via nftables + sysctl. +// +// The reply's `applied` field reports whether the call took effect (true) +// or was an idempotent replay of the last applied policy (false). +func (s *service) SetNetworkConfig(ctx context.Context, req *buoyv1.SetNetworkConfigRequest) (*buoyv1.SetNetworkConfigResponse, error) { + nc := req.GetConfig() + if nc == nil { + return nil, status.Error(codes.InvalidArgument, "SetNetworkConfig: missing config") + } + p := netpolicy.Policy{ + Forwarding: nc.GetForwarding(), + Masquerade: nc.GetMasquerade(), + Isolation: nc.GetIsolation(), + } + if err := p.Validate(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "SetNetworkConfig: %v", err) + } + applied, err := s.netPolicy.Apply(ctx, p) + if err != nil { + return nil, status.Errorf(codes.Internal, "SetNetworkConfig: %v", err) + } + return &buoyv1.SetNetworkConfigResponse{Applied: applied}, nil +} + // WatchEvents streams live data-plane events to helm: handshake up/down, // peer connect/disconnect, observer errors. helm holds the stream open; // this is what makes the admin UI live (DESIGN §7). The first events fire diff --git a/internal/control/service_test.go b/internal/control/service_test.go index b2a39c4..0f529b1 100644 --- a/internal/control/service_test.go +++ b/internal/control/service_test.go @@ -199,6 +199,62 @@ func TestGetMetricsRPC(t *testing.T) { } } +// --- SetNetworkConfig ------------------------------------------------------- + +func TestSetNetworkConfigAppliesAndReplays(t *testing.T) { + c := startTestServer(t) + + resp, err := c.SetNetworkConfig(context.Background(), &buoyv1.SetNetworkConfigRequest{ + Config: &buoyv1.NetworkConfig{Forwarding: true, Masquerade: true}, + }) + if err != nil { + t.Fatalf("first SetNetworkConfig: %v", err) + } + if !resp.GetApplied() { + t.Error("first call should report applied=true") + } + + // An identical call is an idempotent replay. + resp, err = c.SetNetworkConfig(context.Background(), &buoyv1.SetNetworkConfigRequest{ + Config: &buoyv1.NetworkConfig{Forwarding: true, Masquerade: true}, + }) + if err != nil { + t.Fatalf("replay SetNetworkConfig: %v", err) + } + if resp.GetApplied() { + t.Error("identical replay should report applied=false") + } + + // A changed policy applies again. + resp, err = c.SetNetworkConfig(context.Background(), &buoyv1.SetNetworkConfigRequest{ + Config: &buoyv1.NetworkConfig{Forwarding: true, Isolation: true}, + }) + if err != nil { + t.Fatalf("changed SetNetworkConfig: %v", err) + } + if !resp.GetApplied() { + t.Error("changed policy should report applied=true") + } +} + +func TestSetNetworkConfigRejectsInvalid(t *testing.T) { + c := startTestServer(t) + _, err := c.SetNetworkConfig(context.Background(), &buoyv1.SetNetworkConfigRequest{ + Config: &buoyv1.NetworkConfig{Masquerade: true}, // forwarding=false + }) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("masquerade without forwarding: got %v, want InvalidArgument", err) + } +} + +func TestSetNetworkConfigMissingConfig(t *testing.T) { + c := startTestServer(t) + _, err := c.SetNetworkConfig(context.Background(), &buoyv1.SetNetworkConfigRequest{}) + if status.Code(err) != codes.InvalidArgument { + t.Errorf("missing config: got %v, want InvalidArgument", err) + } +} + // --- WatchEvents ------------------------------------------------------------ // TestWatchEventsClosesOnClientCancel proves the server-stream plumbing: diff --git a/internal/netpolicy/manager.go b/internal/netpolicy/manager.go new file mode 100644 index 0000000..e50eb1c --- /dev/null +++ b/internal/netpolicy/manager.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package netpolicy + +import ( + "context" + "sync" +) + +// Applier installs a Policy on the host. Tests substitute a fake; the +// production wiring uses *NftApplier. +type Applier interface { + Apply(ctx context.Context, p Policy) error +} + +// Manager owns the last-applied Policy and serves idempotent Apply calls. +// A call that matches the most recent applied policy returns applied=false +// without re-running the firewall transaction; anything else applies and +// records the new policy. The state lives only in memory, so a buoy +// restart re-applies on the next call (which is correct — nftables rules +// are not durable across reboots either). +type Manager struct { + applier Applier + + mu sync.Mutex + last *Policy +} + +// NewManager wraps an Applier. +func NewManager(a Applier) *Manager { + return &Manager{applier: a} +} + +// Apply installs p if it differs from the last applied policy. +// It returns (true, nil) when the call took effect, (false, nil) on an +// idempotent replay, or (false, err) on a validation or applier error. +func (m *Manager) Apply(ctx context.Context, p Policy) (bool, error) { + if err := p.Validate(); err != nil { + return false, err + } + m.mu.Lock() + defer m.mu.Unlock() + if m.last != nil && *m.last == p { + return false, nil + } + if err := m.applier.Apply(ctx, p); err != nil { + return false, err + } + clone := p + m.last = &clone + return true, nil +} + +// LastApplied returns a copy of the most-recently-applied policy, or nil +// if no policy has been applied since process start. +func (m *Manager) LastApplied() *Policy { + m.mu.Lock() + defer m.mu.Unlock() + if m.last == nil { + return nil + } + clone := *m.last + return &clone +} diff --git a/internal/netpolicy/manager_test.go b/internal/netpolicy/manager_test.go new file mode 100644 index 0000000..298b811 --- /dev/null +++ b/internal/netpolicy/manager_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package netpolicy + +import ( + "context" + "errors" + "sync" + "testing" +) + +// fakeApplier records every Apply call and can return an injected error. +type fakeApplier struct { + mu sync.Mutex + calls []Policy + failErr error +} + +func (f *fakeApplier) Apply(_ context.Context, p Policy) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failErr != nil { + return f.failErr + } + f.calls = append(f.calls, p) + return nil +} + +func (f *fakeApplier) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} + +func TestManagerAppliesFirstCall(t *testing.T) { + f := &fakeApplier{} + m := NewManager(f) + applied, err := m.Apply(context.Background(), Policy{Forwarding: true, Masquerade: true}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if !applied { + t.Error("first Apply should return applied=true") + } + if f.callCount() != 1 { + t.Errorf("applier calls = %d, want 1", f.callCount()) + } +} + +func TestManagerIdempotentReplay(t *testing.T) { + f := &fakeApplier{} + m := NewManager(f) + p := Policy{Forwarding: true, Isolation: true} + if _, err := m.Apply(context.Background(), p); err != nil { + t.Fatal(err) + } + applied, err := m.Apply(context.Background(), p) + if err != nil { + t.Fatal(err) + } + if applied { + t.Error("identical second Apply should return applied=false") + } + if f.callCount() != 1 { + t.Errorf("applier calls = %d, want 1 (replay must not re-apply)", f.callCount()) + } +} + +func TestManagerReAppliesOnChange(t *testing.T) { + f := &fakeApplier{} + m := NewManager(f) + if _, err := m.Apply(context.Background(), Policy{Forwarding: true}); err != nil { + t.Fatal(err) + } + if applied, err := m.Apply(context.Background(), + Policy{Forwarding: true, Masquerade: true}); err != nil || !applied { + t.Fatalf("apply changed policy: applied=%v err=%v", applied, err) + } + if f.callCount() != 2 { + t.Errorf("applier calls = %d, want 2 (changed policy re-applies)", f.callCount()) + } +} + +func TestManagerRejectsInvalidBeforeApplier(t *testing.T) { + f := &fakeApplier{} + m := NewManager(f) + _, err := m.Apply(context.Background(), Policy{Masquerade: true}) // forwarding=false invalid + if err == nil { + t.Fatal("Apply with invalid policy = nil error") + } + if f.callCount() != 0 { + t.Errorf("applier called %d times; must not run for invalid policy", f.callCount()) + } +} + +// TestManagerDoesNotRecordOnFailure proves a failed Apply leaves the +// last-applied state untouched — the next call with the same policy must +// still try, since the previous attempt didn't land. +func TestManagerDoesNotRecordOnFailure(t *testing.T) { + f := &fakeApplier{failErr: errors.New("nft boom")} + m := NewManager(f) + p := Policy{Forwarding: true} + if _, err := m.Apply(context.Background(), p); err == nil { + t.Fatal("Apply with failing applier = nil error") + } + if m.LastApplied() != nil { + t.Error("LastApplied should be nil after a failed Apply") + } + + // With the failure cleared, the same policy now applies — proving the + // failed attempt didn't poison the idempotency cache. + f.failErr = nil + applied, err := m.Apply(context.Background(), p) + if err != nil || !applied { + t.Fatalf("retry Apply: applied=%v err=%v", applied, err) + } +} diff --git a/internal/netpolicy/nft.go b/internal/netpolicy/nft.go new file mode 100644 index 0000000..0022831 --- /dev/null +++ b/internal/netpolicy/nft.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package netpolicy + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// tableName is the nftables table buoy owns. Keeping it in its own table +// (rather than mutating the system filter/nat tables) means a `delete table` +// is a clean, atomic teardown that touches nothing else on the host. +const tableName = "pharos_buoy" + +// renderRuleset builds the nftables transaction script for a policy. The +// `add table; delete table; ...` opening is the standard idempotent reset: +// it works whether the table existed before or not, and the subsequent +// declarations live in a fresh table. +// +// When the policy needs no firewall rules — forwarding off, or forwarding +// on with neither masquerade nor isolation — the rendered script only +// removes any prior buoy table. +func renderRuleset(iface string, p Policy) string { + var b strings.Builder + fmt.Fprintf(&b, "add table inet %s\n", tableName) + fmt.Fprintf(&b, "delete table inet %s\n", tableName) + + needsTable := p.Forwarding && (p.Masquerade || p.Isolation) + if !needsTable { + return b.String() + } + + fmt.Fprintf(&b, "table inet %s {\n", tableName) + if p.Isolation { + b.WriteString(" chain forward {\n") + b.WriteString(" type filter hook forward priority filter; policy accept;\n") + fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" drop\n", iface, iface) + b.WriteString(" }\n") + } + if p.Masquerade { + b.WriteString(" chain postrouting {\n") + b.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") + fmt.Fprintf(&b, " iifname \"%s\" oifname != \"%s\" masquerade\n", iface, iface) + b.WriteString(" }\n") + } + b.WriteString("}\n") + return b.String() +} + +// NftApplier applies a Policy to the running kernel via nftables and the +// /proc/sys forwarding switches. Test paths (NftBin, IPv4ForwardPath, +// IPv6ForwardPath) are overridable; production leaves them empty so the +// applier picks up the system `nft` and `/proc/sys/...`. +type NftApplier struct { + // WGInterface scopes rules to the AmneziaWG interface; empty means + // DefaultWGInterface. + WGInterface string + // NftBin overrides the `nft` binary lookup (tests use a stub). + NftBin string + // IPv4ForwardPath and IPv6ForwardPath override the procfs files + // (tests redirect to a tempdir). + IPv4ForwardPath string + IPv6ForwardPath string +} + +// NewNftApplier returns an NftApplier wired to the system defaults. +func NewNftApplier() *NftApplier { + return &NftApplier{ + WGInterface: DefaultWGInterface, + IPv4ForwardPath: "/proc/sys/net/ipv4/ip_forward", + IPv6ForwardPath: "/proc/sys/net/ipv6/conf/all/forwarding", + } +} + +func (a *NftApplier) iface() string { + if a.WGInterface != "" { + return a.WGInterface + } + return DefaultWGInterface +} + +func (a *NftApplier) nft() string { + if a.NftBin != "" { + return a.NftBin + } + return "nft" +} + +// Apply renders the ruleset for p, hands it to `nft -f -`, and writes the +// matching /proc/sys forwarding switches. Errors are surfaced verbatim; +// idempotency is the caller's concern. +func (a *NftApplier) Apply(ctx context.Context, p Policy) error { + ruleset := renderRuleset(a.iface(), p) + cmd := exec.CommandContext(ctx, a.nft(), "-f", "-") + cmd.Stdin = strings.NewReader(ruleset) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("netpolicy: nft -f -: %w (stderr: %s)", + err, strings.TrimSpace(stderr.String())) + } + + if err := writeForward(a.IPv4ForwardPath, p.Forwarding); err != nil { + return err + } + if err := writeForward(a.IPv6ForwardPath, p.Forwarding); err != nil { + return err + } + return nil +} + +// writeForward sets a /proc/sys/.../forwarding (or ip_forward) switch. +// Missing paths are tolerated only when explicitly empty — a real +// applier always has both paths set. +func writeForward(path string, enabled bool) error { + if path == "" { + return nil + } + v := []byte("0\n") + if enabled { + v = []byte("1\n") + } + if err := os.WriteFile(path, v, 0o644); err != nil { + return fmt.Errorf("netpolicy: write %s: %w", path, err) + } + return nil +} diff --git a/internal/netpolicy/nft_test.go b/internal/netpolicy/nft_test.go new file mode 100644 index 0000000..0f36ec0 --- /dev/null +++ b/internal/netpolicy/nft_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package netpolicy + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRenderRulesetResetsTable(t *testing.T) { + // Every render begins with the idempotent table reset so a prior buoy + // policy is unconditionally torn down before the new one lands. + got := renderRuleset("awg0", Policy{}) + for _, want := range []string{ + "add table inet " + tableName, + "delete table inet " + tableName, + } { + if !strings.Contains(got, want) { + t.Errorf("ruleset missing %q:\n%s", want, got) + } + } +} + +func TestRenderRulesetForwardingOnlyEmitsNoRules(t *testing.T) { + // Forwarding on, neither masquerade nor isolation: kernel's default + // forward-accept is fine, so buoy installs no rules — only the reset. + got := renderRuleset("awg0", Policy{Forwarding: true}) + if strings.Contains(got, "table inet "+tableName+" {") { + t.Errorf("forwarding-only ruleset should not declare a table body:\n%s", got) + } +} + +func TestRenderRulesetMasqueradeOnly(t *testing.T) { + got := renderRuleset("awg0", Policy{Forwarding: true, Masquerade: true}) + if !strings.Contains(got, "iifname \"awg0\" oifname != \"awg0\" masquerade") { + t.Errorf("masquerade rule missing:\n%s", got) + } + if strings.Contains(got, "iifname \"awg0\" oifname \"awg0\" drop") { + t.Errorf("isolation rule should not appear:\n%s", got) + } +} + +func TestRenderRulesetIsolationOnly(t *testing.T) { + got := renderRuleset("awg0", Policy{Forwarding: true, Isolation: true}) + if !strings.Contains(got, "iifname \"awg0\" oifname \"awg0\" drop") { + t.Errorf("isolation rule missing:\n%s", got) + } + if strings.Contains(got, "masquerade") { + t.Errorf("masquerade rule should not appear:\n%s", got) + } +} + +func TestRenderRulesetBothRules(t *testing.T) { + got := renderRuleset("awg0", Policy{Forwarding: true, Masquerade: true, Isolation: true}) + if !strings.Contains(got, "drop") || !strings.Contains(got, "masquerade") { + t.Errorf("both rules should appear:\n%s", got) + } +} + +// TestNftApplierShellsOut exercises the full Apply path with a stub `nft` +// that records argv + stdin, plus tempdir overrides for the /proc/sys +// forwarding switches. Skipped on Windows where /bin/sh isn't around. +func TestNftApplierShellsOut(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-stub spy needs /bin/sh") + } + dir := t.TempDir() + stdinCapture := filepath.Join(dir, "stdin") + stub := filepath.Join(dir, "nft.sh") + if err := os.WriteFile(stub, + []byte(fmt.Sprintf("#!/bin/sh\ncat > %q\n", stdinCapture)), + 0o755); err != nil { + t.Fatal(err) + } + + v4 := filepath.Join(dir, "ipv4-forward") + v6 := filepath.Join(dir, "ipv6-forward") + if err := os.WriteFile(v4, []byte("0\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(v6, []byte("0\n"), 0o644); err != nil { + t.Fatal(err) + } + + a := &NftApplier{ + WGInterface: "awg0", + NftBin: stub, + IPv4ForwardPath: v4, + IPv6ForwardPath: v6, + } + if err := a.Apply(context.Background(), + Policy{Forwarding: true, Masquerade: true}); err != nil { + t.Fatalf("Apply: %v", err) + } + + got, err := os.ReadFile(stdinCapture) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "masquerade") { + t.Errorf("nft stdin missing masquerade rule:\n%s", got) + } + + for _, path := range []string{v4, v6} { + v, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(v)) != "1" { + t.Errorf("%s = %q, want 1", path, v) + } + } +} + +func TestNftApplierTurnsForwardingOff(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-stub spy needs /bin/sh") + } + dir := t.TempDir() + stub := filepath.Join(dir, "nft.sh") + if err := os.WriteFile(stub, []byte("#!/bin/sh\ncat >/dev/null\n"), 0o755); err != nil { + t.Fatal(err) + } + v4 := filepath.Join(dir, "ipv4-forward") + v6 := filepath.Join(dir, "ipv6-forward") + if err := os.WriteFile(v4, []byte("1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(v6, []byte("1\n"), 0o644); err != nil { + t.Fatal(err) + } + + a := &NftApplier{ + WGInterface: "awg0", + NftBin: stub, + IPv4ForwardPath: v4, + IPv6ForwardPath: v6, + } + if err := a.Apply(context.Background(), Policy{}); err != nil { + t.Fatalf("Apply: %v", err) + } + for _, path := range []string{v4, v6} { + v, _ := os.ReadFile(path) + if strings.TrimSpace(string(v)) != "0" { + t.Errorf("%s = %q, want 0 after forwarding=false", path, v) + } + } +} diff --git a/internal/netpolicy/policy.go b/internal/netpolicy/policy.go new file mode 100644 index 0000000..63d0663 --- /dev/null +++ b/internal/netpolicy/policy.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +// Package netpolicy applies a buoy node's traffic-handling policy: +// kernel IP forwarding, source NAT for forwarded traffic, and +// client-to-client isolation (DESIGN §3, decision 16). helm carries the +// policy over the wire as three booleans on NetworkConfig; buoy renders +// them into an nftables table + the /proc/sys forwarding switches. +// +// The package is independent of the AmneziaWG data plane — it only depends +// on knowing the WG interface name (default "awg0") so it can scope the +// nftables rules. +package netpolicy + +import "fmt" + +// DefaultWGInterface is the AmneziaWG interface name buoy scopes rules to. +const DefaultWGInterface = "awg0" + +// Policy is the three-boolean network-handling policy helm pushes (DESIGN +// §3, decision 16). masquerade and isolation are only meaningful when +// forwarding is true. +type Policy struct { + // Forwarding turns on kernel IP forwarding (ipv4 + ipv6). With it off, + // the node accepts AmneziaWG handshakes but routes nothing onward. + Forwarding bool + // Masquerade source-NATs forwarded WG traffic to the node's egress + // address — the "internet egress" posture. + Masquerade bool + // Isolation drops client-to-client forwarded traffic — peers can still + // egress through the node but cannot reach each other. + Isolation bool +} + +// Validate rejects combinations the contract forbids (helm's +// netpolicy.Policy.Validate already enforces this on the helm side; buoy +// repeats the check defensively). +func (p Policy) Validate() error { + if !p.Forwarding && (p.Masquerade || p.Isolation) { + return fmt.Errorf("netpolicy: masquerade/isolation require forwarding=true (got %+v)", p) + } + return nil +} diff --git a/internal/netpolicy/policy_test.go b/internal/netpolicy/policy_test.go new file mode 100644 index 0000000..f5974f8 --- /dev/null +++ b/internal/netpolicy/policy_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package netpolicy + +import "testing" + +func TestPolicyValidate(t *testing.T) { + tests := []struct { + name string + p Policy + wantErr bool + }{ + {"all off", Policy{}, false}, + {"forwarding only", Policy{Forwarding: true}, false}, + {"forwarding + masquerade", Policy{Forwarding: true, Masquerade: true}, false}, + {"forwarding + isolation", Policy{Forwarding: true, Isolation: true}, false}, + {"all on", Policy{Forwarding: true, Masquerade: true, Isolation: true}, false}, + {"masquerade without forwarding", Policy{Masquerade: true}, true}, + {"isolation without forwarding", Policy{Isolation: true}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.p.Validate() + if tc.wantErr && err == nil { + t.Error("Validate = nil, want error") + } + if !tc.wantErr && err != nil { + t.Errorf("Validate = %v, want nil", err) + } + }) + } +}