From eec3e8d298208e115c86ef2c1a2ce9b5569248d3 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 19:18:47 +0000 Subject: [PATCH 01/27] feat(core): Start(name) with active-state refresh --- internal/core/named.go | 57 +++++++++++++++++++ internal/core/named_test.go | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 internal/core/named.go create mode 100644 internal/core/named_test.go diff --git a/internal/core/named.go b/internal/core/named.go new file mode 100644 index 0000000..512d52f --- /dev/null +++ b/internal/core/named.go @@ -0,0 +1,57 @@ +package core + +import ( + "context" + "errors" + + "github.com/iker/exit-node/internal/gcp" +) + +// ErrNameRequired is returned by Start when name is the empty string. +var ErrNameRequired = errors.New("core: name required") + +// GetActive returns the currently-active exit node from the local state cache, +// or nil if none is recorded. +func (c *Core) GetActive() (*gcp.ExitNode, error) { + return c.store.GetActive() +} + +// Start calls the provider's Start method for the named exit node and then +// refreshes the local state cache if that node is currently the active one. +func (c *Core) Start(ctx context.Context, name string) error { + if name == "" { + return ErrNameRequired + } + if err := c.provider.Start(ctx, name); err != nil { + return err + } + return c.refreshIfActive(ctx, name) +} + +// refreshIfActive re-fetches the named node from the provider and updates the +// local state cache — but only when name matches the currently-recorded active +// node. A provider Get failure is logged and swallowed; Start already succeeded +// so a state-refresh hiccup must not surface as a Start failure. +func (c *Core) refreshIfActive(ctx context.Context, name string) error { + active, err := c.store.GetActive() + if err != nil { + c.log.Warn("refreshIfActive: could not read active state", "err", err) + return nil + } + if active == nil || active.Name != name { + return nil + } + fresh, err := c.provider.Get(ctx, name) + if err != nil { + c.log.Warn("refreshIfActive: provider.Get failed, skipping state update", "name", name, "err", err) + return nil + } + if fresh == nil { + c.log.Warn("refreshIfActive: provider.Get returned nil, skipping state update", "name", name) + return nil + } + if err := c.store.SetActive(fresh); err != nil { + c.log.Warn("refreshIfActive: SetActive failed", "name", name, "err", err) + } + return nil +} diff --git a/internal/core/named_test.go b/internal/core/named_test.go new file mode 100644 index 0000000..ad09090 --- /dev/null +++ b/internal/core/named_test.go @@ -0,0 +1,106 @@ +package core + +import ( + "context" + "errors" + "log/slog" + "testing" + + "github.com/iker/exit-node/internal/config" + "github.com/iker/exit-node/internal/gcp" + "github.com/iker/exit-node/internal/state" +) + +type namedFixtureT struct { + c *Core + store *state.Store + prov *mockProvider + ts *mockTS +} + +func namedFixture(t *testing.T, active *gcp.ExitNode) *namedFixtureT { + t.Helper() + statePath := t.TempDir() + "/state.json" + store, err := state.Open(statePath) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + if active != nil { + if err := store.SetActive(active); err != nil { + t.Fatalf("SetActive: %v", err) + } + } + cfg := &config.Config{} + prov := newMockProvider() + ts := newMockTS() + pf := newMockPF() + probe := &mockProbe{} + c := New(Deps{ + Config: cfg, Provider: prov, TS: ts, PF: pf, Probe: probe, + Store: store, Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)), + }) + return &namedFixtureT{c: c, store: store, prov: prov, ts: ts} +} + +func TestStart_CallsProviderStart(t *testing.T) { + f := namedFixture(t, nil) + + if err := f.c.Start(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Start: %v", err) + } + if !hasCallWithArg(f.prov.calls, "Start", "vpn-us-central1-abc") { + t.Errorf("provider.Start not called with expected name; calls=%v", f.prov.calls) + } +} + +func TestStart_RefreshesStateWhenNameMatchesActive(t *testing.T) { + active := &gcp.ExitNode{Name: "vpn-us-central1-abc", State: gcp.StateStopped} + f := namedFixture(t, active) + f.prov.GetResult["vpn-us-central1-abc"] = &gcp.ExitNode{ + Name: "vpn-us-central1-abc", State: gcp.StateRunning, PublicIP: "1.2.3.4", + } + + if err := f.c.Start(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Start: %v", err) + } + got, err := f.store.GetActive() + if err != nil { + t.Fatalf("GetActive: %v", err) + } + if got == nil { + t.Fatal("GetActive returned nil, want refreshed node") + } + if got.State != gcp.StateRunning || got.PublicIP != "1.2.3.4" { + t.Errorf("state not refreshed: got State=%v PublicIP=%q", got.State, got.PublicIP) + } +} + +func TestStart_DoesNotTouchStateWhenNameMismatch(t *testing.T) { + active := &gcp.ExitNode{Name: "vpn-us-central1-abc", State: gcp.StateRunning, PublicIP: "9.9.9.9"} + f := namedFixture(t, active) + // Provider Get for the different name returns nothing (default empty map). + + if err := f.c.Start(context.Background(), "vpn-eu-west1-xyz"); err != nil { + t.Fatalf("Start: %v", err) + } + got, err := f.store.GetActive() + if err != nil { + t.Fatalf("GetActive: %v", err) + } + if got == nil || got.PublicIP != "9.9.9.9" { + t.Errorf("state mutated unexpectedly: %+v", got) + } +} + +func TestStart_RejectsEmptyName(t *testing.T) { + f := namedFixture(t, nil) + err := f.c.Start(context.Background(), "") + if !errors.Is(err, ErrNameRequired) { + t.Errorf("got %v, want ErrNameRequired", err) + } + // Provider.Start must not have been called. + if hasCallWithArg(f.prov.calls, "Start", "") { + t.Errorf("provider.Start was called despite empty name") + } +} From ef3e21ef45fca272e3ad21f14a349af52d7ac7e8 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 19:49:49 +0000 Subject: [PATCH 02/27] polish(core): wrap provider.Start error with node name --- internal/core/named.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/core/named.go b/internal/core/named.go index 512d52f..930d6c7 100644 --- a/internal/core/named.go +++ b/internal/core/named.go @@ -3,6 +3,7 @@ package core import ( "context" "errors" + "fmt" "github.com/iker/exit-node/internal/gcp" ) @@ -23,7 +24,7 @@ func (c *Core) Start(ctx context.Context, name string) error { return ErrNameRequired } if err := c.provider.Start(ctx, name); err != nil { - return err + return fmt.Errorf("provider.Start %s: %w", name, err) } return c.refreshIfActive(ctx, name) } From b5581bddd88fbeccc769aa4653f8758759266505 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 19:51:08 +0000 Subject: [PATCH 03/27] feat(core): Stop(name) with active-state refresh --- internal/core/named.go | 12 ++++++++++ internal/core/named_test.go | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/internal/core/named.go b/internal/core/named.go index 930d6c7..2a3e560 100644 --- a/internal/core/named.go +++ b/internal/core/named.go @@ -29,6 +29,18 @@ func (c *Core) Start(ctx context.Context, name string) error { return c.refreshIfActive(ctx, name) } +// Stop stops a running exit-node VM by name. If the name matches the +// currently-active node, the on-disk state cache is refreshed. +func (c *Core) Stop(ctx context.Context, name string) error { + if name == "" { + return ErrNameRequired + } + if err := c.provider.Stop(ctx, name); err != nil { + return fmt.Errorf("provider.Stop %s: %w", name, err) + } + return c.refreshIfActive(ctx, name) +} + // refreshIfActive re-fetches the named node from the provider and updates the // local state cache — but only when name matches the currently-recorded active // node. A provider Get failure is logged and swallowed; Start already succeeded diff --git a/internal/core/named_test.go b/internal/core/named_test.go index ad09090..02c65b8 100644 --- a/internal/core/named_test.go +++ b/internal/core/named_test.go @@ -104,3 +104,48 @@ func TestStart_RejectsEmptyName(t *testing.T) { t.Errorf("provider.Start was called despite empty name") } } + +func TestStop_CallsProviderStop(t *testing.T) { + f := namedFixture(t, nil) + + if err := f.c.Stop(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Stop: %v", err) + } + if !hasCallWithArg(f.prov.calls, "Stop", "vpn-us-central1-abc") { + t.Errorf("provider.Stop not called with expected name; calls=%v", f.prov.calls) + } +} + +func TestStop_RefreshesStateWhenNameMatchesActive(t *testing.T) { + active := &gcp.ExitNode{Name: "vpn-us-central1-abc", State: gcp.StateRunning} + f := namedFixture(t, active) + f.prov.GetResult["vpn-us-central1-abc"] = &gcp.ExitNode{ + Name: "vpn-us-central1-abc", State: gcp.StateStopped, PublicIP: "", + } + + if err := f.c.Stop(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Stop: %v", err) + } + got, err := f.store.GetActive() + if err != nil { + t.Fatalf("GetActive: %v", err) + } + if got == nil { + t.Fatal("GetActive returned nil, want refreshed node") + } + if got.State != gcp.StateStopped { + t.Errorf("state not refreshed: got State=%v, want StateStopped", got.State) + } +} + +func TestStop_RejectsEmptyName(t *testing.T) { + f := namedFixture(t, nil) + err := f.c.Stop(context.Background(), "") + if !errors.Is(err, ErrNameRequired) { + t.Errorf("got %v, want ErrNameRequired", err) + } + // Provider.Stop must not have been called. + if hasCallWithArg(f.prov.calls, "Stop", "") { + t.Errorf("provider.Stop was called despite empty name") + } +} From c1ab03ba3c7f76466d59a9c84fc8545cca043cc2 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 19:53:22 +0000 Subject: [PATCH 04/27] feat(core): Destroy(name) with TS device cleanup and state clear --- internal/core/named.go | 44 ++++++++++++++++++++ internal/core/named_test.go | 81 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/internal/core/named.go b/internal/core/named.go index 2a3e560..6182d04 100644 --- a/internal/core/named.go +++ b/internal/core/named.go @@ -4,10 +4,15 @@ import ( "context" "errors" "fmt" + "time" "github.com/iker/exit-node/internal/gcp" ) +// deviceLookupTimeout is the maximum time Destroy will wait for a Tailscale +// device to appear before giving up and proceeding with VM destruction. +const deviceLookupTimeout = 5 * time.Second + // ErrNameRequired is returned by Start when name is the empty string. var ErrNameRequired = errors.New("core: name required") @@ -41,6 +46,45 @@ func (c *Core) Stop(ctx context.Context, name string) error { return c.refreshIfActive(ctx, name) } +// Destroy removes a named exit node: first it tries to look up and delete the +// Tailscale device, then it destroys the VM. If the VM destroy fails the error +// is returned. If the destroyed node was the active node, the state cache is +// cleared. +func (c *Core) Destroy(ctx context.Context, name string) error { + if name == "" { + return ErrNameRequired + } + + // Step 1: Attempt to remove the Tailscale device. A short timeout is used + // so that a missing or already-deleted device does not block VM cleanup. + dev, err := c.ts.WaitForDevice(ctx, name, deviceLookupTimeout) + if err != nil { + c.log.Warn("Destroy: could not look up TS device, skipping device delete", "name", name, "err", err) + } else if dev != nil { + if delErr := c.ts.DeleteDevice(ctx, dev.ID); delErr != nil { + c.log.Warn("Destroy: ts.DeleteDevice failed, proceeding with VM destroy", "name", name, "deviceID", dev.ID, "err", delErr) + } + } + + // Step 2: Destroy the VM — load-bearing; failure aborts the operation. + if err := c.provider.Destroy(ctx, name); err != nil { + return fmt.Errorf("provider.Destroy %s: %w", name, err) + } + + // Step 3: Clear state cache if this was the active node. + active, err := c.store.GetActive() + if err != nil { + c.log.Warn("Destroy: could not read active state", "err", err) + return nil + } + if active != nil && active.Name == name { + if err := c.store.ClearActive(); err != nil { + return fmt.Errorf("clear state after destroy: %w", err) + } + } + return nil +} + // refreshIfActive re-fetches the named node from the provider and updates the // local state cache — but only when name matches the currently-recorded active // node. A provider Get failure is logged and swallowed; Start already succeeded diff --git a/internal/core/named_test.go b/internal/core/named_test.go index 02c65b8..3453bb1 100644 --- a/internal/core/named_test.go +++ b/internal/core/named_test.go @@ -9,6 +9,7 @@ import ( "github.com/iker/exit-node/internal/config" "github.com/iker/exit-node/internal/gcp" "github.com/iker/exit-node/internal/state" + "github.com/iker/exit-node/internal/tailscale" ) type namedFixtureT struct { @@ -149,3 +150,83 @@ func TestStop_RejectsEmptyName(t *testing.T) { t.Errorf("provider.Stop was called despite empty name") } } + +// --------------------------------------------------------------------------- +// Destroy tests +// --------------------------------------------------------------------------- + +func TestDestroy_HappyPath_DestroysVMAndDevice(t *testing.T) { + f := namedFixture(t, nil) + f.ts.WaitDevice = &tailscale.Device{ID: "device-1", Hostname: "vpn-us-central1-abc"} + + if err := f.c.Destroy(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Destroy: %v", err) + } + if !hasCallWithArg(f.prov.calls, "Destroy", "vpn-us-central1-abc") { + t.Errorf("provider.Destroy not called with expected name; calls=%v", f.prov.calls) + } + if !hasCallWithArg(f.ts.calls, "DeleteDevice", "device-1") { + t.Errorf("ts.DeleteDevice not called with expected device ID; calls=%v", f.ts.calls) + } +} + +func TestDestroy_ClearsStateWhenNameMatchesActive(t *testing.T) { + active := &gcp.ExitNode{Name: "vpn-us-central1-abc", State: gcp.StateRunning} + f := namedFixture(t, active) + f.ts.WaitDevice = &tailscale.Device{ID: "device-1", Hostname: "vpn-us-central1-abc"} + + if err := f.c.Destroy(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Destroy: %v", err) + } + got, err := f.store.GetActive() + if err != nil { + t.Fatalf("GetActive: %v", err) + } + if got != nil { + t.Errorf("state not cleared after destroy: %+v", got) + } +} + +func TestDestroy_LeavesStateAloneWhenNameMismatch(t *testing.T) { + active := &gcp.ExitNode{Name: "vpn-eu-west1-xyz", State: gcp.StateRunning, PublicIP: "9.9.9.9"} + f := namedFixture(t, active) + f.ts.WaitDevice = &tailscale.Device{ID: "device-1", Hostname: "vpn-us-central1-abc"} + + if err := f.c.Destroy(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Destroy: %v", err) + } + got, err := f.store.GetActive() + if err != nil { + t.Fatalf("GetActive: %v", err) + } + if got == nil || got.Name != "vpn-eu-west1-xyz" { + t.Errorf("active state mutated unexpectedly: %+v", got) + } +} + +func TestDestroy_DeviceLookupFails_ProceedsWithVMDestroy(t *testing.T) { + f := namedFixture(t, nil) + f.ts.WaitErr = errors.New("device not found") + // WaitDevice is nil — mockTS returns (nil, WaitErr) when WaitErr is set. + + if err := f.c.Destroy(context.Background(), "vpn-us-central1-abc"); err != nil { + t.Fatalf("Destroy: %v", err) + } + if !hasCallWithArg(f.prov.calls, "Destroy", "vpn-us-central1-abc") { + t.Errorf("provider.Destroy not called with expected name; calls=%v", f.prov.calls) + } + if hasCallWithArg(f.ts.calls, "DeleteDevice", "device-1") { + t.Errorf("ts.DeleteDevice must not be called when device lookup failed; calls=%v", f.ts.calls) + } +} + +func TestDestroy_RejectsEmptyName(t *testing.T) { + f := namedFixture(t, nil) + err := f.c.Destroy(context.Background(), "") + if !errors.Is(err, ErrNameRequired) { + t.Errorf("got %v, want ErrNameRequired", err) + } + if hasCallWithArg(f.prov.calls, "Destroy", "") { + t.Errorf("provider.Destroy must not be called for empty name") + } +} From 2a4b87e4fe1db6aa41f71ed04ce938024e8fe939 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 19:55:11 +0000 Subject: [PATCH 05/27] docs(examples): annotated reference config.toml + parse test --- examples/config.toml | 76 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 13 ++++++ 2 files changed, 89 insertions(+) create mode 100644 examples/config.toml diff --git a/examples/config.toml b/examples/config.toml new file mode 100644 index 0000000..620c183 --- /dev/null +++ b/examples/config.toml @@ -0,0 +1,76 @@ +# exitnode — reference configuration. +# +# Copy this file to ~/.config/exitnode/config.toml and edit the values +# for your environment. All env-var names are configurable here so that +# you can pick whatever naming convention your secret store uses. + +[gcp] +# GCP project ID that hosts the exit-node VMs. +project = "my-gcp-project" +# Default region used by `exitnode up` and `exitnode rotate` when no +# --region flag is given. +default_region = "us-west1" +# Default machine type. e2-micro is the cheapest x86_64; e2-small is +# the cheapest with more than 1 vCPU. +default_machine_type = "e2-micro" +# Default zone within the region. Empty → exit-node picks a random +# UP zone in the region at provision time. +default_zone = "" +# VPC network name. "default" is the auto-created VPC every GCP +# project starts with. +network = "default" +# Boot disk size in GiB. The image is debian-12; 10 GiB is comfortably +# above the image's actual footprint. +disk_size_gb = 10 + +[tailscale] +# Your tailnet name — visible at the top of the Tailscale admin console. +tailnet = "example.com" +# Env-var names holding the OAuth client-credentials. Both are required. +# Create the OAuth client at https://login.tailscale.com/admin/settings/oauth +# with scopes "auth_keys" (write) and "devices:core" (write). +oauth_client_id_env = "TAILSCALE_OAUTH_CLIENT_ID" +oauth_client_secret_env = "TAILSCALE_OAUTH_CLIENT_SECRET" +# Tags applied to every provisioned device. Must be defined in your +# tailnet's ACL `tagOwners`. +tags = ["tag:exit-node"] +# How long the ephemeral auth key minted for each new VM remains valid +# before Tailscale rejects it. 5m is generous — the VM consumes the key +# on first boot, usually within 60 seconds. +ephemeral_key_ttl = "5m" + +[pfsense] +# pfSense webConfigurator host or IP. No trailing slash. +host = "https://pfsense.lan" +# Env-var name holding the pfsense-api plugin API key. +api_key_env = "PFSENSE_API_KEY" +# Name of the gateway you've configured in System → Routing → Gateways +# that points at the Tailscale interface. exitnode patches its +# monitor-IP to follow the active exit node. +gateway_name = "TAILSCALE_VPN_GW" +# Set false to skip TLS verification (only safe for the default +# self-signed cert on a trusted LAN). +verify_tls = true + +[behavior] +# When true, every `exitnode up` and `exitnode rotate` runs the +# pfsense sync step automatically. Set false if you want to drive +# pfsense sync manually via `exitnode pfsense sync`. +auto_sync_pfsense = true +# Pre-cutover probe — verifies egress through the candidate node +# from your orchestrator host BEFORE swapping pfSense over. Strongly +# recommended. +verify_pre_cutover = true +# Post-cutover probe — re-verifies egress through the LAN gateway +# AFTER pfSense is swapped. Default off because it requires the +# orchestrator host to use pfSense as its default route. +verify_post_cutover = false +# Public probe URL. Anything that returns the caller's IP as plain +# text works (api.ipify.org, ifconfig.me, etc.). +probe_url = "https://api.ipify.org" +# How long exitnode will wait for the new VM's Tailscale device to +# appear in the API after provision. +registration_timeout = "90s" +# Public URL of scripts/install.sh — fetched by the VM startup script. +# Point at your fork's main branch (or a tagged release for stability). +install_script_url = "https://raw.githubusercontent.com/USER/exit-node/main/scripts/install.sh" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e03d4cd..bef8ee8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -159,3 +159,16 @@ func TestResolveGCPCredentialsFallsBackToADC(t *testing.T) { t.Errorf("ADC payload should be empty (resolved later by google sdk)") } } + +func TestExampleConfigParses(t *testing.T) { + cfg, err := Load("../../examples/config.toml") + if err != nil { + t.Fatalf("Load examples/config.toml: %v", err) + } + if cfg.GCP.Project == "" || cfg.Tailscale.Tailnet == "" || cfg.PFSense.Host == "" { + t.Errorf("example config has empty top-level values: %+v", cfg) + } + if cfg.Tailscale.EphemeralKeyTTL == 0 { + t.Errorf("example config did not parse the duration: %+v", cfg.Tailscale) + } +} From 476facd6e34721b389ee229d2b83af04e94ed9ef Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:01:12 +0000 Subject: [PATCH 06/27] feat(cli): root command + global flags + buildCore helper --- cmd/exitnode/main.go | 13 ++++ cmd/exitnode/render.go | 19 +++++ cmd/exitnode/root.go | 156 ++++++++++++++++++++++++++++++++++++++ cmd/exitnode/root_test.go | 22 ++++++ go.mod | 3 + go.sum | 10 +++ 6 files changed, 223 insertions(+) create mode 100644 cmd/exitnode/main.go create mode 100644 cmd/exitnode/render.go create mode 100644 cmd/exitnode/root.go create mode 100644 cmd/exitnode/root_test.go diff --git a/cmd/exitnode/main.go b/cmd/exitnode/main.go new file mode 100644 index 0000000..7870c99 --- /dev/null +++ b/cmd/exitnode/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "exitnode:", err) + os.Exit(1) + } +} diff --git a/cmd/exitnode/render.go b/cmd/exitnode/render.go new file mode 100644 index 0000000..5ee716a --- /dev/null +++ b/cmd/exitnode/render.go @@ -0,0 +1,19 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" +) + +// renderJSON marshals v to w as indented JSON. +func renderJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// renderKV prints a key=value pair line. +func renderKV(w io.Writer, key string, val any) { + fmt.Fprintf(w, "%-14s %v\n", key+":", val) +} diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go new file mode 100644 index 0000000..7853403 --- /dev/null +++ b/cmd/exitnode/root.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/iker/exit-node/internal/config" + "github.com/iker/exit-node/internal/core" + "github.com/iker/exit-node/internal/gcp" + "github.com/iker/exit-node/internal/pfsense" + "github.com/iker/exit-node/internal/state" + "github.com/iker/exit-node/internal/tailscale" + "github.com/iker/exit-node/internal/verify" +) + +// rootOpts collects the global flags. One instance per process; subcommands +// read from it via closure. +type rootOpts struct { + configPath string + json bool + verbose bool +} + +func newRootCmd() *cobra.Command { + opts := &rootOpts{} + + cmd := &cobra.Command{ + Use: "exitnode", + Short: "On-demand Tailscale exit nodes that rotate across cloud regions", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.PersistentFlags().StringVar(&opts.configPath, "config", defaultConfigPath(), "Path to config.toml") + cmd.PersistentFlags().BoolVar(&opts.json, "json", false, "Machine-readable JSON output") + cmd.PersistentFlags().BoolVarP(&opts.verbose, "verbose", "v", false, "Verbose (debug) logging") + + cmd.AddCommand( + newUpCmd(opts), + newDownCmd(opts), + newRotateCmd(opts), + newListCmd(opts), + newStatusCmd(opts), + newHealthCmd(opts), + newPFSenseCmd(opts), + newCostCmd(opts), + ) + return cmd +} + +func defaultConfigPath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "exitnode", "config.toml") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "exitnode", "config.toml") +} + +func defaultStatePath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "exitnode", "state.json") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "exitnode", "state.json") +} + +// Subcommand stubs (real impls in subsequent tasks). Kept visible (not +// Hidden) so the help-output smoke test in Step 7 passes even before +// the real impls land. +func newUpCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "up"} } +func newDownCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "down"} } +func newRotateCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "rotate"} } +func newListCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "list"} } +func newStatusCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "status"} } +func newHealthCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "health"} } +func newPFSenseCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "pfsense"} } +func newCostCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "cost"} } + +// buildCore loads config + secrets, opens the state store, and constructs +// a *core.Core with real client implementations wired in. Returned cleanup +// must always be called (releases the state lock). +func (o *rootOpts) buildCore(ctx context.Context) (*core.Core, func(), error) { + cfg, err := config.Load(o.configPath) + if err != nil { + return nil, func() {}, fmt.Errorf("load config: %w", err) + } + + tsID, tsSecret, err := cfg.ResolveTailscaleSecrets() + if err != nil { + return nil, func() {}, err + } + pfKey, err := cfg.ResolvePFSenseAPIKey() + if err != nil { + return nil, func() {}, err + } + _, gcpJSON, err := cfg.ResolveGCPCredentials() + if err != nil { + return nil, func() {}, err + } + + provider, err := gcp.New(ctx, gcp.Options{ + Project: cfg.GCP.Project, + Region: cfg.GCP.DefaultRegion, + CredentialsJSON: gcpJSON, + Network: cfg.GCP.Network, + InstallScriptURL: cfg.Behavior.InstallScriptURL, + }) + if err != nil { + return nil, func() {}, fmt.Errorf("gcp client: %w", err) + } + + ts, err := tailscale.New(tailscale.Options{ + Tailnet: cfg.Tailscale.Tailnet, + ClientID: tsID, + ClientSecret: tsSecret, + }) + if err != nil { + return nil, func() {}, fmt.Errorf("tailscale client: %w", err) + } + + pf, err := pfsense.New(pfsense.Options{ + BaseURL: cfg.PFSense.Host, + APIKey: pfKey, + VerifyTLS: cfg.PFSense.VerifyTLS, + }) + if err != nil { + return nil, func() {}, fmt.Errorf("pfsense client: %w", err) + } + + probe := verify.New(cfg.Behavior.ProbeURL) + + store, err := state.Open(defaultStatePath()) + if err != nil { + if errors.Is(err, state.ErrLocked) { + return nil, func() {}, fmt.Errorf("another exitnode process is running (state.json locked)") + } + return nil, func() {}, fmt.Errorf("open state: %w", err) + } + + level := slog.LevelInfo + if o.verbose { + level = slog.LevelDebug + } + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) + + c := core.New(core.Deps{ + Config: cfg, Provider: provider, TS: ts, PF: pf, Probe: probe, Store: store, Logger: log, + }) + return c, func() { _ = store.Close() }, nil +} diff --git a/cmd/exitnode/root_test.go b/cmd/exitnode/root_test.go new file mode 100644 index 0000000..2f8d37b --- /dev/null +++ b/cmd/exitnode/root_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRootCmd_Help_ListsAllSubcommands(t *testing.T) { + cmd := newRootCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetArgs([]string{"--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + for _, sub := range []string{"up", "down", "rotate", "list", "status", "health", "pfsense", "cost"} { + if !strings.Contains(buf.String(), sub) { + t.Errorf("help output missing subcommand %q\nfull output:\n%s", sub, buf.String()) + } + } +} diff --git a/go.mod b/go.mod index 18794b7..05ceeff 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,9 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect diff --git a/go.sum b/go.sum index d013c28..aa5e495 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -29,8 +30,15 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5Ugt github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU= @@ -45,6 +53,7 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= @@ -69,6 +78,7 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= tailscale.com/client/tailscale/v2 v2.9.0 h1:zBZIIeIYXL42qvvile7d29O2DKSr3AfNc2gzd1JCf2o= From 8119a7041fffac2bc15d142ccade6c1eb6e57083 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:02:43 +0000 Subject: [PATCH 07/27] feat(cli): exitnode up (idempotent provision) --- cmd/exitnode/root.go | 1 - cmd/exitnode/up.go | 48 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 cmd/exitnode/up.go diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index 7853403..7b58552 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,7 +73,6 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newUpCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "up"} } func newDownCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "down"} } func newRotateCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "rotate"} } func newListCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "list"} } diff --git a/cmd/exitnode/up.go b/cmd/exitnode/up.go new file mode 100644 index 0000000..cfbce10 --- /dev/null +++ b/cmd/exitnode/up.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/iker/exit-node/internal/core" +) + +func newUpCmd(opts *rootOpts) *cobra.Command { + var ( + region string + machineType string + ) + cmd := &cobra.Command{ + Use: "up", + Short: "Provision an exit node (idempotent — returns the active one if it exists)", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + + node, err := c.Up(ctx, core.UpOpts{Region: region, MachineType: machineType}) + if err != nil { + return fmt.Errorf("up: %w", err) + } + return renderNode(os.Stdout, opts.json, node) + }, + } + cmd.Flags().StringVar(®ion, "region", "", "GCP region (overrides config default)") + cmd.Flags().StringVar(&machineType, "machine", "", "GCP machine type (overrides config default)") + return cmd +} + +func renderNode(w *os.File, asJSON bool, n any) error { + if asJSON { + return renderJSON(w, n) + } + // Type-assert to gcp.ExitNode is fine because callers pass that type. + fmt.Fprintln(w, n) + return nil +} From e714ce91e5fa2c94a256ea3750d31fd9e2c1114e Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:02:59 +0000 Subject: [PATCH 08/27] feat(cli): exitnode down [--destroy] --- cmd/exitnode/down.go | 32 ++++++++++++++++++++++++++++++++ cmd/exitnode/root.go | 1 - 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 cmd/exitnode/down.go diff --git a/cmd/exitnode/down.go b/cmd/exitnode/down.go new file mode 100644 index 0000000..89567ed --- /dev/null +++ b/cmd/exitnode/down.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/iker/exit-node/internal/core" +) + +func newDownCmd(opts *rootOpts) *cobra.Command { + var destroy bool + cmd := &cobra.Command{ + Use: "down", + Short: "Stop (or with --destroy, terminate) the currently active exit node", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + if err := c.Down(ctx, core.DownOpts{Destroy: destroy}); err != nil { + return fmt.Errorf("down: %w", err) + } + return nil + }, + } + cmd.Flags().BoolVar(&destroy, "destroy", false, "Destroy the VM + tailscale device (default: stop only)") + return cmd +} diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index 7b58552..d08cd0a 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,7 +73,6 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newDownCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "down"} } func newRotateCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "rotate"} } func newListCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "list"} } func newStatusCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "status"} } From 882cd1bd8d364cb44da36892a1dda0dba57b586b Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:03:16 +0000 Subject: [PATCH 09/27] feat(cli): exitnode rotate [--region] --- cmd/exitnode/root.go | 1 - cmd/exitnode/rotate.go | 45 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 cmd/exitnode/rotate.go diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index d08cd0a..ffd0a57 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,7 +73,6 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newRotateCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "rotate"} } func newListCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "list"} } func newStatusCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "status"} } func newHealthCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "health"} } diff --git a/cmd/exitnode/rotate.go b/cmd/exitnode/rotate.go new file mode 100644 index 0000000..e3ae932 --- /dev/null +++ b/cmd/exitnode/rotate.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/iker/exit-node/internal/core" +) + +func newRotateCmd(opts *rootOpts) *cobra.Command { + var region string + cmd := &cobra.Command{ + Use: "rotate", + Short: "Provision a new exit node, cut pfSense over, destroy the old one", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + + res, err := c.Rotate(ctx, core.RotateOpts{Region: region}) + if err != nil { + return fmt.Errorf("rotate: %w", err) + } + if opts.json { + return renderJSON(os.Stdout, res) + } + fmt.Fprintln(os.Stdout, "rotated:") + if res.Old != nil { + renderKV(os.Stdout, "old", res.Old.Name) + } + if res.New != nil { + renderKV(os.Stdout, "new", res.New.Name) + } + return nil + }, + } + cmd.Flags().StringVar(®ion, "region", "", "GCP region for the new node (overrides config default)") + return cmd +} From 8d401ffb38a96c07f14218da266e9b81acacbb2a Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:03:32 +0000 Subject: [PATCH 10/27] feat(cli): exitnode list [--json] --- cmd/exitnode/list.go | 41 +++++++++++++++++++++++++++++++++++++++++ cmd/exitnode/root.go | 1 - 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 cmd/exitnode/list.go diff --git a/cmd/exitnode/list.go b/cmd/exitnode/list.go new file mode 100644 index 0000000..3274964 --- /dev/null +++ b/cmd/exitnode/list.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newListCmd(opts *rootOpts) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all exitnode-managed VMs in the GCP project", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + + nodes, err := c.List(ctx) + if err != nil { + return fmt.Errorf("list: %w", err) + } + if opts.json { + return renderJSON(os.Stdout, nodes) + } + if len(nodes) == 0 { + fmt.Fprintln(os.Stdout, "(no exitnode-managed VMs found)") + return nil + } + fmt.Fprintf(os.Stdout, "%-32s %-14s %-12s %s\n", "NAME", "REGION", "STATE", "PUBLIC_IP") + for _, n := range nodes { + fmt.Fprintf(os.Stdout, "%-32s %-14s %-12v %s\n", n.Name, n.Region, n.State, n.PublicIP) + } + return nil + }, + } +} diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index ffd0a57..1043685 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,7 +73,6 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newListCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "list"} } func newStatusCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "status"} } func newHealthCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "health"} } func newPFSenseCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "pfsense"} } From a4405bc0ab6231517e390b97aefae72be8b9875e Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:04:58 +0000 Subject: [PATCH 11/27] feat(cli): exitnode status [--json] --- cmd/exitnode/root.go | 3 +-- cmd/exitnode/status.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 cmd/exitnode/status.go diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index 1043685..9e3ca9c 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,8 +73,7 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newStatusCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "status"} } -func newHealthCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "health"} } +func newHealthCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "health"} } func newPFSenseCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "pfsense"} } func newCostCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "cost"} } diff --git a/cmd/exitnode/status.go b/cmd/exitnode/status.go new file mode 100644 index 0000000..b0ff9e8 --- /dev/null +++ b/cmd/exitnode/status.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newStatusCmd(opts *rootOpts) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the currently active exit node and any state drift", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + + s, err := c.Status(ctx) + if err != nil { + return fmt.Errorf("status: %w", err) + } + if opts.json { + return renderJSON(os.Stdout, s) + } + if s.Cached == nil { + fmt.Fprintln(os.Stdout, "no active exit node") + return nil + } + renderKV(os.Stdout, "name", s.Cached.Name) + renderKV(os.Stdout, "region", s.Cached.Region) + if s.Live != nil { + renderKV(os.Stdout, "live_state", s.Live.State) + renderKV(os.Stdout, "public_ip", s.Live.PublicIP) + } + if s.Drift() { + fmt.Fprintln(os.Stdout, "(state drift detected — run `exitnode list` to inspect)") + } + return nil + }, + } +} From 2137acdcdc517508df5cf320869991a266673fde Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:05:15 +0000 Subject: [PATCH 12/27] feat(cli): exitnode health (exits 2 on probe fail) --- cmd/exitnode/health.go | 42 ++++++++++++++++++++++++++++++++++++++++++ cmd/exitnode/root.go | 1 - 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 cmd/exitnode/health.go diff --git a/cmd/exitnode/health.go b/cmd/exitnode/health.go new file mode 100644 index 0000000..06c7bb7 --- /dev/null +++ b/cmd/exitnode/health.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newHealthCmd(opts *rootOpts) *cobra.Command { + return &cobra.Command{ + Use: "health", + Short: "Probe egress through the active exit node", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + + h, err := c.Health(ctx) + if err != nil { + return fmt.Errorf("health: %w", err) + } + if opts.json { + return renderJSON(os.Stdout, h) + } + renderKV(os.Stdout, "ok", h.OK) + renderKV(os.Stdout, "egress_ip", h.EgressIP) + renderKV(os.Stdout, "expected_ip", h.ExpectedIP) + if h.ProbeErr != nil { + renderKV(os.Stdout, "probe_err", h.ProbeErr.Error()) + } + if !h.OK { + os.Exit(2) + } + return nil + }, + } +} diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index 9e3ca9c..906f738 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,7 +73,6 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newHealthCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "health"} } func newPFSenseCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "pfsense"} } func newCostCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "cost"} } From 62d25e89802016ce48bb0c668d6497f5f9af2154 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:05:32 +0000 Subject: [PATCH 13/27] feat(cli): exitnode pfsense sync --- cmd/exitnode/pfsense.go | 32 ++++++++++++++++++++++++++++++++ cmd/exitnode/root.go | 3 +-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 cmd/exitnode/pfsense.go diff --git a/cmd/exitnode/pfsense.go b/cmd/exitnode/pfsense.go new file mode 100644 index 0000000..8e56817 --- /dev/null +++ b/cmd/exitnode/pfsense.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +func newPFSenseCmd(opts *rootOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "pfsense", + Short: "pfSense gateway operations", + } + cmd.AddCommand(&cobra.Command{ + Use: "sync", + Short: "Push the active node's Tailscale IP to the configured pfSense gateway", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + if err := c.SyncPFSense(ctx); err != nil { + return fmt.Errorf("pfsense sync: %w", err) + } + return nil + }, + }) + return cmd +} diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index 906f738..de693e3 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -73,8 +73,7 @@ func defaultStatePath() string { // Subcommand stubs (real impls in subsequent tasks). Kept visible (not // Hidden) so the help-output smoke test in Step 7 passes even before // the real impls land. -func newPFSenseCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "pfsense"} } -func newCostCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "cost"} } +func newCostCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "cost"} } // buildCore loads config + secrets, opens the state store, and constructs // a *core.Core with real client implementations wired in. Returned cleanup From d79f7670b9e89e7f473765ab47ae6ff010315d8b Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:06:00 +0000 Subject: [PATCH 14/27] feat(cli): exitnode cost [--period] --- cmd/exitnode/cost.go | 70 +++++++++++++++++++++++++++++++++++++++ cmd/exitnode/cost_test.go | 28 ++++++++++++++++ cmd/exitnode/root.go | 5 --- 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 cmd/exitnode/cost.go create mode 100644 cmd/exitnode/cost_test.go diff --git a/cmd/exitnode/cost.go b/cmd/exitnode/cost.go new file mode 100644 index 0000000..95b455f --- /dev/null +++ b/cmd/exitnode/cost.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/iker/exit-node/internal/core" +) + +func newCostCmd(opts *rootOpts) *cobra.Command { + var period string + cmd := &cobra.Command{ + Use: "cost", + Short: "Estimate spend for the active exit node", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + c, cleanup, err := opts.buildCore(ctx) + if err != nil { + return err + } + defer cleanup() + + var d time.Duration + if period != "" { + p, err := parsePeriod(period) + if err != nil { + return err + } + d = p + } + + r, err := c.EstimateCost(ctx, core.CostOpts{Period: d}) + if err != nil { + return fmt.Errorf("cost: %w", err) + } + if opts.json { + return renderJSON(os.Stdout, r) + } + renderKV(os.Stdout, "machine_type", r.MachineType) + renderKV(os.Stdout, "hours", fmt.Sprintf("%.2f", r.Hours)) + renderKV(os.Stdout, "usd_per_hour", fmt.Sprintf("%.4f", r.USDPerHour)) + renderKV(os.Stdout, "usd", fmt.Sprintf("%.2f", r.USD)) + if r.Note != "" { + renderKV(os.Stdout, "note", r.Note) + } + return nil + }, + } + cmd.Flags().StringVar(&period, "period", "", "Look-back window: 24h, 7d, month (default: since node creation)") + return cmd +} + +// parsePeriod accepts a few user-friendly aliases on top of Go's +// time.ParseDuration. "month" means 30 days; "Nd" means N days. +func parsePeriod(s string) (time.Duration, error) { + if s == "month" { + return 30 * 24 * time.Hour, nil + } + if len(s) > 1 && s[len(s)-1] == 'd' { + var days int + if _, err := fmt.Sscanf(s, "%dd", &days); err == nil { + return time.Duration(days) * 24 * time.Hour, nil + } + } + return time.ParseDuration(s) +} diff --git a/cmd/exitnode/cost_test.go b/cmd/exitnode/cost_test.go new file mode 100644 index 0000000..d909e27 --- /dev/null +++ b/cmd/exitnode/cost_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + "time" +) + +func TestParsePeriod(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"24h", 24 * time.Hour}, + {"7d", 7 * 24 * time.Hour}, + {"month", 30 * 24 * time.Hour}, + {"30m", 30 * time.Minute}, + } + for _, tc := range cases { + got, err := parsePeriod(tc.in) + if err != nil { + t.Errorf("parsePeriod(%q) err=%v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("parsePeriod(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index de693e3..f78b408 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -70,11 +70,6 @@ func defaultStatePath() string { return filepath.Join(home, ".config", "exitnode", "state.json") } -// Subcommand stubs (real impls in subsequent tasks). Kept visible (not -// Hidden) so the help-output smoke test in Step 7 passes even before -// the real impls land. -func newCostCmd(*rootOpts) *cobra.Command { return &cobra.Command{Use: "cost"} } - // buildCore loads config + secrets, opens the state store, and constructs // a *core.Core with real client implementations wired in. Returned cleanup // must always be called (releases the state lock). From 491d5fc20766d3b476724daa667a96ea9c7df8f6 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:09:31 +0000 Subject: [PATCH 15/27] feat(mcp): server scaffolding + buildCore helper --- cmd/exitnode-mcp/buildcore.go | 108 ++++++++++++++++++++++++++++++++++ cmd/exitnode-mcp/main.go | 28 +++++++++ go.mod | 5 ++ go.sum | 10 ++++ 4 files changed, 151 insertions(+) create mode 100644 cmd/exitnode-mcp/buildcore.go create mode 100644 cmd/exitnode-mcp/main.go diff --git a/cmd/exitnode-mcp/buildcore.go b/cmd/exitnode-mcp/buildcore.go new file mode 100644 index 0000000..eee5a83 --- /dev/null +++ b/cmd/exitnode-mcp/buildcore.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/iker/exit-node/internal/config" + "github.com/iker/exit-node/internal/core" + "github.com/iker/exit-node/internal/gcp" + "github.com/iker/exit-node/internal/pfsense" + "github.com/iker/exit-node/internal/state" + "github.com/iker/exit-node/internal/tailscale" + "github.com/iker/exit-node/internal/verify" +) + +func defaultConfigPath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "exitnode", "config.toml") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "exitnode", "config.toml") +} + +func defaultStatePath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "exitnode", "state.json") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "exitnode", "state.json") +} + +// buildCore constructs a *core.Core with all live clients wired up. Each +// MCP tool call invokes this — opening + closing the state lock per +// invocation so the CLI and MCP can interleave without deadlocking each +// other. +func buildCore(ctx context.Context) (*core.Core, func(), error) { + cfg, err := config.Load(defaultConfigPath()) + if err != nil { + return nil, func() {}, fmt.Errorf("load config: %w", err) + } + + tsID, tsSecret, err := cfg.ResolveTailscaleSecrets() + if err != nil { + return nil, func() {}, err + } + pfKey, err := cfg.ResolvePFSenseAPIKey() + if err != nil { + return nil, func() {}, err + } + _, gcpJSON, err := cfg.ResolveGCPCredentials() + if err != nil { + return nil, func() {}, err + } + + provider, err := gcp.New(ctx, gcp.Options{ + Project: cfg.GCP.Project, + Region: cfg.GCP.DefaultRegion, + CredentialsJSON: gcpJSON, + Network: cfg.GCP.Network, + InstallScriptURL: cfg.Behavior.InstallScriptURL, + }) + if err != nil { + return nil, func() {}, fmt.Errorf("gcp: %w", err) + } + + ts, err := tailscale.New(tailscale.Options{ + Tailnet: cfg.Tailscale.Tailnet, + ClientID: tsID, + ClientSecret: tsSecret, + }) + if err != nil { + return nil, func() {}, fmt.Errorf("tailscale: %w", err) + } + + pf, err := pfsense.New(pfsense.Options{ + BaseURL: cfg.PFSense.Host, + APIKey: pfKey, + VerifyTLS: cfg.PFSense.VerifyTLS, + }) + if err != nil { + return nil, func() {}, fmt.Errorf("pfsense: %w", err) + } + + probe := verify.New(cfg.Behavior.ProbeURL) + + store, err := state.Open(defaultStatePath()) + if err != nil { + if errors.Is(err, state.ErrLocked) { + return nil, func() {}, fmt.Errorf("state.json locked by another process") + } + return nil, func() {}, fmt.Errorf("state: %w", err) + } + + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + c := core.New(core.Deps{ + Config: cfg, Provider: provider, TS: ts, PF: pf, Probe: probe, Store: store, Logger: log, + }) + return c, func() { _ = store.Close() }, nil +} + +// registerTools is implemented in tools.go. +func registerTools(*mcp.Server) {} diff --git a/cmd/exitnode-mcp/main.go b/cmd/exitnode-mcp/main.go new file mode 100644 index 0000000..b255bfe --- /dev/null +++ b/cmd/exitnode-mcp/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + server := mcp.NewServer(&mcp.Implementation{ + Name: "exitnode-mcp", + Version: "0.1.0", + }, nil) + + registerTools(server) + + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { + fmt.Fprintln(os.Stderr, "exitnode-mcp:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 05ceeff..4e49dbf 100644 --- a/go.mod +++ b/go.mod @@ -17,13 +17,18 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.6.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect diff --git a/go.sum b/go.sum index aa5e495..5efd292 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= @@ -32,9 +34,15 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -43,6 +51,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 h1:erxeiTyq+nw4Cz5+hLDkOwNF5/9IQWCQPv0gpb3+QHU= github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 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/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= From 06aec1ce5f17731e398834da4b47fb82d9fb5c41 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:13:52 +0000 Subject: [PATCH 16/27] feat(mcp): wire all 10 tools to core methods --- cmd/exitnode-mcp/buildcore.go | 4 - cmd/exitnode-mcp/tools.go | 234 +++++++++++++++++++++++++++++++++ cmd/exitnode-mcp/tools_test.go | 37 ++++++ 3 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 cmd/exitnode-mcp/tools.go create mode 100644 cmd/exitnode-mcp/tools_test.go diff --git a/cmd/exitnode-mcp/buildcore.go b/cmd/exitnode-mcp/buildcore.go index eee5a83..b4b48ef 100644 --- a/cmd/exitnode-mcp/buildcore.go +++ b/cmd/exitnode-mcp/buildcore.go @@ -8,8 +8,6 @@ import ( "os" "path/filepath" - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/iker/exit-node/internal/config" "github.com/iker/exit-node/internal/core" "github.com/iker/exit-node/internal/gcp" @@ -104,5 +102,3 @@ func buildCore(ctx context.Context) (*core.Core, func(), error) { return c, func() { _ = store.Close() }, nil } -// registerTools is implemented in tools.go. -func registerTools(*mcp.Server) {} diff --git a/cmd/exitnode-mcp/tools.go b/cmd/exitnode-mcp/tools.go new file mode 100644 index 0000000..eca4245 --- /dev/null +++ b/cmd/exitnode-mcp/tools.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/iker/exit-node/internal/core" +) + +// --- Typed argument structs --- + +type provisionArgs struct { + Region string `json:"region,omitempty" jsonschema:"GCP region; omit to use the config default"` + MachineType string `json:"machine_type,omitempty" jsonschema:"GCP machine type; omit to use the config default"` +} + +type nameArgs struct { + Name string `json:"name" jsonschema:"Exit-node name (matches the GCP VM name)"` +} + +type nameOptionalArgs struct { + Name string `json:"name,omitempty" jsonschema:"Exit-node name; omit to use the currently active node"` +} + +type rotateArgs struct { + Region string `json:"region,omitempty" jsonschema:"GCP region for the replacement node; omit to use the config default"` +} + +type costArgs struct { + Period string `json:"period,omitempty" jsonschema:"Look-back window: 24h, 7d, month; omit for since-creation"` +} + +// --- registerTools wires every tool --- + +func registerTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "provision_exit_node", + Description: "Provision (or return the existing) active exit node. Idempotent.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, a provisionArgs) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + node, err := c.Up(ctx, core.UpOpts{Region: a.Region, MachineType: a.MachineType}) + return jsonResult(node, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "start_exit_node", + Description: "Start a stopped exit node by name (defaults to the currently active node).", + }, func(ctx context.Context, _ *mcp.CallToolRequest, a nameOptionalArgs) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + name, err := resolveName(c, a.Name) + if err != nil { + return nil, nil, err + } + err = c.Start(ctx, name) + return jsonResult(map[string]string{"started": name}, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "stop_exit_node", + Description: "Stop a running exit node by name (defaults to the currently active node).", + }, func(ctx context.Context, _ *mcp.CallToolRequest, a nameOptionalArgs) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + name, err := resolveName(c, a.Name) + if err != nil { + return nil, nil, err + } + err = c.Stop(ctx, name) + return jsonResult(map[string]string{"stopped": name}, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "destroy_exit_node", + Description: "Destroy an exit-node VM + its Tailscale device. Name is REQUIRED — there is no implicit 'destroy active' here.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, a nameArgs) (*mcp.CallToolResult, any, error) { + if a.Name == "" { + return nil, nil, fmt.Errorf("name is required for destroy_exit_node") + } + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + err = c.Destroy(ctx, a.Name) + return jsonResult(map[string]string{"destroyed": a.Name}, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "rotate_exit_node", + Description: "Provision a new exit node, cut pfSense over, destroy the old one.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, a rotateArgs) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + res, err := c.Rotate(ctx, core.RotateOpts{Region: a.Region}) + return jsonResult(res, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_exit_nodes", + Description: "List all exitnode-managed VMs in the GCP project.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + nodes, err := c.List(ctx) + return jsonResult(nodes, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "get_status", + Description: "Get cached + live status of the active exit node, plus a drift flag.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + st, err := c.Status(ctx) + return jsonResult(st, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "verify_connectivity", + Description: "Probe egress through the active exit node and return ok / egress_ip / expected_ip.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + h, err := c.Health(ctx) + return jsonResult(h, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "sync_pfsense_gateway", + Description: "Push the active node's Tailscale IP to the pfSense gateway-monitor IP + apply.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + err = c.SyncPFSense(ctx) + return jsonResult(map[string]string{"status": "ok"}, err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "estimate_cost", + Description: "Estimate spend for the active exit node over a look-back window.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, a costArgs) (*mcp.CallToolResult, any, error) { + c, cleanup, err := buildCore(ctx) + if err != nil { + return nil, nil, err + } + defer cleanup() + var d time.Duration + if a.Period != "" { + p, perr := parsePeriodMCP(a.Period) + if perr != nil { + return nil, nil, perr + } + d = p + } + r, err := c.EstimateCost(ctx, core.CostOpts{Period: d}) + return jsonResult(r, err) + }) +} + +// resolveName falls back to the cached active node when the caller +// passed an empty name (used by start/stop tools). +func resolveName(c *core.Core, name string) (string, error) { + if name != "" { + return name, nil + } + active, err := c.GetActive() + if err != nil { + return "", fmt.Errorf("resolve active node: %w", err) + } + if active == nil { + return "", fmt.Errorf("name omitted and no active exit node recorded") + } + return active.Name, nil +} + +// jsonResult marshals v to JSON text content if err is nil; otherwise +// returns the error to the SDK which renders it as an isError result. +func jsonResult(v any, err error) (*mcp.CallToolResult, any, error) { + if err != nil { + return nil, nil, err + } + b, mErr := json.MarshalIndent(v, "", " ") + if mErr != nil { + return nil, nil, fmt.Errorf("marshal result: %w", mErr) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}, + }, v, nil +} + +// parsePeriodMCP parses a human look-back window string (24h, 7d, month) +// into a time.Duration. Same semantics as the CLI's parsePeriod helper. +func parsePeriodMCP(s string) (time.Duration, error) { + if s == "month" { + return 30 * 24 * time.Hour, nil + } + if len(s) > 1 && s[len(s)-1] == 'd' { + var days int + if _, err := fmt.Sscanf(s, "%dd", &days); err == nil { + return time.Duration(days) * 24 * time.Hour, nil + } + } + return time.ParseDuration(s) +} diff --git a/cmd/exitnode-mcp/tools_test.go b/cmd/exitnode-mcp/tools_test.go new file mode 100644 index 0000000..076b74a --- /dev/null +++ b/cmd/exitnode-mcp/tools_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TestRegisterTools_RegistersAllTen verifies that calling registerTools does +// not panic and registers exactly the 10 expected tool names. +// The MCP SDK (v1.6.0) does not expose a public listing API on *mcp.Server, so +// this is a no-panic smoke test. Protocol-level tool enumeration is covered by +// Task 16+ integration tests. +func TestRegisterTools_RegistersAllTen(t *testing.T) { + s := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil) + registerTools(s) // must not panic + + // Enumerate expected names to document the contract even though we cannot + // assert them via the SDK's public API at this point. + wantNames := []string{ + "provision_exit_node", + "start_exit_node", + "stop_exit_node", + "destroy_exit_node", + "rotate_exit_node", + "list_exit_nodes", + "get_status", + "verify_connectivity", + "sync_pfsense_gateway", + "estimate_cost", + } + if len(wantNames) != 10 { + t.Fatalf("test bug: expected 10 tools, listed %d", len(wantNames)) + } + // If a future SDK version exposes Server.ListTools() or similar, replace + // this comment with an assertion over wantNames. +} From 767c4be18c4cecade8483f82ac8208f7ee66b8d8 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:18:09 +0000 Subject: [PATCH 17/27] docs(examples): mcp.json snippet for Claude Desktop / OpenClaw --- examples/mcp.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 examples/mcp.json diff --git a/examples/mcp.json b/examples/mcp.json new file mode 100644 index 0000000..bf30dc7 --- /dev/null +++ b/examples/mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "exitnode": { + "command": "exitnode-mcp", + "args": [], + "env": { + "TAILSCALE_OAUTH_CLIENT_ID": "$TAILSCALE_OAUTH_CLIENT_ID", + "TAILSCALE_OAUTH_CLIENT_SECRET": "$TAILSCALE_OAUTH_CLIENT_SECRET", + "PFSENSE_API_KEY": "$PFSENSE_API_KEY" + } + } + } +} From a758b3c7f441b352ea91275f21be98a37848f40c Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:22:48 +0000 Subject: [PATCH 18/27] ci: add github actions workflow (vet, lint, test, cross-build matrix) --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cd612b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: ci + +on: + push: + branches: [main, develop] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + check-latest: true + cache: true + - run: go vet ./... + - run: go test -race -short ./... + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + - uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m + + build-matrix: + name: build ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + - name: build exitnode + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: go build -trimpath -o /dev/null ./cmd/exitnode + - name: build exitnode-mcp + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: go build -trimpath -o /dev/null ./cmd/exitnode-mcp From a05eaa8cface3a203a3ce38d13d370bff5937768 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:28:03 +0000 Subject: [PATCH 19/27] ci(release): goreleaser config + release workflow + --version flag --- .github/workflows/release.yml | 26 ++++++++++++++ .goreleaser.yaml | 64 +++++++++++++++++++++++++++++++++++ cmd/exitnode-mcp/main.go | 2 ++ cmd/exitnode/main.go | 7 ++++ cmd/exitnode/root.go | 2 ++ 5 files changed, 101 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8dc7772 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + - uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..0393371 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,64 @@ +version: 2 + +project_name: exitnode + +before: + hooks: + - go mod download + +builds: + - id: exitnode + main: ./cmd/exitnode + binary: exitnode + env: + - CGO_ENABLED=0 + goos: [linux, darwin] + goarch: [amd64, arm64] + flags: [-trimpath] + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + - id: exitnode-mcp + main: ./cmd/exitnode-mcp + binary: exitnode-mcp + env: + - CGO_ENABLED=0 + goos: [linux, darwin] + goarch: [amd64, arm64] + flags: [-trimpath] + ldflags: + - -s -w + - -X main.version={{.Version}} + +archives: + - id: default + formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - LICENSE + - README.md + - examples/config.toml + - examples/mcp.json + - scripts/install.sh + +checksum: + name_template: 'checksums.txt' + algorithm: sha256 + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + +release: + github: + owner: ceballosiker + name: exit-node + draft: false + prerelease: auto diff --git a/cmd/exitnode-mcp/main.go b/cmd/exitnode-mcp/main.go index b255bfe..a1f2f96 100644 --- a/cmd/exitnode-mcp/main.go +++ b/cmd/exitnode-mcp/main.go @@ -10,6 +10,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +var version = "dev" + func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() diff --git a/cmd/exitnode/main.go b/cmd/exitnode/main.go index 7870c99..b17e426 100644 --- a/cmd/exitnode/main.go +++ b/cmd/exitnode/main.go @@ -5,6 +5,13 @@ import ( "os" ) +// Populated by goreleaser via -ldflags "-X main.version=...". +var ( + version = "dev" + commit = "none" + date = "unknown" +) + func main() { if err := newRootCmd().Execute(); err != nil { fmt.Fprintln(os.Stderr, "exitnode:", err) diff --git a/cmd/exitnode/root.go b/cmd/exitnode/root.go index f78b408..ec08519 100644 --- a/cmd/exitnode/root.go +++ b/cmd/exitnode/root.go @@ -37,6 +37,8 @@ func newRootCmd() *cobra.Command { SilenceErrors: true, } + cmd.Version = fmt.Sprintf("%s (commit %s, %s)", version, commit, date) + cmd.PersistentFlags().StringVar(&opts.configPath, "config", defaultConfigPath(), "Path to config.toml") cmd.PersistentFlags().BoolVar(&opts.json, "json", false, "Machine-readable JSON output") cmd.PersistentFlags().BoolVarP(&opts.verbose, "verbose", "v", false, "Verbose (debug) logging") From 00684362b82e8580f37b9b1e400089440c3f4743 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:28:36 +0000 Subject: [PATCH 20/27] polish(mcp): wire version var into Implementation.Version --- cmd/exitnode-mcp/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exitnode-mcp/main.go b/cmd/exitnode-mcp/main.go index a1f2f96..323596d 100644 --- a/cmd/exitnode-mcp/main.go +++ b/cmd/exitnode-mcp/main.go @@ -18,7 +18,7 @@ func main() { server := mcp.NewServer(&mcp.Implementation{ Name: "exitnode-mcp", - Version: "0.1.0", + Version: version, }, nil) registerTools(server) From bdd7aa267394ab44d2735685599cf8729090ad47 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:29:57 +0000 Subject: [PATCH 21/27] docs(readme): prereqs/install/configure/mcp/troubleshooting sections --- README.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 76490f4..e417d60 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ On-demand Tailscale exit nodes that rotate across cloud regions and keep your pfSense gateway in sync. Driven from a CLI for humans and an MCP server for AI agents. -> **Status**: pre-alpha. The library packages (provisioning, rotate -> orchestrator, client adapters, probes) are in place. The `exitnode` and -> `exitnode-mcp` binaries are not yet built — see [Roadmap](#roadmap). +> **Status**: pre-alpha (v0.1). All library packages, both binaries +> (`exitnode`, `exitnode-mcp`), CI, and release packaging are in +> place. The first tagged release will produce binaries for +> linux/darwin × amd64/arm64. ## Why @@ -82,6 +83,67 @@ exit-node/ └── README.md ``` +## Prerequisites + +- **GCP project** with the Compute Engine API enabled and a + service account with `compute.instances.{create,start,stop,delete}`, + `compute.zones.list`, and `compute.images.useReadOnly` on + `projects/debian-cloud`. Either authenticate via Application + Default Credentials (`gcloud auth application-default login`) or + export the SA JSON in `GCP_CREDENTIALS_JSON`. +- **Tailscale tailnet** with an OAuth client (scopes: + `auth_keys` write, `devices:core` write) and ACL `tagOwners` + entries for whatever tag(s) you list in `tailscale.tags`. The + tailnet must also have `autoApprovers.exitNode` set to your + exit-node tag if you want approval to happen without manual + admin intervention. +- **pfSense** with the + [community pfsense-api plugin](https://github.com/jaredhendrickson13/pfsense-api) + installed and an API key minted. The plugin must have the + `Routing` permission group enabled for the key. +- **`tailscale` CLI** installed on the host that runs `exitnode` + (used by the pre-cutover egress probe). If unavailable, the + probe is skipped with a warning — see Troubleshooting. + +## Install + +Install both binaries via `go install`: + +```bash +go install github.com/iker/exit-node/cmd/exitnode@latest +go install github.com/iker/exit-node/cmd/exitnode-mcp@latest +``` + +Or download a release archive from +[Releases](https://github.com/ceballosiker/exit-node/releases) and drop +`exitnode` + `exitnode-mcp` into your `$PATH`. + +## Configure + +Copy [`examples/config.toml`](examples/config.toml) to +`~/.config/exitnode/config.toml` and edit the values for your +environment. Every field is annotated in the example. + +Export the secret env-vars referenced by your config: + +```bash +export TAILSCALE_OAUTH_CLIENT_ID=... +export TAILSCALE_OAUTH_CLIENT_SECRET=... +export PFSENSE_API_KEY=... +# Optional — use a service-account JSON instead of ADC: +export GCP_CREDENTIALS_JSON="$(cat path/to/sa.json)" +``` + +Run `exitnode --help` to see the full command list. The most common flow: + +```bash +exitnode up # provision (idempotent) +exitnode status # see what's active +exitnode health # probe egress +exitnode rotate --region us-east1 # cut over to a new region +exitnode down --destroy # tear it all down +``` + ## Development Requirements: Go 1.25+. @@ -110,6 +172,52 @@ The integration test against real GCP additionally honours: | `EXITNODE_TEST_REGION` | `us-central1` | Region for the smoke test | | `GCP_CREDENTIALS_JSON` | _unset_ | Service-account JSON; otherwise ADC is used | +## Using exitnode-mcp + +`exitnode-mcp` is a stdio MCP server that exposes the same operations to +AI agents (Claude Desktop, OpenClaw, any MCP host). Copy +[`examples/mcp.json`](examples/mcp.json) into your client's MCP +configuration. The binary must be on `$PATH`. + +Available tools: `provision_exit_node`, `start_exit_node`, +`stop_exit_node`, `destroy_exit_node`, `rotate_exit_node`, +`list_exit_nodes`, `get_status`, `verify_connectivity`, +`sync_pfsense_gateway`, `estimate_cost`. `destroy_exit_node` requires +an explicit `name` argument — there is no "destroy active" shortcut +at the MCP layer, deliberately. + +## Troubleshooting + +**`tailscale OAuth env vars ... are unset`** +The env-var names declared in `[tailscale].oauth_client_id_env` and +`oauth_client_secret_env` are not set in the calling shell. Mint a new +OAuth client at `https://login.tailscale.com/admin/settings/oauth` and +export the two values. Check by running +`echo "$TAILSCALE_OAUTH_CLIENT_ID"` (or whatever name your config uses). + +**pfSense revert failed — both nodes alive** +This is the loud-error case from the rotate state machine: after a +`pfSense Apply` failure, the orchestrator tried to revert to the old +gateway IP and that revert also failed. Both VMs remain running so your +home network keeps its VPN. Recover by manually setting the pfSense +gateway monitor IP to a known-good Tailscale IP from +`exitnode list`, then `exitnode down --destroy` to clean up. File an +issue with the orchestrator log so we can harden this path. + +**`exitnode up` says "no `tailscale` CLI on host"** +The pre-cutover egress probe shells out to `tailscale`. Install the +client (`curl -fsSL https://tailscale.com/install.sh | sh` on Linux, +`brew install tailscale` on macOS) and re-run. The probe is skipped +silently if the CLI is absent — it does not block provisioning, but +you lose verification. + +**"another exitnode process is running (state.json locked)"** +The state file's POSIX flock is held by another `exitnode` or +`exitnode-mcp` invocation. Most often this means a previous run +hung. Confirm with `lsof ~/.config/exitnode/state.json.lock`; if +nothing holds it, the lock file is stale and safe to delete with +`rm ~/.config/exitnode/state.json.lock`. + ## Roadmap Work is staged in three sequential plans: @@ -122,7 +230,7 @@ Work is staged in three sequential plans: `internal/tailscale`, `internal/pfsense`, `internal/verify`, and `scripts/install.sh`. ✅ Complete. - **Plan 3 — Binaries & distribution.** `cmd/exitnode`, `cmd/exitnode-mcp`, - examples, CI workflows, and goreleaser packaging. ⏳ In progress. + examples, CI workflows, and goreleaser packaging. ✅ Complete. ## License From 0db191eed2bd7611cf2174196a49607d08b4b3ff Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:30:45 +0000 Subject: [PATCH 22/27] chore: go mod tidy (Plan 3 closes out staged indirects) --- go.mod | 10 +++++----- go.sum | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 4e49dbf..4c111e1 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,19 @@ module github.com/iker/exit-node go 1.25.0 require ( + cloud.google.com/go/compute v1.62.0 github.com/BurntSushi/toml v1.6.0 github.com/gofrs/flock v0.13.0 + github.com/modelcontextprotocol/go-sdk v1.6.0 + github.com/spf13/cobra v1.10.2 + google.golang.org/api v0.278.0 + google.golang.org/protobuf v1.36.11 tailscale.com/client/tailscale/v2 v2.9.0 ) require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute v1.62.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -22,10 +26,8 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/modelcontextprotocol/go-sdk v1.6.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/hujson v0.0.0-20220506213045-af5ed07155e5 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect @@ -39,10 +41,8 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect - google.golang.org/api v0.278.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 5efd292..4ef83ed 100644 --- a/go.sum +++ b/go.sum @@ -24,10 +24,18 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= @@ -61,6 +69,10 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -68,14 +80,18 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= From 9244a9f1c7398e64910e20a8b8f8e45ceb89e645 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:41:09 +0000 Subject: [PATCH 23/27] docs(readme): drop 3-plan roadmap; describe v0.1 surface + deferred items --- README.md | 63 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e417d60..3495a3d 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,12 @@ [![Go Version](https://img.shields.io/badge/go-1.25-00ADD8?logo=go)](go.mod) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Status: Pre-alpha](https://img.shields.io/badge/status-pre--alpha-orange)](#status) +[![CI](https://github.com/ceballosiker/exit-node/actions/workflows/ci.yml/badge.svg)](https://github.com/ceballosiker/exit-node/actions/workflows/ci.yml) On-demand Tailscale exit nodes that rotate across cloud regions and keep your pfSense gateway in sync. Driven from a CLI for humans and an MCP server for AI agents. -> **Status**: pre-alpha (v0.1). All library packages, both binaries -> (`exitnode`, `exitnode-mcp`), CI, and release packaging are in -> place. The first tagged release will produce binaries for -> linux/darwin × amd64/arm64. - ## Why VPN exit nodes are useful exactly because they're not always-on: @@ -52,7 +47,7 @@ Four narrow client interfaces, one orchestrator: | Package | Responsibility | | --- | --- | -| `internal/core` | Rotate state machine + `up` / `down` / `status` / `health` / `cost` / `list` / `sync` actions | +| `internal/core` | Rotate state machine + lifecycle ops (`Up` / `Down` / `Rotate` / `Start` / `Stop` / `Destroy` / `List` / `Status` / `Health` / `SyncPFSense` / `EstimateCost`) — single source of truth for business logic, called by both the CLI and the MCP server | | `internal/config` | Typed configuration loader (project, region, tags, pfSense, gateway names) | | `internal/state` | On-disk state file — what's currently running, last rotation, prior nodes | | `internal/gcp` | Compute Engine adapter — `Provision` / `Start` / `Stop` / `Destroy` / `List` / `Get` | @@ -68,16 +63,26 @@ also has a `//go:build integration` smoke test that exercises real GCP ``` exit-node/ +├── cmd/ +│ ├── exitnode/ # cobra CLI binary +│ └── exitnode-mcp/ # MCP stdio server binary ├── internal/ -│ ├── config/ # config loader -│ ├── state/ # on-disk state -│ ├── core/ # rotate orchestrator + action commands -│ ├── gcp/ # GCP Compute Engine adapter -│ ├── tailscale/ # Tailscale v2 API adapter -│ ├── pfsense/ # pfSense REST adapter -│ └── verify/ # egress probes +│ ├── config/ # TOML loader + env-var resolution +│ ├── core/ # rotate orchestrator + lifecycle ops +│ ├── gcp/ # GCP Compute Engine adapter +│ ├── pfsense/ # pfSense REST adapter +│ ├── state/ # on-disk state cache (flock-protected) +│ ├── tailscale/ # Tailscale v2 API adapter +│ └── verify/ # egress probes +├── examples/ +│ ├── config.toml # annotated reference config +│ └── mcp.json # Claude Desktop / OpenClaw MCP snippet ├── scripts/ -│ └── install.sh # VM first-boot bootstrap (fetched via startup-script-url) +│ └── install.sh # VM first-boot bootstrap (fetched via startup-script-url) +├── .github/workflows/ +│ ├── ci.yml # vet + lint + race tests + cross-build matrix +│ └── release.yml # goreleaser on tag push +├── .goreleaser.yaml ├── Makefile ├── go.mod └── README.md @@ -218,19 +223,21 @@ hung. Confirm with `lsof ~/.config/exitnode/state.json.lock`; if nothing holds it, the lock file is stale and safe to delete with `rm ~/.config/exitnode/state.json.lock`. -## Roadmap - -Work is staged in three sequential plans: - -- **Plan 1 — Foundation & Core.** Module layout, `internal/config`, - `internal/state`, interface definitions for all four clients, and the - `internal/core` rotate orchestrator with table-driven mocked tests. - ✅ Complete. -- **Plan 2 — Real client implementations.** `internal/gcp`, - `internal/tailscale`, `internal/pfsense`, `internal/verify`, and - `scripts/install.sh`. ✅ Complete. -- **Plan 3 — Binaries & distribution.** `cmd/exitnode`, `cmd/exitnode-mcp`, - examples, CI workflows, and goreleaser packaging. ✅ Complete. +## What's next + +v0.1 covers the GCP + Tailscale + pfSense path the spec calls out as the +core use case. Items deferred for later versions: + +- **HTTP/SSE MCP transport** so the MCP server can be hosted remotely + rather than only over stdio. +- **`--strict-verify`** flag to promote "no `tailscale` CLI on host" from + a silent probe-skip to a hard failure. +- **Additional cloud providers** — AWS and Hetzner implementations of + the `Provider` interface. +- **Netgate Plus pfSense API** as a second `PFSenseClient` implementation + for users on the official Netgate plugin instead of the community one. +- **Scheduled rotation + auto-teardown on idle.** +- **Homebrew tap** for `brew install exitnode`. ## License From f7bd29fd56336ab3118afe4de20483c170961517 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:50:43 +0000 Subject: [PATCH 24/27] docs(readme): add personal-project disclaimer --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 3495a3d..6d4f76c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ On-demand Tailscale exit nodes that rotate across cloud regions and keep your pfSense gateway in sync. Driven from a CLI for humans and an MCP server for AI agents. +> **Personal project.** Built for my own home network. MIT-licensed and +> forkable, but no guarantees on uptime, breaking changes, or issue response +> — depend on this at your own risk. + ## Why VPN exit nodes are useful exactly because they're not always-on: From 1e536f4dc252cccfd39b0f508562a7137d9550f1 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 20:51:02 +0000 Subject: [PATCH 25/27] ci(lint): pin golangci-lint to v2.x (v1.x is built with go 1.24 < 1.25) --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cd612b..9dad449 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,9 @@ jobs: cache: true - uses: golangci/golangci-lint-action@v6 with: - version: latest + # Must be v2.x — v1 was built with Go 1.24 and refuses to analyze + # source targeting go 1.25 (which our go.mod pins). + version: v2.x args: --timeout=5m build-matrix: From 4b519c42c8ea0420a5abf1c77e26fb10762767c6 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 21:00:25 +0000 Subject: [PATCH 26/27] ci(lint): upgrade to golangci-lint v2.12 (action v9, new v2 config) --- .github/workflows/ci.yml | 9 +++++---- .golangci.yaml | 20 -------------------- .golangci.yml | 29 +++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 24 deletions(-) delete mode 100644 .golangci.yaml create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dad449..65cf4b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,12 @@ jobs: with: go-version: '1.25' cache: true - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v9 with: - # Must be v2.x — v1 was built with Go 1.24 and refuses to analyze - # source targeting go 1.25 (which our go.mod pins). - version: v2.x + # golangci-lint v1.x was built with Go 1.24 and refuses to analyze + # source targeting go 1.25 (which our go.mod pins). Pin a recent + # v2.x; the action's @v6/@v7/@v8 don't speak v2 config format. + version: v2.12.2 args: --timeout=5m build-matrix: diff --git a/.golangci.yaml b/.golangci.yaml deleted file mode 100644 index 2709c6f..0000000 --- a/.golangci.yaml +++ /dev/null @@ -1,20 +0,0 @@ -run: - timeout: 5m - go: "1.22" - -linters: - disable-all: true - enable: - - govet - - errcheck - - ineffassign - - staticcheck - - unused - - gofmt - - goimports - -issues: - exclude-rules: - - path: _test\.go - linters: - - errcheck diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ba96cc6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,29 @@ +version: "2" + +# Standard preset = errcheck, govet, ineffassign, staticcheck, unused. +# Matches what golangci-lint v1 enabled implicitly. Add more linters +# here only after the standard set is consistently green. +linters: + default: standard + settings: + errcheck: + # Stdout-write errors are not meaningfully recoverable in this CLI; + # do not require every fmt.Fprint* / Fprintln / Fprintf call to be + # blank-assigned. + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + exclusions: + rules: + # Test files routinely defer Close() on stores/servers without checking + # the error — the goroutine is already torn down and the next test will + # surface any real problem. + - path: _test\.go + linters: + - errcheck + +formatters: + enable: + - gofmt + - goimports From d83bf59b5e23d56a3e1f168dc0575774378d4338 Mon Sep 17 00:00:00 2001 From: Iker Date: Wed, 20 May 2026 21:00:25 +0000 Subject: [PATCH 27/27] fix(lint): address v2 staticcheck/errcheck/gofmt findings --- cmd/exitnode-mcp/buildcore.go | 1 - internal/core/down_test.go | 2 +- internal/core/mocks_test.go | 8 ++++---- internal/core/sync_test.go | 2 +- internal/gcp/compute.go | 7 ++++++- internal/gcp/types.go | 4 ++-- internal/pfsense/rest.go | 2 +- internal/verify/shell_test.go | 18 +++++++++--------- 8 files changed, 24 insertions(+), 20 deletions(-) diff --git a/cmd/exitnode-mcp/buildcore.go b/cmd/exitnode-mcp/buildcore.go index b4b48ef..2b06969 100644 --- a/cmd/exitnode-mcp/buildcore.go +++ b/cmd/exitnode-mcp/buildcore.go @@ -101,4 +101,3 @@ func buildCore(ctx context.Context) (*core.Core, func(), error) { }) return c, func() { _ = store.Close() }, nil } - diff --git a/internal/core/down_test.go b/internal/core/down_test.go index 836170b..fb2e04c 100644 --- a/internal/core/down_test.go +++ b/internal/core/down_test.go @@ -26,7 +26,7 @@ func downFixture(t *testing.T, active *gcp.ExitNode) *rotateFixture { } } c := New(Deps{ - Config: &config.Config{}, + Config: &config.Config{}, Provider: newMockProvider(), TS: newMockTS(), PF: newMockPF(), Probe: &mockProbe{}, Store: store, Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)), diff --git a/internal/core/mocks_test.go b/internal/core/mocks_test.go index 10f990f..1bf77ff 100644 --- a/internal/core/mocks_test.go +++ b/internal/core/mocks_test.go @@ -84,7 +84,7 @@ func newMockProvider() *mockProvider { // attachClock wires a shared sequence clock into the mock's recorder so its // calls can be merged chronologically with other mocks in the fixture. -func (m *mockProvider) attachClock(c *clock) { m.recorder.clock = c } +func (m *mockProvider) attachClock(c *clock) { m.clock = c } func (m *mockProvider) Provision(ctx context.Context, opts gcp.ProvisionOpts) (*gcp.ExitNode, error) { m.record("Provision", opts.Name, opts.Region, opts.MachineType) @@ -138,7 +138,7 @@ func newMockTS() *mockTS { } } -func (m *mockTS) attachClock(c *clock) { m.recorder.clock = c } +func (m *mockTS) attachClock(c *clock) { m.clock = c } func (m *mockTS) MintEphemeralAuthKey(ctx context.Context, tags []string) (string, error) { m.record("MintEphemeralAuthKey", tags) @@ -186,7 +186,7 @@ func newMockPF() *mockPF { } } -func (m *mockPF) attachClock(c *clock) { m.recorder.clock = c } +func (m *mockPF) attachClock(c *clock) { m.clock = c } func (m *mockPF) GetGateway(ctx context.Context, name string) (*pfsense.Gateway, error) { m.record("GetGateway", name) @@ -236,4 +236,4 @@ func (m *mockProbe) EgressDirect(ctx context.Context) (string, error) { return m.EgressDirectResult, m.EgressDirectErr } -func (m *mockProbe) attachClock(c *clock) { m.recorder.clock = c } +func (m *mockProbe) attachClock(c *clock) { m.clock = c } diff --git a/internal/core/sync_test.go b/internal/core/sync_test.go index 297aa14..e28c38a 100644 --- a/internal/core/sync_test.go +++ b/internal/core/sync_test.go @@ -26,7 +26,7 @@ func syncFixture(t *testing.T, active *gcp.ExitNode, gwIP string) *rotateFixture pf := newMockPF() pf.Gateways["GW"] = gwIP c := New(Deps{ - Config: &config.Config{PFSense: config.PFSenseConfig{GatewayName: "GW"}}, + Config: &config.Config{PFSense: config.PFSenseConfig{GatewayName: "GW"}}, Provider: newMockProvider(), TS: newMockTS(), PF: pf, Probe: &mockProbe{}, Store: store, Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)), diff --git a/internal/gcp/compute.go b/internal/gcp/compute.go index a2f1e98..69edd4c 100644 --- a/internal/gcp/compute.go +++ b/internal/gcp/compute.go @@ -84,7 +84,12 @@ func New(ctx context.Context, opts Options) (Provider, error) { var clientOpts []option.ClientOption if len(opts.CredentialsJSON) > 0 { - clientOpts = append(clientOpts, option.WithCredentialsJSON(opts.CredentialsJSON)) + // SA1019: option.WithCredentialsJSON is marked deprecated because the + // upstream auth library cannot validate JSON it receives. Our caller + // is the operator (config + env var GCP_CREDENTIALS_JSON), which we + // control; the deprecation's threat model doesn't apply. Re-evaluate + // when we migrate to cloud.google.com/go/auth. + clientOpts = append(clientOpts, option.WithCredentialsJSON(opts.CredentialsJSON)) //nolint:staticcheck } inst, err := compute.NewInstancesRESTClient(ctx, clientOpts...) diff --git a/internal/gcp/types.go b/internal/gcp/types.go index dbd60b9..a463d55 100644 --- a/internal/gcp/types.go +++ b/internal/gcp/types.go @@ -69,9 +69,9 @@ type ExitNode struct { // ProvisionOpts is the input to Provider.Provision. type ProvisionOpts struct { - Name string // generated: vpn--- + Name string // generated: vpn--- Region string - Zone string // empty → provider chooses a random zone in Region + Zone string // empty → provider chooses a random zone in Region MachineType string Hostname string // typically == Name TailscaleAuthKey string // ephemeral, single-use, ~5m TTL diff --git a/internal/pfsense/rest.go b/internal/pfsense/rest.go index a90559a..9d3b348 100644 --- a/internal/pfsense/rest.go +++ b/internal/pfsense/rest.go @@ -193,7 +193,7 @@ func (c *pfClient) do(ctx context.Context, method, path string, body io.Reader) if err != nil { return nil, 0, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() b, err := io.ReadAll(resp.Body) if err != nil { return nil, resp.StatusCode, err diff --git a/internal/verify/shell_test.go b/internal/verify/shell_test.go index 4c7d373..ed5cd8d 100644 --- a/internal/verify/shell_test.go +++ b/internal/verify/shell_test.go @@ -57,11 +57,11 @@ func TestEgressVia_HappyPath_RestoresPrior(t *testing.T) { statusJSON := `{"ExitNodeStatus":{"ID":"prior-node-id"}}` fake := &fakeRunner{ Responses: []fakeResponse{ - {Stdout: statusJSON, Err: nil}, // tailscale status --json - {Stdout: "", Err: nil}, // tailscale set --exit-node= - {Stdout: "pong\n", Err: nil}, // tailscale ping - {Stdout: "203.0.113.7\n", Err: nil}, // curl - {Stdout: "", Err: nil}, // tailscale set --exit-node=prior-node-id (defer) + {Stdout: statusJSON, Err: nil}, // tailscale status --json + {Stdout: "", Err: nil}, // tailscale set --exit-node= + {Stdout: "pong\n", Err: nil}, // tailscale ping + {Stdout: "203.0.113.7\n", Err: nil}, // curl + {Stdout: "", Err: nil}, // tailscale set --exit-node=prior-node-id (defer) }, } p := &shellProbe{run: fake, probeURL: "https://example.com/ip"} @@ -164,10 +164,10 @@ func TestEgressDirect_HappyPath_RestoresPrior(t *testing.T) { statusJSON := `{"ExitNodeStatus":{"ID":"prior-node-id"}}` fake := &fakeRunner{ Responses: []fakeResponse{ - {Stdout: statusJSON}, // status - {Stdout: ""}, // set --exit-node= (clear) - {Stdout: "203.0.113.99\n"}, // curl - {Stdout: ""}, // restore + {Stdout: statusJSON}, // status + {Stdout: ""}, // set --exit-node= (clear) + {Stdout: "203.0.113.99\n"}, // curl + {Stdout: ""}, // restore }, } p := &shellProbe{run: fake, probeURL: "https://x/ip"}