From 691539b036cc08530811ced9621b06efe2c8cde1 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 12:01:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(tunnel):=20N4=20=E2=80=94=20cloudflared=20?= =?UTF-8?q?wrapper=20+=20ingress=20+=20secret=20refusal;=20CI=20skips=20do?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/tunnel manages the optional, default-down, account-gated public tunnel (spec 05), behind an injectable Runner so it is fully unit-testable without the binary or a Cloudflare account: - Available / Login / Create(name) / RouteDNS(name, host) / Run(config). RouteDNS refuses a wildcard with the manual-CNAME remediation (cloudflared rejects `*`). - IngressConfig renders a deterministic cloudflared config.yml whose ingress maps every public hostname → the shared Caddy upstream (public reuses local routing, no drift) with the required 404 catch-all last. - SecretBearing returns the NON-local secret:// refs in a service's env so a tunnel refuses to expose a service carrying remote secrets without override (a nil/unknown classifier fails safe → treats all as non-local). - CLI `tunnel login|create|route` replaces the stub (account verbs); running the tunnel container with routes-derived ingress is wired into the saga (N5). CI: add `paths-ignore` for `**/*.md`/docs/LICENSE/NOTICE so docs-only PRs skip the now-heavier consolidated lane (the e2e step made every PR ~4.5 min). Unit-tested (fake runner): available, verbs-need-binary, create+route call-through, wildcard refusal, empty-name error, deterministic ingress rendering + 404 last, SecretBearing local-vs-nonlocal (+ fail-safe nil). CLI registration test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 + internal/cli/root.go | 1 + internal/cli/stubs.go | 7 -- internal/cli/tunnel.go | 72 +++++++++++++++++ internal/cli/tunnel_test.go | 13 +++ internal/tunnel/tunnel.go | 140 +++++++++++++++++++++++++++++++++ internal/tunnel/tunnel_test.go | 128 ++++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 internal/cli/tunnel.go create mode 100644 internal/cli/tunnel_test.go create mode 100644 internal/tunnel/tunnel.go create mode 100644 internal/tunnel/tunnel_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d34bea..5459892 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,9 @@ name: CI on: push: branches: [main] + paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] pull_request: + paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] permissions: contents: read diff --git a/internal/cli/root.go b/internal/cli/root.go index b21fa35..54ee813 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -71,6 +71,7 @@ func NewRootCmd(opts Options) *cobra.Command { newStatusCmd(g), newDnsCmd(g), newTrustCmd(g), + newTunnelCmd(g), newDoctorCmd(g), newConfigCmd(g), newGenerateCmd(g), diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index 07b87fe..95ecd40 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -36,13 +36,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) { stub("login", "Authenticate a secrets provider", "M4"), stub("keygen", "Generate an age/SOPS key", "M4"), ), - stub("tunnel", "Optional public tunnel via cloudflared", "M5", - stub("login", "Authenticate cloudflared", "M5"), - stub("create", "Create a named tunnel", "M5"), - stub("route", "Route DNS to the tunnel", "M5"), - stub("up", "Bring the tunnel up", "M5"), - stub("down", "Bring the tunnel down", "M5"), - ), stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"), stub("workspace", "Workspace-level lifecycle", "M6", stub("destroy", "Reverse ALL machine-global artifacts for this workspace", "M6"), diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go new file mode 100644 index 0000000..4603122 --- /dev/null +++ b/internal/cli/tunnel.go @@ -0,0 +1,72 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/tunnel" +) + +// newTunnelCmd wires `tunnel login|create|route` — the account-gated cloudflared +// setup verbs (spec 05). The tunnel is default-down; bringing it up as a managed +// container (ingress rendered from the proxy routes) is wired into the saga (N5). +func newTunnelCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "tunnel", + Short: "Optional public tunnel via cloudflared (account-gated, default down)", + } + cmd.AddCommand(newTunnelLoginCmd(g), newTunnelCreateCmd(g), newTunnelRouteCmd(g)) + return cmd +} + +func newTunnelLoginCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "login", + Short: "Authenticate cloudflared with your Cloudflare account", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := tunnel.New().Login(cmd.Context()); err != nil { + return err + } + if !g.Quiet { + fmt.Fprintln(cmd.OutOrStdout(), "cloudflared login ok") + } + return nil + }, + } +} + +func newTunnelCreateCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "create ", + Short: "Create a named tunnel (writes its credentials file)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := tunnel.New().Create(cmd.Context(), args[0]); err != nil { + return err + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "tunnel %q created\n", args[0]) + } + return nil + }, + } +} + +func newTunnelRouteCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "route ", + Short: "Route a (non-wildcard) hostname to the tunnel", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := tunnel.New().RouteDNS(cmd.Context(), args[0], args[1]); err != nil { + return err + } + if !g.Quiet { + fmt.Fprintf(cmd.OutOrStdout(), "routed %s → tunnel %q\n", args[1], args[0]) + } + return nil + }, + } +} diff --git a/internal/cli/tunnel_test.go b/internal/cli/tunnel_test.go new file mode 100644 index 0000000..c60aae8 --- /dev/null +++ b/internal/cli/tunnel_test.go @@ -0,0 +1,13 @@ +package cli + +import "testing" + +func TestTunnelRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, sub := range []string{"login", "create", "route"} { + c, _, err := root.Find([]string{"tunnel", sub}) + if err != nil || c.Name() != sub || c.RunE == nil { + t.Errorf("tunnel %s not registered as a real command: %v", sub, err) + } + } +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go new file mode 100644 index 0000000..638cf46 --- /dev/null +++ b/internal/tunnel/tunnel.go @@ -0,0 +1,140 @@ +// Package tunnel manages the optional public tunnel via cloudflared (spec 05). +// The tunnel is DEFAULT-DOWN and account-gated; it reuses local routing by +// pointing its ingress at the shared Caddy container, so the same proxy []Route +// drives both local and public access (no drift). A tunnel must refuse to expose +// a service whose env carries non-local secret:// values without an override. +// +// cloudflared runs through an injectable Runner so the package is fully +// unit-testable without the binary or a Cloudflare account (locked decision #3: +// build the logic, fake-runner test, flag the human/account step). +package tunnel + +import ( + "context" + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "github.com/open-source-cloud/devstack/internal/secrets" +) + +// Runner runs the external cloudflared binary. Injectable for tests. +type Runner interface { + Run(ctx context.Context, name string, args ...string) error + LookPath(file string) (string, error) +} + +// Tunnel wraps cloudflared. The zero value uses the real OS exec runner. +type Tunnel struct { + Runner Runner +} + +// New returns a Tunnel backed by the real exec runner. +func New() *Tunnel { return &Tunnel{Runner: execRunner{}} } + +func (t *Tunnel) runner() Runner { + if t.Runner != nil { + return t.Runner + } + return execRunner{} +} + +// Available reports whether the cloudflared binary is on PATH. +func (t *Tunnel) Available() bool { + _, err := t.runner().LookPath("cloudflared") + return err == nil +} + +// Login runs the interactive `cloudflared tunnel login` (account-gated). +func (t *Tunnel) Login(ctx context.Context) error { + return t.exec(ctx, "tunnel", "login") +} + +// Create creates a named tunnel (writes .json creds) via +// `cloudflared tunnel create ` — avoids the login regression. +func (t *Tunnel) Create(ctx context.Context, name string) error { + if name == "" { + return fmt.Errorf("tunnel name required") + } + return t.exec(ctx, "tunnel", "create", name) +} + +// RouteDNS points a hostname at the tunnel. NOTE: cloudflared rejects a wildcard +// (`*.project`) — that single wildcard CNAME must be created manually in DNS +// (spec 05 gotcha); this routes concrete hostnames. +func (t *Tunnel) RouteDNS(ctx context.Context, name, hostname string) error { + if strings.HasPrefix(hostname, "*") { + return fmt.Errorf("cloudflared cannot route a wildcard %q — create the *. CNAME manually in the Cloudflare dashboard", hostname) + } + return t.exec(ctx, "tunnel", "route", "dns", name, hostname) +} + +// Run starts the tunnel in the foreground with a config file +// (`cloudflared tunnel --config run`). +func (t *Tunnel) Run(ctx context.Context, configPath string) error { + return t.exec(ctx, "tunnel", "--config", configPath, "run") +} + +func (t *Tunnel) exec(ctx context.Context, args ...string) error { + if !t.Available() { + return fmt.Errorf("cloudflared not found on PATH — install it (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and run `devstack tunnel login`") + } + if err := t.runner().Run(ctx, "cloudflared", args...); err != nil { + return fmt.Errorf("cloudflared %s: %w", strings.Join(args, " "), err) + } + return nil +} + +// IngressConfig renders a cloudflared config.yml whose ingress maps every public +// hostname to the shared Caddy upstream (so public reuses local routing), with +// the required catch-all 404 last. Deterministic (hostnames sorted). +func IngressConfig(name, credentialsFile, caddyUpstream string, hostnames []string) string { + var b strings.Builder + fmt.Fprintf(&b, "tunnel: %s\n", name) + fmt.Fprintf(&b, "credentials-file: %s\n", credentialsFile) + b.WriteString("ingress:\n") + hosts := append([]string(nil), hostnames...) + sort.Strings(hosts) + for _, h := range hosts { + fmt.Fprintf(&b, " - hostname: %s\n service: %s\n", h, caddyUpstream) + } + b.WriteString(" - service: http_status:404\n") + return b.String() +} + +// SecretBearing returns the non-local secret:// references found in envValues — a +// tunnel must refuse to expose a service carrying these (unless overridden). +// isLocalProvider classifies a provider name as local (offline, e.g. sops+age) +// vs non-local (aws/infisical); an unknown/empty classifier treats all as +// non-local (fail safe). +func SecretBearing(envValues []string, isLocalProvider func(provider string) bool) []string { + var out []string + for _, v := range envValues { + if !secrets.IsRef(v) { + continue + } + ref, err := secrets.ParseRef(v) + if err != nil { + continue + } + if isLocalProvider != nil && isLocalProvider(ref.Provider) { + continue + } + out = append(out, ref.Raw) + } + sort.Strings(out) + return out +} + +// execRunner is the production Runner. tunnel verbs are interactive/long-running, +// so stdio is inherited. +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} +func (execRunner) LookPath(file string) (string, error) { return exec.LookPath(file) } diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go new file mode 100644 index 0000000..334cbe4 --- /dev/null +++ b/internal/tunnel/tunnel_test.go @@ -0,0 +1,128 @@ +package tunnel + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeRunner struct { + have map[string]bool + err error + calls []string +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.calls = append(f.calls, name+" "+strings.Join(args, " ")) + return f.err +} +func (f *fakeRunner) LookPath(file string) (string, error) { + if f.have[file] { + return "/usr/bin/" + file, nil + } + return "", errors.New("not found") +} + +func have(bins ...string) *fakeRunner { + m := map[string]bool{} + for _, b := range bins { + m[b] = true + } + return &fakeRunner{have: m} +} + +func TestAvailable(t *testing.T) { + if !(&Tunnel{Runner: have("cloudflared")}).Available() { + t.Error("cloudflared present → available") + } + if (&Tunnel{Runner: have()}).Available() { + t.Error("cloudflared absent → not available") + } +} + +func TestVerbsRequireBinary(t *testing.T) { + tr := &Tunnel{Runner: have()} // no cloudflared + if err := tr.Login(context.Background()); err == nil { + t.Error("login without cloudflared should error") + } + if err := tr.Create(context.Background(), "t"); err == nil { + t.Error("create without cloudflared should error") + } +} + +func TestCreateAndRoute(t *testing.T) { + fr := have("cloudflared") + tr := &Tunnel{Runner: fr} + if err := tr.Create(context.Background(), "shop"); err != nil { + t.Fatal(err) + } + if err := tr.RouteDNS(context.Background(), "shop", "api.shop.example.com"); err != nil { + t.Fatal(err) + } + joined := strings.Join(fr.calls, "|") + if !strings.Contains(joined, "cloudflared tunnel create shop") { + t.Errorf("missing create call: %v", fr.calls) + } + if !strings.Contains(joined, "tunnel route dns shop api.shop.example.com") { + t.Errorf("missing route call: %v", fr.calls) + } +} + +func TestRouteDNSRejectsWildcard(t *testing.T) { + tr := &Tunnel{Runner: have("cloudflared")} + if err := tr.RouteDNS(context.Background(), "shop", "*.shop.example.com"); err == nil { + t.Error("cloudflared cannot route a wildcard — should error with manual-CNAME hint") + } +} + +func TestCreateRequiresName(t *testing.T) { + tr := &Tunnel{Runner: have("cloudflared")} + if err := tr.Create(context.Background(), ""); err == nil { + t.Error("empty tunnel name should error") + } +} + +func TestIngressConfig(t *testing.T) { + cfg := IngressConfig("shop", "/creds/shop.json", "https://shared-caddy", + []string{"web.shop.example.com", "api.shop.example.com"}) + // Header. + if !strings.Contains(cfg, "tunnel: shop") || !strings.Contains(cfg, "credentials-file: /creds/shop.json") { + t.Errorf("missing header:\n%s", cfg) + } + // Deterministic order (sorted): api before web. + ai := strings.Index(cfg, "api.shop.example.com") + wi := strings.Index(cfg, "web.shop.example.com") + if ai < 0 || wi < 0 || ai > wi { + t.Errorf("hostnames not sorted:\n%s", cfg) + } + // Each routes to the caddy upstream; catch-all 404 last. + if strings.Count(cfg, "service: https://shared-caddy") != 2 { + t.Errorf("each host should route to caddy:\n%s", cfg) + } + if !strings.HasSuffix(strings.TrimSpace(cfg), "service: http_status:404") { + t.Errorf("ingress must end with the 404 catch-all:\n%s", cfg) + } +} + +func TestSecretBearing(t *testing.T) { + isLocal := func(p string) bool { return p == "sops" } + env := []string{ + "plain-value", + "secret://sops/secrets.yaml#pw", // local → allowed + "secret://aws-sm/app/db#password", // non-local → refused + "secret://infisical/prod/KEY", // non-local → refused + } + got := SecretBearing(env, isLocal) + if len(got) != 2 { + t.Fatalf("SecretBearing = %v, want 2 non-local refs", got) + } + // nil classifier → everything non-local (fail safe). + if len(SecretBearing(env, nil)) != 3 { + t.Errorf("nil classifier should treat all secrets as non-local") + } + // no secrets → none. + if len(SecretBearing([]string{"a", "b"}, isLocal)) != 0 { + t.Errorf("no secrets → empty") + } +}