Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newStatusCmd(g),
newDnsCmd(g),
newTrustCmd(g),
newTunnelCmd(g),
newDoctorCmd(g),
newConfigCmd(g),
newGenerateCmd(g),
Expand Down
7 changes: 0 additions & 7 deletions internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
72 changes: 72 additions & 0 deletions internal/cli/tunnel.go
Original file line number Diff line number Diff line change
@@ -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 <name>",
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 <name> <hostname>",
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
},
}
}
13 changes: 13 additions & 0 deletions internal/cli/tunnel_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
140 changes: 140 additions & 0 deletions internal/tunnel/tunnel.go
Original file line number Diff line number Diff line change
@@ -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 <UUID>.json creds) via
// `cloudflared tunnel create <name>` — 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 *.<project> 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 <path> 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) }
128 changes: 128 additions & 0 deletions internal/tunnel/tunnel_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading