Skip to content

Commit 91f206f

Browse files
feat(tunnel): N4 — cloudflared wrapper + ingress + secret refusal; CI skips docs (#23)
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) <noreply@anthropic.com>
1 parent fe1c8a0 commit 91f206f

7 files changed

Lines changed: 356 additions & 7 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ name: CI
33
on:
44
push:
55
branches: [main]
6+
paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"]
67
pull_request:
8+
paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"]
79

810
permissions:
911
contents: read

‎internal/cli/root.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func NewRootCmd(opts Options) *cobra.Command {
7171
newStatusCmd(g),
7272
newDnsCmd(g),
7373
newTrustCmd(g),
74+
newTunnelCmd(g),
7475
newDoctorCmd(g),
7576
newConfigCmd(g),
7677
newGenerateCmd(g),

‎internal/cli/stubs.go‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3636
stub("login", "Authenticate a secrets provider", "M4"),
3737
stub("keygen", "Generate an age/SOPS key", "M4"),
3838
),
39-
stub("tunnel", "Optional public tunnel via cloudflared", "M5",
40-
stub("login", "Authenticate cloudflared", "M5"),
41-
stub("create", "Create a named tunnel", "M5"),
42-
stub("route", "Route DNS to the tunnel", "M5"),
43-
stub("up", "Bring the tunnel up", "M5"),
44-
stub("down", "Bring the tunnel down", "M5"),
45-
),
4639
stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"),
4740
stub("workspace", "Workspace-level lifecycle", "M6",
4841
stub("destroy", "Reverse ALL machine-global artifacts for this workspace", "M6"),

‎internal/cli/tunnel.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/open-source-cloud/devstack/internal/tunnel"
9+
)
10+
11+
// newTunnelCmd wires `tunnel login|create|route` — the account-gated cloudflared
12+
// setup verbs (spec 05). The tunnel is default-down; bringing it up as a managed
13+
// container (ingress rendered from the proxy routes) is wired into the saga (N5).
14+
func newTunnelCmd(g *GlobalOpts) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "tunnel",
17+
Short: "Optional public tunnel via cloudflared (account-gated, default down)",
18+
}
19+
cmd.AddCommand(newTunnelLoginCmd(g), newTunnelCreateCmd(g), newTunnelRouteCmd(g))
20+
return cmd
21+
}
22+
23+
func newTunnelLoginCmd(g *GlobalOpts) *cobra.Command {
24+
return &cobra.Command{
25+
Use: "login",
26+
Short: "Authenticate cloudflared with your Cloudflare account",
27+
Args: cobra.NoArgs,
28+
RunE: func(cmd *cobra.Command, _ []string) error {
29+
if err := tunnel.New().Login(cmd.Context()); err != nil {
30+
return err
31+
}
32+
if !g.Quiet {
33+
fmt.Fprintln(cmd.OutOrStdout(), "cloudflared login ok")
34+
}
35+
return nil
36+
},
37+
}
38+
}
39+
40+
func newTunnelCreateCmd(g *GlobalOpts) *cobra.Command {
41+
return &cobra.Command{
42+
Use: "create <name>",
43+
Short: "Create a named tunnel (writes its credentials file)",
44+
Args: cobra.ExactArgs(1),
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
if err := tunnel.New().Create(cmd.Context(), args[0]); err != nil {
47+
return err
48+
}
49+
if !g.Quiet {
50+
fmt.Fprintf(cmd.OutOrStdout(), "tunnel %q created\n", args[0])
51+
}
52+
return nil
53+
},
54+
}
55+
}
56+
57+
func newTunnelRouteCmd(g *GlobalOpts) *cobra.Command {
58+
return &cobra.Command{
59+
Use: "route <name> <hostname>",
60+
Short: "Route a (non-wildcard) hostname to the tunnel",
61+
Args: cobra.ExactArgs(2),
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
if err := tunnel.New().RouteDNS(cmd.Context(), args[0], args[1]); err != nil {
64+
return err
65+
}
66+
if !g.Quiet {
67+
fmt.Fprintf(cmd.OutOrStdout(), "routed %s → tunnel %q\n", args[1], args[0])
68+
}
69+
return nil
70+
},
71+
}
72+
}

‎internal/cli/tunnel_test.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package cli
2+
3+
import "testing"
4+
5+
func TestTunnelRegistered(t *testing.T) {
6+
root := NewRootCmd(Options{})
7+
for _, sub := range []string{"login", "create", "route"} {
8+
c, _, err := root.Find([]string{"tunnel", sub})
9+
if err != nil || c.Name() != sub || c.RunE == nil {
10+
t.Errorf("tunnel %s not registered as a real command: %v", sub, err)
11+
}
12+
}
13+
}

‎internal/tunnel/tunnel.go‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Package tunnel manages the optional public tunnel via cloudflared (spec 05).
2+
// The tunnel is DEFAULT-DOWN and account-gated; it reuses local routing by
3+
// pointing its ingress at the shared Caddy container, so the same proxy []Route
4+
// drives both local and public access (no drift). A tunnel must refuse to expose
5+
// a service whose env carries non-local secret:// values without an override.
6+
//
7+
// cloudflared runs through an injectable Runner so the package is fully
8+
// unit-testable without the binary or a Cloudflare account (locked decision #3:
9+
// build the logic, fake-runner test, flag the human/account step).
10+
package tunnel
11+
12+
import (
13+
"context"
14+
"fmt"
15+
"os"
16+
"os/exec"
17+
"sort"
18+
"strings"
19+
20+
"github.com/open-source-cloud/devstack/internal/secrets"
21+
)
22+
23+
// Runner runs the external cloudflared binary. Injectable for tests.
24+
type Runner interface {
25+
Run(ctx context.Context, name string, args ...string) error
26+
LookPath(file string) (string, error)
27+
}
28+
29+
// Tunnel wraps cloudflared. The zero value uses the real OS exec runner.
30+
type Tunnel struct {
31+
Runner Runner
32+
}
33+
34+
// New returns a Tunnel backed by the real exec runner.
35+
func New() *Tunnel { return &Tunnel{Runner: execRunner{}} }
36+
37+
func (t *Tunnel) runner() Runner {
38+
if t.Runner != nil {
39+
return t.Runner
40+
}
41+
return execRunner{}
42+
}
43+
44+
// Available reports whether the cloudflared binary is on PATH.
45+
func (t *Tunnel) Available() bool {
46+
_, err := t.runner().LookPath("cloudflared")
47+
return err == nil
48+
}
49+
50+
// Login runs the interactive `cloudflared tunnel login` (account-gated).
51+
func (t *Tunnel) Login(ctx context.Context) error {
52+
return t.exec(ctx, "tunnel", "login")
53+
}
54+
55+
// Create creates a named tunnel (writes <UUID>.json creds) via
56+
// `cloudflared tunnel create <name>` — avoids the login regression.
57+
func (t *Tunnel) Create(ctx context.Context, name string) error {
58+
if name == "" {
59+
return fmt.Errorf("tunnel name required")
60+
}
61+
return t.exec(ctx, "tunnel", "create", name)
62+
}
63+
64+
// RouteDNS points a hostname at the tunnel. NOTE: cloudflared rejects a wildcard
65+
// (`*.project`) — that single wildcard CNAME must be created manually in DNS
66+
// (spec 05 gotcha); this routes concrete hostnames.
67+
func (t *Tunnel) RouteDNS(ctx context.Context, name, hostname string) error {
68+
if strings.HasPrefix(hostname, "*") {
69+
return fmt.Errorf("cloudflared cannot route a wildcard %q — create the *.<project> CNAME manually in the Cloudflare dashboard", hostname)
70+
}
71+
return t.exec(ctx, "tunnel", "route", "dns", name, hostname)
72+
}
73+
74+
// Run starts the tunnel in the foreground with a config file
75+
// (`cloudflared tunnel --config <path> run`).
76+
func (t *Tunnel) Run(ctx context.Context, configPath string) error {
77+
return t.exec(ctx, "tunnel", "--config", configPath, "run")
78+
}
79+
80+
func (t *Tunnel) exec(ctx context.Context, args ...string) error {
81+
if !t.Available() {
82+
return fmt.Errorf("cloudflared not found on PATH — install it (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and run `devstack tunnel login`")
83+
}
84+
if err := t.runner().Run(ctx, "cloudflared", args...); err != nil {
85+
return fmt.Errorf("cloudflared %s: %w", strings.Join(args, " "), err)
86+
}
87+
return nil
88+
}
89+
90+
// IngressConfig renders a cloudflared config.yml whose ingress maps every public
91+
// hostname to the shared Caddy upstream (so public reuses local routing), with
92+
// the required catch-all 404 last. Deterministic (hostnames sorted).
93+
func IngressConfig(name, credentialsFile, caddyUpstream string, hostnames []string) string {
94+
var b strings.Builder
95+
fmt.Fprintf(&b, "tunnel: %s\n", name)
96+
fmt.Fprintf(&b, "credentials-file: %s\n", credentialsFile)
97+
b.WriteString("ingress:\n")
98+
hosts := append([]string(nil), hostnames...)
99+
sort.Strings(hosts)
100+
for _, h := range hosts {
101+
fmt.Fprintf(&b, " - hostname: %s\n service: %s\n", h, caddyUpstream)
102+
}
103+
b.WriteString(" - service: http_status:404\n")
104+
return b.String()
105+
}
106+
107+
// SecretBearing returns the non-local secret:// references found in envValues — a
108+
// tunnel must refuse to expose a service carrying these (unless overridden).
109+
// isLocalProvider classifies a provider name as local (offline, e.g. sops+age)
110+
// vs non-local (aws/infisical); an unknown/empty classifier treats all as
111+
// non-local (fail safe).
112+
func SecretBearing(envValues []string, isLocalProvider func(provider string) bool) []string {
113+
var out []string
114+
for _, v := range envValues {
115+
if !secrets.IsRef(v) {
116+
continue
117+
}
118+
ref, err := secrets.ParseRef(v)
119+
if err != nil {
120+
continue
121+
}
122+
if isLocalProvider != nil && isLocalProvider(ref.Provider) {
123+
continue
124+
}
125+
out = append(out, ref.Raw)
126+
}
127+
sort.Strings(out)
128+
return out
129+
}
130+
131+
// execRunner is the production Runner. tunnel verbs are interactive/long-running,
132+
// so stdio is inherited.
133+
type execRunner struct{}
134+
135+
func (execRunner) Run(ctx context.Context, name string, args ...string) error {
136+
cmd := exec.CommandContext(ctx, name, args...)
137+
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
138+
return cmd.Run()
139+
}
140+
func (execRunner) LookPath(file string) (string, error) { return exec.LookPath(file) }

‎internal/tunnel/tunnel_test.go‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package tunnel
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"testing"
8+
)
9+
10+
type fakeRunner struct {
11+
have map[string]bool
12+
err error
13+
calls []string
14+
}
15+
16+
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
17+
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
18+
return f.err
19+
}
20+
func (f *fakeRunner) LookPath(file string) (string, error) {
21+
if f.have[file] {
22+
return "/usr/bin/" + file, nil
23+
}
24+
return "", errors.New("not found")
25+
}
26+
27+
func have(bins ...string) *fakeRunner {
28+
m := map[string]bool{}
29+
for _, b := range bins {
30+
m[b] = true
31+
}
32+
return &fakeRunner{have: m}
33+
}
34+
35+
func TestAvailable(t *testing.T) {
36+
if !(&Tunnel{Runner: have("cloudflared")}).Available() {
37+
t.Error("cloudflared present → available")
38+
}
39+
if (&Tunnel{Runner: have()}).Available() {
40+
t.Error("cloudflared absent → not available")
41+
}
42+
}
43+
44+
func TestVerbsRequireBinary(t *testing.T) {
45+
tr := &Tunnel{Runner: have()} // no cloudflared
46+
if err := tr.Login(context.Background()); err == nil {
47+
t.Error("login without cloudflared should error")
48+
}
49+
if err := tr.Create(context.Background(), "t"); err == nil {
50+
t.Error("create without cloudflared should error")
51+
}
52+
}
53+
54+
func TestCreateAndRoute(t *testing.T) {
55+
fr := have("cloudflared")
56+
tr := &Tunnel{Runner: fr}
57+
if err := tr.Create(context.Background(), "shop"); err != nil {
58+
t.Fatal(err)
59+
}
60+
if err := tr.RouteDNS(context.Background(), "shop", "api.shop.example.com"); err != nil {
61+
t.Fatal(err)
62+
}
63+
joined := strings.Join(fr.calls, "|")
64+
if !strings.Contains(joined, "cloudflared tunnel create shop") {
65+
t.Errorf("missing create call: %v", fr.calls)
66+
}
67+
if !strings.Contains(joined, "tunnel route dns shop api.shop.example.com") {
68+
t.Errorf("missing route call: %v", fr.calls)
69+
}
70+
}
71+
72+
func TestRouteDNSRejectsWildcard(t *testing.T) {
73+
tr := &Tunnel{Runner: have("cloudflared")}
74+
if err := tr.RouteDNS(context.Background(), "shop", "*.shop.example.com"); err == nil {
75+
t.Error("cloudflared cannot route a wildcard — should error with manual-CNAME hint")
76+
}
77+
}
78+
79+
func TestCreateRequiresName(t *testing.T) {
80+
tr := &Tunnel{Runner: have("cloudflared")}
81+
if err := tr.Create(context.Background(), ""); err == nil {
82+
t.Error("empty tunnel name should error")
83+
}
84+
}
85+
86+
func TestIngressConfig(t *testing.T) {
87+
cfg := IngressConfig("shop", "/creds/shop.json", "https://shared-caddy",
88+
[]string{"web.shop.example.com", "api.shop.example.com"})
89+
// Header.
90+
if !strings.Contains(cfg, "tunnel: shop") || !strings.Contains(cfg, "credentials-file: /creds/shop.json") {
91+
t.Errorf("missing header:\n%s", cfg)
92+
}
93+
// Deterministic order (sorted): api before web.
94+
ai := strings.Index(cfg, "api.shop.example.com")
95+
wi := strings.Index(cfg, "web.shop.example.com")
96+
if ai < 0 || wi < 0 || ai > wi {
97+
t.Errorf("hostnames not sorted:\n%s", cfg)
98+
}
99+
// Each routes to the caddy upstream; catch-all 404 last.
100+
if strings.Count(cfg, "service: https://shared-caddy") != 2 {
101+
t.Errorf("each host should route to caddy:\n%s", cfg)
102+
}
103+
if !strings.HasSuffix(strings.TrimSpace(cfg), "service: http_status:404") {
104+
t.Errorf("ingress must end with the 404 catch-all:\n%s", cfg)
105+
}
106+
}
107+
108+
func TestSecretBearing(t *testing.T) {
109+
isLocal := func(p string) bool { return p == "sops" }
110+
env := []string{
111+
"plain-value",
112+
"secret://sops/secrets.yaml#pw", // local → allowed
113+
"secret://aws-sm/app/db#password", // non-local → refused
114+
"secret://infisical/prod/KEY", // non-local → refused
115+
}
116+
got := SecretBearing(env, isLocal)
117+
if len(got) != 2 {
118+
t.Fatalf("SecretBearing = %v, want 2 non-local refs", got)
119+
}
120+
// nil classifier → everything non-local (fail safe).
121+
if len(SecretBearing(env, nil)) != 3 {
122+
t.Errorf("nil classifier should treat all secrets as non-local")
123+
}
124+
// no secrets → none.
125+
if len(SecretBearing([]string{"a", "b"}, isLocal)) != 0 {
126+
t.Errorf("no secrets → empty")
127+
}
128+
}

0 commit comments

Comments
 (0)