From cabcb0d5b8f2857bede972fdb36d0e2b541f968f Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 6 May 2026 19:50:02 +0800 Subject: [PATCH 1/5] [verified] feat(tunnel): rebuild persistent tunnel/domain flow on current main --- cmd/obol/main.go | 101 +--- cmd/obol/sell.go | 2 +- cmd/obol/tunnel_domain.go | 426 ++++++++++++++++ .../cloudflared/templates/deployment.yaml | 61 ++- .../cloudflared/templates/pdb.yaml | 22 + .../infrastructure/cloudflared/values.yaml | 7 + internal/hermes/hermes.go | 2 +- internal/openclaw/openclaw.go | 2 +- internal/stack/stack.go | 8 +- internal/tunnel/cloudflare.go | 431 ++++++++++++---- internal/tunnel/cloudflare_test.go | 188 +++++++ internal/tunnel/domain_setup.go | 373 ++++++++++++++ internal/tunnel/login.go | 15 +- internal/tunnel/provision.go | 181 ++++--- internal/tunnel/resources.go | 54 +++ internal/tunnel/restore.go | 106 ++++ internal/tunnel/state.go | 189 +++++++- internal/tunnel/tunnel.go | 458 +++++++++++++----- internal/tunnel/tunnel_lifecycle_test.go | 73 ++- 19 files changed, 2238 insertions(+), 461 deletions(-) create mode 100644 cmd/obol/tunnel_domain.go create mode 100644 internal/embed/infrastructure/cloudflared/templates/pdb.yaml create mode 100644 internal/tunnel/cloudflare_test.go create mode 100644 internal/tunnel/domain_setup.go create mode 100644 internal/tunnel/resources.go create mode 100644 internal/tunnel/restore.go diff --git a/cmd/obol/main.go b/cmd/obol/main.go index 714c6334..0ba43eaa 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -14,7 +14,6 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/kubectl" "github.com/ObolNetwork/obol-stack/internal/stack" - "github.com/ObolNetwork/obol-stack/internal/tunnel" "github.com/ObolNetwork/obol-stack/internal/ui" "github.com/ObolNetwork/obol-stack/internal/version" "github.com/urfave/cli/v3" @@ -227,104 +226,10 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} agentCommand(cfg), walletCommand(cfg), // ============================================================ - // Tunnel Management Commands + // Tunnel & Domain Commands // ============================================================ - { - Name: "tunnel", - Usage: "Manage Cloudflare tunnel for public access", - Commands: []*cli.Command{ - { - Name: "status", - Usage: "Show tunnel status and public URL", - Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Status(cfg, getUI(cmd)) - }, - }, - { - Name: "login", - Usage: "Authenticate via browser and create a locally-managed tunnel (no API token)", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "hostname", - Aliases: []string{"H"}, - Usage: "Public hostname to route (e.g. stack.example.com)", - Required: true, - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Login(cfg, getUI(cmd), tunnel.LoginOptions{ - Hostname: cmd.String("hostname"), - }) - }, - }, - { - Name: "provision", - Usage: "Provision a persistent (DNS-routed) Cloudflare Tunnel", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "hostname", - Aliases: []string{"H"}, - Usage: "Public hostname to route (e.g. stack.example.com)", - Required: true, - }, - &cli.StringFlag{ - Name: "account-id", - Aliases: []string{"a"}, - Usage: "Cloudflare account ID (or set CLOUDFLARE_ACCOUNT_ID)", - Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID"), - }, - &cli.StringFlag{ - Name: "zone-id", - Aliases: []string{"z"}, - Usage: "Cloudflare zone ID for the hostname (or set CLOUDFLARE_ZONE_ID)", - Sources: cli.EnvVars("CLOUDFLARE_ZONE_ID"), - }, - &cli.StringFlag{ - Name: "api-token", - Aliases: []string{"t"}, - Usage: "Cloudflare API token (or set CLOUDFLARE_API_TOKEN)", - Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN"), - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Provision(cfg, getUI(cmd), tunnel.ProvisionOptions{ - Hostname: cmd.String("hostname"), - AccountID: cmd.String("account-id"), - ZoneID: cmd.String("zone-id"), - APIToken: cmd.String("api-token"), - }) - }, - }, - { - Name: "restart", - Usage: "Restart the tunnel connector (quick tunnels get a new URL)", - Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Restart(cfg, getUI(cmd)) - }, - }, - { - Name: "stop", - Usage: "Stop the tunnel (scale cloudflared to 0 replicas)", - Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Stop(cfg, getUI(cmd)) - }, - }, - { - Name: "logs", - Usage: "View cloudflared logs", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "follow", - Aliases: []string{"f"}, - Usage: "Follow log output", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Logs(cfg, cmd.Bool("follow")) - }, - }, - }, - }, + tunnelCommand(cfg), + domainCommand(cfg), // ============================================================ // Kubernetes Tool Passthroughs (with auto-configured KUBECONFIG) // ============================================================ diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 42fb8a23..294b96aa 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -2364,7 +2364,7 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { "-o", "jsonpath={.items}") if listErr == nil && (remaining == "[]" || strings.TrimSpace(remaining) == "") { st, _ := tunnel.LoadTunnelState(cfg) - if st == nil || st.Mode != "dns" { + if st == nil || !st.IsPersistent() { u.Blank() u.Info("No ServiceOffers remaining. Stopping quick tunnel.") _ = tunnel.Stop(cfg, u) diff --git a/cmd/obol/tunnel_domain.go b/cmd/obol/tunnel_domain.go new file mode 100644 index 00000000..cfae7ee7 --- /dev/null +++ b/cmd/obol/tunnel_domain.go @@ -0,0 +1,426 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/tunnel" + "github.com/urfave/cli/v3" +) + +func tunnelCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "tunnel", + Usage: "Manage Cloudflare tunnel for public access", + Commands: []*cli.Command{ + { + Name: "status", + Usage: "Show tunnel status and public URL", + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Status(cfg, getUI(cmd)) + }, + }, + { + Name: "setup", + Usage: "Guided persistent tunnel setup with optional domain registration", + Flags: tunnelSetupFlags(), + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + opts, err := setupOptionsFromCommand(cmd, u) + if err != nil { + return err + } + result, err := tunnel.Setup(cfg, u, opts) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + u.Blank() + u.Successf("Tunnel ready: %s", result.URL) + return nil + }, + }, + { + Name: "login", + Usage: "Authenticate via browser and create a locally-managed tunnel (no API token)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "hostname", + Aliases: []string{"H"}, + Usage: "Public hostname to route (e.g. stack.example.com)", + Required: true, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Login(cfg, getUI(cmd), tunnel.LoginOptions{Hostname: cmd.String("hostname")}) + }, + }, + { + Name: "provision", + Usage: "Provision a persistent remote-managed Cloudflare Tunnel", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "hostname", + Aliases: []string{"H"}, + Usage: "Public hostname to route (e.g. stack.example.com)", + Required: true, + }, + &cli.StringFlag{ + Name: "account-id", + Aliases: []string{"a"}, + Usage: "Cloudflare account ID (optional if the API token can access a single account)", + Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID"), + }, + &cli.StringFlag{ + Name: "zone-id", + Aliases: []string{"z"}, + Usage: "Cloudflare zone ID for the hostname (auto-detected when omitted)", + Sources: cli.EnvVars("CLOUDFLARE_ZONE_ID"), + }, + &cli.StringFlag{ + Name: "api-token", + Aliases: []string{"t"}, + Usage: "Cloudflare API token", + Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN"), + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Provision(cfg, getUI(cmd), tunnel.ProvisionOptions{ + Hostname: cmd.String("hostname"), + AccountID: cmd.String("account-id"), + ZoneID: cmd.String("zone-id"), + APIToken: cmd.String("api-token"), + }) + }, + }, + { + Name: "restart", + Usage: "Restart the tunnel connector (quick tunnels get a new URL)", + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Restart(cfg, getUI(cmd)) + }, + }, + { + Name: "stop", + Usage: "Stop the tunnel (scale cloudflared to 0 replicas)", + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Stop(cfg, getUI(cmd)) + }, + }, + { + Name: "logs", + Usage: "View cloudflared logs", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "follow", Aliases: []string{"f"}, Usage: "Follow log output"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return tunnel.Logs(cfg, cmd.Bool("follow")) + }, + }, + }, + } +} + +func domainCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "domain", + Usage: "Search, check, and register Cloudflare Registrar domains", + Commands: []*cli.Command{ + { + Name: "search", + Usage: "Search for available Cloudflare Registrar domains", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "query", Aliases: []string{"q"}, Usage: "Keyword, phrase, or domain to search for"}, + &cli.StringSliceFlag{Name: "extensions", Usage: "Optional extension filter(s), e.g. --extensions com --extensions dev"}, + &cli.IntFlag{Name: "limit", Usage: "Maximum number of suggestions to return", Value: 10}, + &cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")}, + &cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")}, + &cli.StringFlag{Name: "from-json", Usage: "Read search options from JSON file (or - for stdin)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + opts, err := domainSearchOptionsFromCommand(cmd, u) + if err != nil { + return err + } + result, err := tunnel.SearchDomains(opts) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + printDomainSuggestions(u, result) + return nil + }, + }, + { + Name: "check", + Usage: "Check authoritative availability for one or more domains", + ArgsUsage: " [ ...]", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")}, + &cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")}, + &cli.StringFlag{Name: "from-json", Usage: "Read check options from JSON file (or - for stdin)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + opts, err := domainCheckOptionsFromCommand(cmd) + if err != nil { + return err + } + result, err := tunnel.CheckDomains(opts) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + printDomainChecks(u, result) + return nil + }, + }, + { + Name: "register", + Usage: "Register a domain through Cloudflare Registrar", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.IntFlag{Name: "years", Usage: "Registration term in years (default 1 or registry minimum)", Value: 1}, + &cli.BoolFlag{Name: "auto-renew", Usage: "Enable automatic renewal"}, + &cli.StringFlag{Name: "privacy-mode", Usage: "WHOIS privacy mode", Value: "redaction"}, + &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Confirm the billable registration without prompting"}, + &cli.BoolFlag{Name: "respond-async", Usage: "Request an immediate async workflow response from Cloudflare"}, + &cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")}, + &cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")}, + &cli.StringFlag{Name: "from-json", Usage: "Read registration options from JSON file (or - for stdin)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + opts, err := domainRegisterOptionsFromCommand(cmd) + if err != nil { + return err + } + result, err := tunnel.RegisterDomain(u, opts) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(result) + } + printDomainRegistration(u, result) + return nil + }, + }, + }, + } +} + +func tunnelSetupFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{Name: "hostname", Aliases: []string{"H"}, Usage: "Public hostname to route (e.g. stack.example.com)"}, + &cli.StringFlag{Name: "management", Usage: "Tunnel management mode: local or remote", Value: "auto"}, + &cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")}, + &cli.StringFlag{Name: "zone-id", Aliases: []string{"z"}, Usage: "Cloudflare zone ID (auto-detected when omitted)", Sources: cli.EnvVars("CLOUDFLARE_ZONE_ID")}, + &cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")}, + &cli.BoolFlag{Name: "register-domain", Usage: "Register the domain apex via Cloudflare Registrar when the zone is missing"}, + &cli.IntFlag{Name: "years", Usage: "Domain registration term in years", Value: 1}, + &cli.BoolFlag{Name: "auto-renew", Usage: "Enable domain auto-renew when registering a domain"}, + &cli.StringFlag{Name: "privacy-mode", Usage: "WHOIS privacy mode for registration", Value: "redaction"}, + &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Confirm billable domain registration without prompting"}, + &cli.StringFlag{Name: "from-json", Usage: "Read setup options from JSON file (or - for stdin)"}, + } +} + +func setupOptionsFromCommand(cmd *cli.Command, u interface { + Input(string, string) (string, error) +}) (tunnel.SetupOptions, error) { + if jsonPath := cmd.String("from-json"); jsonPath != "" { + var opts tunnel.SetupOptions + data, err := readJSONInput(jsonPath) + if err != nil { + return opts, err + } + if err := json.Unmarshal(data, &opts); err != nil { + return opts, fmt.Errorf("parse setup JSON: %w", err) + } + return opts, nil + } + + hostname := cmd.String("hostname") + if strings.TrimSpace(hostname) == "" { + input, err := u.Input("Public hostname", "") + if err != nil { + return tunnel.SetupOptions{}, err + } + hostname = input + } + + return tunnel.SetupOptions{ + Hostname: hostname, + Management: cmd.String("management"), + AccountID: cmd.String("account-id"), + ZoneID: cmd.String("zone-id"), + APIToken: cmd.String("api-token"), + RegisterDomain: cmd.Bool("register-domain"), + Years: cmd.Int("years"), + AutoRenew: cmd.Bool("auto-renew"), + PrivacyMode: cmd.String("privacy-mode"), + ConfirmCharge: cmd.Bool("yes"), + }, nil +} + +func domainSearchOptionsFromCommand(cmd *cli.Command, u interface { + Input(string, string) (string, error) +}) (tunnel.DomainSearchOptions, error) { + if jsonPath := cmd.String("from-json"); jsonPath != "" { + var opts tunnel.DomainSearchOptions + data, err := readJSONInput(jsonPath) + if err != nil { + return opts, err + } + if err := json.Unmarshal(data, &opts); err != nil { + return opts, fmt.Errorf("parse domain search JSON: %w", err) + } + return opts, nil + } + + query := cmd.String("query") + if strings.TrimSpace(query) == "" { + input, err := u.Input("Search query", "") + if err != nil { + return tunnel.DomainSearchOptions{}, err + } + query = input + } + + return tunnel.DomainSearchOptions{ + Query: query, + Extensions: cmd.StringSlice("extensions"), + Limit: cmd.Int("limit"), + AccountID: cmd.String("account-id"), + APIToken: cmd.String("api-token"), + }, nil +} + +func domainCheckOptionsFromCommand(cmd *cli.Command) (tunnel.DomainCheckOptions, error) { + if jsonPath := cmd.String("from-json"); jsonPath != "" { + var opts tunnel.DomainCheckOptions + data, err := readJSONInput(jsonPath) + if err != nil { + return opts, err + } + if err := json.Unmarshal(data, &opts); err != nil { + return opts, fmt.Errorf("parse domain check JSON: %w", err) + } + return opts, nil + } + + return tunnel.DomainCheckOptions{ + Domains: cmd.Args().Slice(), + AccountID: cmd.String("account-id"), + APIToken: cmd.String("api-token"), + }, nil +} + +func domainRegisterOptionsFromCommand(cmd *cli.Command) (tunnel.DomainRegisterOptions, error) { + if jsonPath := cmd.String("from-json"); jsonPath != "" { + var opts tunnel.DomainRegisterOptions + data, err := readJSONInput(jsonPath) + if err != nil { + return opts, err + } + if err := json.Unmarshal(data, &opts); err != nil { + return opts, fmt.Errorf("parse domain registration JSON: %w", err) + } + return opts, nil + } + + return tunnel.DomainRegisterOptions{ + DomainName: cmd.Args().First(), + Years: cmd.Int("years"), + AutoRenew: cmd.Bool("auto-renew"), + PrivacyMode: cmd.String("privacy-mode"), + ConfirmCharge: cmd.Bool("yes"), + RespondAsync: cmd.Bool("respond-async"), + AccountID: cmd.String("account-id"), + APIToken: cmd.String("api-token"), + }, nil +} + +func printDomainSuggestions(u interface { + Blank() + Bold(string) + Print(string) + Detail(string, string) +}, result *tunnel.DomainSearchResult) { + u.Blank() + u.Bold("Domain Suggestions") + for _, domain := range result.Domains { + summary := "not registrable" + if domain.Registrable { + summary = tunnelSummaryPrice(domain) + } + if domain.Reason != "" { + summary = summary + " — " + domain.Reason + } + u.Print("- " + domain.Name) + u.Detail(" Status", summary) + } +} + +func printDomainChecks(u interface { + Blank() + Bold(string) + Print(string) + Detail(string, string) +}, result *tunnel.DomainCheckResult) { + u.Blank() + u.Bold("Domain Availability") + for _, domain := range result.Domains { + status := "not registrable" + if domain.Registrable { + status = tunnelSummaryPrice(domain) + } + if domain.Reason != "" { + status = status + " — " + domain.Reason + } + u.Print("- " + domain.Name) + u.Detail(" Status", status) + } +} + +func printDomainRegistration(u interface { + Blank() + Bold(string) + Print(string) + Detail(string, string) + Successf(string, ...any) +}, result *tunnel.DomainRegisterResult) { + u.Blank() + u.Successf("Domain registration submitted for %s", result.Availability.Name) + u.Detail("Price", tunnelSummaryPrice(result.Availability)) + if result.Workflow != nil { + u.Detail("Workflow State", result.Workflow.State) + if result.Workflow.Links.Self != "" { + u.Detail("Workflow URL", result.Workflow.Links.Self) + } + if result.Workflow.Links.Resource != "" { + u.Detail("Domain Resource", result.Workflow.Links.Resource) + } + } +} + +func tunnelSummaryPrice(domain tunnel.CloudflareRegistrarDomainAlias) string { + if domain.Pricing == nil || domain.Pricing.RegistrationCost == "" { + return "registrable" + } + if domain.Pricing.Currency == "" { + return domain.Pricing.RegistrationCost + "/year" + } + return domain.Pricing.Currency + " " + domain.Pricing.RegistrationCost + "/year" +} diff --git a/internal/embed/infrastructure/cloudflared/templates/deployment.yaml b/internal/embed/infrastructure/cloudflared/templates/deployment.yaml index 8ae75b97..4256382c 100644 --- a/internal/embed/infrastructure/cloudflared/templates/deployment.yaml +++ b/internal/embed/infrastructure/cloudflared/templates/deployment.yaml @@ -4,11 +4,22 @@ {{- $localSecretName := default "cloudflared-local-credentials" .Values.localManaged.secretName -}} {{- $localConfigMapName := default "cloudflared-local-config" .Values.localManaged.configMapName -}} {{- $localTunnelIDKey := default "tunnel_id" .Values.localManaged.tunnelIDKey -}} +{{- $managementConfigMapName := default "cloudflared-management" .Values.management.configMapName -}} +{{- $managementModeKey := default "management_mode" .Values.management.modeKey -}} +{{- $persistentReplicas := int (default 2 .Values.persistent.replicaCount) -}} + +{{- $effectiveMode := $mode -}} +{{- if eq $mode "auto" -}} +{{- $mgmt := lookup "v1" "ConfigMap" .Release.Namespace $managementConfigMapName -}} +{{- if and $mgmt $mgmt.data (hasKey $mgmt.data $managementModeKey) -}} +{{- $effectiveMode = get $mgmt.data $managementModeKey | default "auto" -}} +{{- end -}} +{{- end -}} {{- $useLocal := false -}} -{{- if eq $mode "local" -}} +{{- if eq $effectiveMode "local" -}} {{- $useLocal = true -}} -{{- else if eq $mode "auto" -}} +{{- else if eq $effectiveMode "auto" -}} {{- $ls := lookup "v1" "Secret" .Release.Namespace $localSecretName -}} {{- $cm := lookup "v1" "ConfigMap" .Release.Namespace $localConfigMapName -}} {{- if and $ls $cm -}} @@ -18,9 +29,9 @@ {{- $useRemote := false -}} {{- if not $useLocal -}} -{{- if eq $mode "remote" -}} +{{- if eq $effectiveMode "remote" -}} {{- $useRemote = true -}} -{{- else if eq $mode "auto" -}} +{{- else if eq $effectiveMode "auto" -}} {{- $rs := lookup "v1" "Secret" .Release.Namespace $remoteSecretName -}} {{- if $rs -}} {{- $useRemote = true -}} @@ -28,6 +39,11 @@ {{- end -}} {{- end -}} +{{- $replicas := 0 -}} +{{- if or $useLocal $useRemote -}} +{{- $replicas = $persistentReplicas -}} +{{- end -}} + apiVersion: apps/v1 kind: Deployment metadata: @@ -36,11 +52,7 @@ metadata: app.kubernetes.io/name: cloudflared app.kubernetes.io/part-of: obol-stack spec: - {{- if or $useLocal $useRemote }} - replicas: 1 - {{- else }} - replicas: 0 - {{- end }} + replicas: {{ $replicas }} selector: matchLabels: app.kubernetes.io/name: cloudflared @@ -57,22 +69,22 @@ spec: - --no-autoupdate - --metrics - {{ .Values.metrics.address | quote }} - {{ if $useLocal }} + {{- if $useLocal }} - --origincert - /etc/cloudflared/cert.pem - --config - /etc/cloudflared/config.yml - run - "$(TUNNEL_ID)" - {{ else if $useRemote }} + {{- else if $useRemote }} - run - --token - "$(TUNNEL_TOKEN)" - {{ else }} + {{- else }} - --url - {{ .Values.quickTunnel.url | quote }} - {{ end }} - {{ if $useLocal }} + {{- end }} + {{- if $useLocal }} env: - name: TUNNEL_ID valueFrom: @@ -83,23 +95,36 @@ spec: - name: cloudflared-local mountPath: /etc/cloudflared readOnly: true - {{ else if $useRemote }} + {{- else if $useRemote }} env: - name: TUNNEL_TOKEN valueFrom: secretKeyRef: name: {{ $remoteSecretName | quote }} key: {{ $remoteSecretKey | quote }} - {{ end }} + {{- end }} ports: - name: metrics containerPort: 2000 + startupProbe: + httpGet: + path: /ready + port: metrics + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: metrics + periodSeconds: 10 + failureThreshold: 3 livenessProbe: httpGet: path: /ready port: metrics initialDelaySeconds: 10 periodSeconds: 10 + failureThreshold: 6 resources: requests: cpu: 10m @@ -107,7 +132,7 @@ spec: limits: cpu: 100m memory: 128Mi - {{ if $useLocal }} + {{- if $useLocal }} volumes: - name: cloudflared-local projected: @@ -116,5 +141,5 @@ spec: name: {{ $localSecretName | quote }} - configMap: name: {{ $localConfigMapName | quote }} - {{ end }} + {{- end }} restartPolicy: Always diff --git a/internal/embed/infrastructure/cloudflared/templates/pdb.yaml b/internal/embed/infrastructure/cloudflared/templates/pdb.yaml new file mode 100644 index 00000000..599a35b2 --- /dev/null +++ b/internal/embed/infrastructure/cloudflared/templates/pdb.yaml @@ -0,0 +1,22 @@ +{{- $mode := default "auto" .Values.mode -}} +{{- $managementConfigMapName := default "cloudflared-management" .Values.management.configMapName -}} +{{- $managementModeKey := default "management_mode" .Values.management.modeKey -}} +{{- $persistentReplicas := int (default 2 .Values.persistent.replicaCount) -}} +{{- $effectiveMode := $mode -}} +{{- if eq $mode "auto" -}} +{{- $mgmt := lookup "v1" "ConfigMap" .Release.Namespace $managementConfigMapName -}} +{{- if and $mgmt $mgmt.data (hasKey $mgmt.data $managementModeKey) -}} +{{- $effectiveMode = get $mgmt.data $managementModeKey | default "auto" -}} +{{- end -}} +{{- end -}} +{{- if and (or (eq $effectiveMode "local") (eq $effectiveMode "remote")) (gt $persistentReplicas 1) }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: cloudflared +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: cloudflared +{{- end }} diff --git a/internal/embed/infrastructure/cloudflared/values.yaml b/internal/embed/infrastructure/cloudflared/values.yaml index ac84be2b..2fb32c3d 100644 --- a/internal/embed/infrastructure/cloudflared/values.yaml +++ b/internal/embed/infrastructure/cloudflared/values.yaml @@ -10,6 +10,13 @@ metrics: quickTunnel: url: "http://traefik.traefik.svc.cluster.local:80" +persistent: + replicaCount: 2 + +management: + configMapName: "cloudflared-management" + modeKey: "management_mode" + remoteManaged: tokenSecretName: "cloudflared-tunnel-token" tokenSecretKey: "TUNNEL_TOKEN" diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index ff892dc7..642287b1 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -161,7 +161,7 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { agentBaseURL := "" if opts.AgentMode { - if st, _ := tunnel.LoadTunnelState(cfg); st != nil && st.Hostname != "" { + if st, _ := tunnel.LoadTunnelState(cfg); st != nil && st.IsPersistent() { agentBaseURL = "https://" + st.Hostname } } diff --git a/internal/openclaw/openclaw.go b/internal/openclaw/openclaw.go index 9be6eddc..3fba946c 100644 --- a/internal/openclaw/openclaw.go +++ b/internal/openclaw/openclaw.go @@ -275,7 +275,7 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { if opts.AgentMode { st, _ := tunnel.LoadTunnelState(cfg) - if st != nil && st.Hostname != "" { + if st != nil && st.IsPersistent() { agentBaseURL = "https://" + st.Hostname } // Agent mode always needs Ollama models for local inference, diff --git a/internal/stack/stack.go b/internal/stack/stack.go index f4cfd974..cbc5618e 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -480,10 +480,12 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s // Quick tunnels are dormant by default and activate on first `obol sell`. u.Blank() - if st, _ := tunnel.LoadTunnelState(cfg); st != nil && st.Mode == "dns" && st.Hostname != "" { + if st, _ := tunnel.LoadTunnelState(cfg); st != nil && st.IsPersistent() && st.Hostname != "" { u.Info("Starting persistent Cloudflare tunnel") - - if tunnelURL, err := tunnel.EnsureRunning(cfg, u); err != nil { + if err := tunnel.RestorePersistentResources(cfg, u); err != nil { + u.Warnf("Tunnel resources could not be restored automatically: %v", err) + u.Dim(" Fix and retry with: obol tunnel restart") + } else if tunnelURL, err := tunnel.EnsureRunning(cfg, u); err != nil { u.Warnf("Tunnel not started: %v", err) u.Dim(" Start manually with: obol tunnel restart") } else { diff --git a/internal/tunnel/cloudflare.go b/internal/tunnel/cloudflare.go index 7eeda76f..3efc81e5 100644 --- a/internal/tunnel/cloudflare.go +++ b/internal/tunnel/cloudflare.go @@ -8,21 +8,189 @@ import ( "io" "net/http" "net/url" + "path" + "slices" "strings" "time" + + "golang.org/x/net/publicsuffix" ) +const cloudflareAPIBaseURL = "https://api.cloudflare.com/client/v4" + type cloudflareTunnel struct { - ID string `json:"id"` - Token string `json:"token"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + Token string `json:"token,omitempty"` + Status string `json:"status,omitempty"` + Connections []cloudflareTunnelConnection `json:"connections,omitempty"` +} + +type cloudflareTunnelConnection struct { + ID string `json:"id,omitempty"` +} + +type cloudflareAccount struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type cloudflareZone struct { + ID string `json:"id"` + Name string `json:"name"` + Account cloudflareAccount `json:"account"` +} + +type cloudflareDomainPricing struct { + Currency string `json:"currency,omitempty"` + RegistrationCost string `json:"registration_cost,omitempty"` + RenewalCost string `json:"renewal_cost,omitempty"` +} + +type cloudflareRegistrarDomain struct { + Name string `json:"name"` + Registrable bool `json:"registrable"` + Pricing *cloudflareDomainPricing `json:"pricing,omitempty"` + Reason string `json:"reason,omitempty"` + Tier string `json:"tier,omitempty"` +} + +type cloudflareRegistrationRequest struct { + DomainName string `json:"domain_name"` + AutoRenew bool `json:"auto_renew,omitempty"` + PrivacyMode string `json:"privacy_mode,omitempty"` + Years int `json:"years,omitempty"` +} + +type cloudflareWorkflowError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type cloudflareWorkflowLinks struct { + Self string `json:"self"` + Resource string `json:"resource,omitempty"` +} + +type cloudflareRegistrarWorkflow struct { + Completed bool `json:"completed"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + State string `json:"state"` + Links cloudflareWorkflowLinks `json:"links"` + Context map[string]any `json:"context,omitempty"` + Error *cloudflareWorkflowError `json:"error,omitempty"` +} + +type dnsRecord struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Proxied bool `json:"proxied"` } type cloudflareClient struct { - apiToken string + apiToken string + baseURL string + httpClient *http.Client +} + +type cloudflareAPIError struct { + Code any `json:"code"` + Message string `json:"message"` +} + +type cloudflareAPIResponse[T any] struct { + Success bool `json:"success"` + Errors []cloudflareAPIError `json:"errors"` + Result T `json:"result"` } func newCloudflareClient(apiToken string) *cloudflareClient { - return &cloudflareClient{apiToken: apiToken} + return &cloudflareClient{ + apiToken: strings.TrimSpace(apiToken), + baseURL: cloudflareAPIBaseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func extractZoneName(hostname string) (string, error) { + hostname = normalizeHostname(hostname) + if hostname == "" { + return "", errors.New("hostname is required") + } + + zoneName, err := publicsuffix.EffectiveTLDPlusOne(hostname) + if err != nil { + return "", fmt.Errorf("derive zone from hostname %q: %w", hostname, err) + } + + return zoneName, nil +} + +func (c *cloudflareClient) ListAccounts() ([]cloudflareAccount, error) { + var resp cloudflareAPIResponse[[]cloudflareAccount] + if err := c.doJSON(http.MethodGet, "/accounts", nil, nil, &resp); err != nil { + return nil, err + } + + if !resp.Success { + return nil, c.apiError("cloudflare account list failed", resp.Errors) + } + + return resp.Result, nil +} + +func (c *cloudflareClient) ResolveAccountID(explicitAccountID string) (string, error) { + explicitAccountID = strings.TrimSpace(explicitAccountID) + if explicitAccountID != "" { + return explicitAccountID, nil + } + + accounts, err := c.ListAccounts() + if err != nil { + return "", err + } + + switch len(accounts) { + case 0: + return "", errors.New("cloudflare token cannot access any accounts") + case 1: + return accounts[0].ID, nil + default: + return "", errors.New("--account-id is required because the Cloudflare token can access multiple accounts") + } +} + +func (c *cloudflareClient) ResolveZoneForHostname(hostname string) (*cloudflareZone, error) { + zoneName, err := extractZoneName(hostname) + if err != nil { + return nil, err + } + + q := url.Values{} + q.Set("name", zoneName) + + var resp cloudflareAPIResponse[[]cloudflareZone] + if err := c.doJSON(http.MethodGet, "/zones?"+q.Encode(), nil, nil, &resp); err != nil { + return nil, err + } + + if !resp.Success { + return nil, c.apiError("cloudflare zone lookup failed", resp.Errors) + } + + for _, zone := range resp.Result { + if strings.EqualFold(zone.Name, zoneName) { + zoneCopy := zone + return &zoneCopy, nil + } + } + + return nil, fmt.Errorf("cloudflare zone %q was not found; add the zone to Cloudflare or register it first", zoneName) } func (c *cloudflareClient) CreateTunnel(accountID, tunnelName string) (*cloudflareTunnel, error) { @@ -31,42 +199,39 @@ func (c *cloudflareClient) CreateTunnel(accountID, tunnelName string) (*cloudfla "config_src": "cloudflare", } - var resp struct { - Success bool `json:"success"` - Errors []struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"errors"` - Result struct { - ID string `json:"id"` - Token string `json:"token"` - } `json:"result"` + var resp cloudflareAPIResponse[cloudflareTunnel] + if err := c.doJSON(http.MethodPost, fmt.Sprintf("/accounts/%s/cfd_tunnel", accountID), reqBody, nil, &resp); err != nil { + return nil, err + } + + if !resp.Success { + return nil, c.apiError("cloudflare tunnel create failed", resp.Errors) } - if err := c.doJSON("POST", fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/cfd_tunnel", accountID), reqBody, &resp); err != nil { + return &resp.Result, nil +} + +func (c *cloudflareClient) GetTunnel(accountID, tunnelID string) (*cloudflareTunnel, error) { + var resp cloudflareAPIResponse[cloudflareTunnel] + if err := c.doJSON(http.MethodGet, fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", accountID, tunnelID), nil, nil, &resp); err != nil { return nil, err } if !resp.Success { - return nil, fmt.Errorf("cloudflare tunnel create failed: %v", resp.Errors) + return nil, c.apiError("cloudflare tunnel fetch failed", resp.Errors) } - return &cloudflareTunnel{ID: resp.Result.ID, Token: resp.Result.Token}, nil + return &resp.Result, nil } func (c *cloudflareClient) GetTunnelToken(accountID, tunnelID string) (string, error) { - var resp struct { - Success bool `json:"success"` - Errors []any `json:"errors"` - Result string `json:"result"` - } - - if err := c.doJSON("GET", fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/cfd_tunnel/%s/token", accountID, tunnelID), nil, &resp); err != nil { + var resp cloudflareAPIResponse[string] + if err := c.doJSON(http.MethodGet, fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/token", accountID, tunnelID), nil, nil, &resp); err != nil { return "", err } if !resp.Success || resp.Result == "" { - return "", errors.New("cloudflare tunnel token fetch failed") + return "", c.apiError("cloudflare tunnel token fetch failed", resp.Errors) } return resp.Result, nil @@ -88,112 +253,145 @@ func (c *cloudflareClient) UpdateTunnelConfiguration(accountID, tunnelID, hostna }, } - var resp struct { - Success bool `json:"success"` - Errors []struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"errors"` - } - - url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/cfd_tunnel/%s/configurations", accountID, tunnelID) - if err := c.doJSON("PUT", url, reqBody, &resp); err != nil { + var resp cloudflareAPIResponse[map[string]any] + endpoint := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/configurations", accountID, tunnelID) + if err := c.doJSON(http.MethodPut, endpoint, reqBody, nil, &resp); err != nil { return err } if !resp.Success { - return fmt.Errorf("cloudflare tunnel configuration update failed: %v", resp.Errors) + return c.apiError("cloudflare tunnel configuration update failed", resp.Errors) } return nil } -type dnsRecord struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Content string `json:"content"` - Proxied bool `json:"proxied"` -} - func (c *cloudflareClient) UpsertTunnelDNSRecord(zoneID, hostname, content string) error { - // Find existing records for this exact name/type. - listURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=CNAME&name=%s", zoneID, url.QueryEscape(hostname)) - - var listResp struct { - Success bool `json:"success"` - Errors []struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"errors"` - Result []dnsRecord `json:"result"` - } - if err := c.doJSON("GET", listURL, nil, &listResp); err != nil { + q := url.Values{} + q.Set("type", "CNAME") + q.Set("name", hostname) + + var listResp cloudflareAPIResponse[[]dnsRecord] + endpoint := fmt.Sprintf("/zones/%s/dns_records?%s", zoneID, q.Encode()) + if err := c.doJSON(http.MethodGet, endpoint, nil, nil, &listResp); err != nil { return err } if !listResp.Success { - return fmt.Errorf("cloudflare dns record list failed: %v", listResp.Errors) + return c.apiError("cloudflare dns record list failed", listResp.Errors) } - if len(listResp.Result) > 0 { - // Update first matching record. - recID := listResp.Result[0].ID - updateURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recID) - reqBody := map[string]any{ - "type": "CNAME", - "proxied": true, - "name": hostname, - "content": content, - } + reqBody := map[string]any{ + "type": "CNAME", + "proxied": true, + "name": hostname, + "content": content, + } - var updResp struct { - Success bool `json:"success"` - Errors []struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"errors"` - } - if err := c.doJSON("PUT", updateURL, reqBody, &updResp); err != nil { + if len(listResp.Result) > 0 { + var updResp cloudflareAPIResponse[dnsRecord] + updateEndpoint := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, listResp.Result[0].ID) + if err := c.doJSON(http.MethodPut, updateEndpoint, reqBody, nil, &updResp); err != nil { return err } - if !updResp.Success { - return fmt.Errorf("cloudflare dns record update failed: %v", updResp.Errors) + return c.apiError("cloudflare dns record update failed", updResp.Errors) } - return nil } - // Create new record. - createURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID) - reqBody := map[string]any{ - "type": "CNAME", - "proxied": true, - "name": hostname, - "content": content, + var createResp cloudflareAPIResponse[dnsRecord] + createEndpoint := fmt.Sprintf("/zones/%s/dns_records", zoneID) + if err := c.doJSON(http.MethodPost, createEndpoint, reqBody, nil, &createResp); err != nil { + return err + } + if !createResp.Success { + return c.apiError("cloudflare dns record create failed", createResp.Errors) } - var createResp struct { - Success bool `json:"success"` - Errors []struct { - Code int `json:"code"` - Message string `json:"message"` - } `json:"errors"` + return nil +} + +func (c *cloudflareClient) SearchRegistrarDomains(accountID, query string, limit int, extensions []string) ([]cloudflareRegistrarDomain, error) { + q := url.Values{} + q.Set("q", query) + if limit > 0 { + q.Set("limit", fmt.Sprintf("%d", limit)) + } + for _, ext := range extensions { + ext = strings.TrimSpace(strings.TrimPrefix(ext, ".")) + if ext != "" { + q.Add("extensions", ext) + } } - if err := c.doJSON("POST", createURL, reqBody, &createResp); err != nil { - return err + var resp cloudflareAPIResponse[struct { + Domains []cloudflareRegistrarDomain `json:"domains"` + }] + endpoint := fmt.Sprintf("/accounts/%s/registrar/domain-search?%s", accountID, q.Encode()) + if err := c.doJSON(http.MethodGet, endpoint, nil, nil, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, c.apiError("cloudflare registrar search failed", resp.Errors) } - if !createResp.Success { - return fmt.Errorf("cloudflare dns record create failed: %v", createResp.Errors) + return resp.Result.Domains, nil +} + +func (c *cloudflareClient) CheckRegistrarDomains(accountID string, domains []string) ([]cloudflareRegistrarDomain, error) { + if len(domains) == 0 { + return nil, errors.New("at least one domain is required") + } + if len(domains) > 20 { + return nil, errors.New("cloudflare registrar domain-check supports up to 20 domains per request") } - return nil + var resp cloudflareAPIResponse[struct { + Domains []cloudflareRegistrarDomain `json:"domains"` + }] + endpoint := fmt.Sprintf("/accounts/%s/registrar/domain-check", accountID) + if err := c.doJSON(http.MethodPost, endpoint, map[string]any{"domains": domains}, nil, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, c.apiError("cloudflare registrar availability check failed", resp.Errors) + } + + return resp.Result.Domains, nil +} + +func (c *cloudflareClient) CreateRegistration(accountID string, req cloudflareRegistrationRequest, respondAsync bool) (*cloudflareRegistrarWorkflow, error) { + headers := map[string]string{} + if respondAsync { + headers["Prefer"] = "respond-async" + } + + var resp cloudflareAPIResponse[cloudflareRegistrarWorkflow] + endpoint := fmt.Sprintf("/accounts/%s/registrar/registrations", accountID) + if err := c.doJSON(http.MethodPost, endpoint, req, headers, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, c.apiError("cloudflare domain registration failed", resp.Errors) + } + + return &resp.Result, nil +} + +func (c *cloudflareClient) GetWorkflowStatus(statusURL string) (*cloudflareRegistrarWorkflow, error) { + var resp cloudflareAPIResponse[cloudflareRegistrarWorkflow] + if err := c.doJSON(http.MethodGet, statusURL, nil, nil, &resp); err != nil { + return nil, err + } + if !resp.Success { + return nil, c.apiError("cloudflare workflow status fetch failed", resp.Errors) + } + + return &resp.Result, nil } -func (c *cloudflareClient) doJSON(method, url string, reqBody any, out any) error { +func (c *cloudflareClient) doJSON(method, endpoint string, reqBody any, headers map[string]string, out any) error { var ( body []byte err error @@ -206,17 +404,33 @@ func (c *cloudflareClient) doJSON(method, url string, reqBody any, out any) erro } } - req, err := http.NewRequest(method, url, bytes.NewReader(body)) + requestURL := endpoint + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + base, err := url.Parse(strings.TrimRight(c.baseURL, "/") + "/") + if err != nil { + return err + } + base.Path = path.Join(base.Path, strings.TrimPrefix(endpoint, "/")) + if strings.Contains(endpoint, "?") { + parts := strings.SplitN(strings.TrimPrefix(endpoint, "/"), "?", 2) + base.Path = path.Join(path.Dir(base.Path), path.Base(parts[0])) + base.RawQuery = parts[1] + } + requestURL = base.String() + } + + req, err := http.NewRequest(method, requestURL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.apiToken) req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } - client := &http.Client{Timeout: 30 * time.Second} - - resp, err := client.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return err } @@ -228,7 +442,6 @@ func (c *cloudflareClient) doJSON(method, url string, reqBody any, out any) erro } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // Best effort: surface body for debugging without leaking secrets. return fmt.Errorf("cloudflare api error (%s): %s", resp.Status, strings.TrimSpace(string(respBody))) } @@ -242,3 +455,17 @@ func (c *cloudflareClient) doJSON(method, url string, reqBody any, out any) erro return nil } + +func (c *cloudflareClient) apiError(prefix string, errs []cloudflareAPIError) error { + if len(errs) == 0 { + return errors.New(prefix) + } + + parts := make([]string, 0, len(errs)) + for _, apiErr := range errs { + parts = append(parts, fmt.Sprintf("[%v] %s", apiErr.Code, apiErr.Message)) + } + slices.Sort(parts) + + return fmt.Errorf("%s: %s", prefix, strings.Join(parts, "; ")) +} diff --git a/internal/tunnel/cloudflare_test.go b/internal/tunnel/cloudflare_test.go new file mode 100644 index 00000000..725f2f68 --- /dev/null +++ b/internal/tunnel/cloudflare_test.go @@ -0,0 +1,188 @@ +package tunnel + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestExtractZoneName(t *testing.T) { + zone, err := extractZoneName("api.stack.example.co.uk") + if err != nil { + t.Fatalf("extractZoneName: %v", err) + } + if zone != "example.co.uk" { + t.Fatalf("zone = %q, want example.co.uk", zone) + } +} + +func TestSaveAndLoadRemoteTunnelToken(t *testing.T) { + cfg := testConfig(t) + if err := saveRemoteTunnelToken(cfg, "secret-token"); err != nil { + t.Fatalf("saveRemoteTunnelToken: %v", err) + } + got, err := loadRemoteTunnelToken(cfg) + if err != nil { + t.Fatalf("loadRemoteTunnelToken: %v", err) + } + if got != "secret-token" { + t.Fatalf("token = %q, want secret-token", got) + } +} + +func TestCloudflareClientResolveAccountIDSingleAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/accounts" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": []map[string]any{{"id": "acct-123", "name": "Main"}}, + }) + })) + defer server.Close() + + client := newCloudflareClient("token") + client.baseURL = server.URL + + accountID, err := client.ResolveAccountID("") + if err != nil { + t.Fatalf("ResolveAccountID: %v", err) + } + if accountID != "acct-123" { + t.Fatalf("accountID = %q, want acct-123", accountID) + } +} + +func TestCloudflareClientResolveZoneForHostname(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/zones" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("name"); got != "example.co.uk" { + t.Fatalf("zone query = %q, want example.co.uk", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": []map[string]any{{ + "id": "zone-123", + "name": "example.co.uk", + "account": map[string]any{ + "id": "acct-123", + "name": "Main", + }, + }}, + }) + })) + defer server.Close() + + client := newCloudflareClient("token") + client.baseURL = server.URL + + zone, err := client.ResolveZoneForHostname("stack.example.co.uk") + if err != nil { + t.Fatalf("ResolveZoneForHostname: %v", err) + } + if zone.ID != "zone-123" || zone.Account.ID != "acct-123" { + t.Fatalf("unexpected zone: %+v", zone) + } +} + +func TestCloudflareClientRegistrarEndpoints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/accounts/acct-123/registrar/domain-search": + if got := r.URL.Query().Get("q"); got != "obol" { + t.Fatalf("search q = %q, want obol", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "domains": []map[string]any{{ + "name": "obolstack.dev", + "registrable": true, + "tier": "standard", + "pricing": map[string]any{ + "currency": "USD", + "registration_cost": "10.00", + "renewal_cost": "10.00", + }, + }}, + }, + }) + case r.Method == http.MethodPost && r.URL.Path == "/accounts/acct-123/registrar/domain-check": + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "obolstack.dev") { + t.Fatalf("domain-check body missing domain: %s", string(body)) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "domains": []map[string]any{{ + "name": "obolstack.dev", + "registrable": true, + "tier": "standard", + "pricing": map[string]any{ + "currency": "USD", + "registration_cost": "10.00", + "renewal_cost": "10.00", + }, + }}, + }, + }) + case r.Method == http.MethodPost && r.URL.Path == "/accounts/acct-123/registrar/registrations": + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "obolstack.dev") { + t.Fatalf("registrations body missing domain: %s", string(body)) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "completed": true, + "state": "succeeded", + "links": map[string]any{ + "self": "https://example.test/workflows/1", + "resource": "https://example.test/domains/obolstack.dev", + }, + }, + }) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newCloudflareClient("token") + client.baseURL = server.URL + + search, err := client.SearchRegistrarDomains("acct-123", "obol", 5, []string{"dev"}) + if err != nil { + t.Fatalf("SearchRegistrarDomains: %v", err) + } + if len(search) != 1 || search[0].Name != "obolstack.dev" { + t.Fatalf("unexpected search results: %+v", search) + } + + check, err := client.CheckRegistrarDomains("acct-123", []string{"obolstack.dev"}) + if err != nil { + t.Fatalf("CheckRegistrarDomains: %v", err) + } + if len(check) != 1 || !check[0].Registrable { + t.Fatalf("unexpected check results: %+v", check) + } + + workflow, err := client.CreateRegistration("acct-123", cloudflareRegistrationRequest{ + DomainName: "obolstack.dev", + Years: 1, + PrivacyMode: "redaction", + }, false) + if err != nil { + t.Fatalf("CreateRegistration: %v", err) + } + if workflow.State != "succeeded" { + t.Fatalf("workflow state = %q, want succeeded", workflow.State) + } +} diff --git a/internal/tunnel/domain_setup.go b/internal/tunnel/domain_setup.go new file mode 100644 index 00000000..4e7c186e --- /dev/null +++ b/internal/tunnel/domain_setup.go @@ -0,0 +1,373 @@ +package tunnel + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +type DomainSearchOptions struct { + Query string + Extensions []string + Limit int + AccountID string + APIToken string +} + +type DomainSearchResult struct { + AccountID string `json:"account_id"` + Domains []cloudflareRegistrarDomain `json:"domains"` +} + +type DomainCheckOptions struct { + Domains []string + AccountID string + APIToken string +} + +type DomainCheckResult struct { + AccountID string `json:"account_id"` + Domains []cloudflareRegistrarDomain `json:"domains"` +} + +type DomainRegisterOptions struct { + DomainName string + Years int + AutoRenew bool + PrivacyMode string + ConfirmCharge bool + RespondAsync bool + AccountID string + APIToken string +} + +type DomainRegisterResult struct { + AccountID string `json:"account_id"` + Availability cloudflareRegistrarDomain `json:"availability"` + Workflow *cloudflareRegistrarWorkflow `json:"workflow,omitempty"` +} + +type SetupOptions struct { + Hostname string + Management string + AccountID string + ZoneID string + APIToken string + RegisterDomain bool + Years int + AutoRenew bool + PrivacyMode string + ConfirmCharge bool +} + +type SetupResult struct { + Hostname string `json:"hostname"` + URL string `json:"url"` + Mode string `json:"mode"` + ManagementMode string `json:"management_mode"` + AccountID string `json:"account_id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + RegistrationStatus *cloudflareRegistrarWorkflow `json:"registration_status,omitempty"` +} + +// Exported aliases for CLI and JSON presentation helpers. +type CloudflareRegistrarDomainAlias = cloudflareRegistrarDomain + +type CloudflareRegistrarWorkflowAlias = cloudflareRegistrarWorkflow + +func SearchDomains(opts DomainSearchOptions) (*DomainSearchResult, error) { + query := strings.TrimSpace(opts.Query) + if query == "" { + return nil, errors.New("domain search query is required") + } + if opts.APIToken == "" { + return nil, errors.New("--api-token is required (or set CLOUDFLARE_API_TOKEN)") + } + + client := newCloudflareClient(opts.APIToken) + accountID, err := client.ResolveAccountID(opts.AccountID) + if err != nil { + return nil, err + } + + limit := opts.Limit + if limit <= 0 { + limit = 20 + } + domains, err := client.SearchRegistrarDomains(accountID, query, limit, opts.Extensions) + if err != nil { + return nil, err + } + + return &DomainSearchResult{AccountID: accountID, Domains: domains}, nil +} + +func CheckDomains(opts DomainCheckOptions) (*DomainCheckResult, error) { + if len(opts.Domains) == 0 { + return nil, errors.New("at least one domain is required") + } + if opts.APIToken == "" { + return nil, errors.New("--api-token is required (or set CLOUDFLARE_API_TOKEN)") + } + + normalized := make([]string, 0, len(opts.Domains)) + for _, domain := range opts.Domains { + domain = normalizeHostname(domain) + if domain != "" { + normalized = append(normalized, domain) + } + } + if len(normalized) == 0 { + return nil, errors.New("at least one valid domain is required") + } + + client := newCloudflareClient(opts.APIToken) + accountID, err := client.ResolveAccountID(opts.AccountID) + if err != nil { + return nil, err + } + + domains, err := client.CheckRegistrarDomains(accountID, normalized) + if err != nil { + return nil, err + } + + return &DomainCheckResult{AccountID: accountID, Domains: domains}, nil +} + +func RegisterDomain(u *ui.UI, opts DomainRegisterOptions) (*DomainRegisterResult, error) { + domainName := normalizeHostname(opts.DomainName) + if domainName == "" { + return nil, errors.New("domain name is required") + } + if opts.APIToken == "" { + return nil, errors.New("--api-token is required (or set CLOUDFLARE_API_TOKEN)") + } + + privacyMode := strings.TrimSpace(opts.PrivacyMode) + if privacyMode == "" { + privacyMode = "redaction" + } + + client := newCloudflareClient(opts.APIToken) + accountID, err := client.ResolveAccountID(opts.AccountID) + if err != nil { + return nil, err + } + + check, err := CheckDomains(DomainCheckOptions{ + Domains: []string{domainName}, + AccountID: accountID, + APIToken: opts.APIToken, + }) + if err != nil { + return nil, err + } + + availability, err := findDomainAvailability(check.Domains, domainName) + if err != nil { + return nil, err + } + if !availability.Registrable { + return nil, fmt.Errorf("%s is not registrable via the Cloudflare API (%s)", domainName, availability.Reason) + } + if strings.EqualFold(availability.Tier, "premium") { + return nil, fmt.Errorf("%s is a premium domain and cannot be registered via the Cloudflare API yet", domainName) + } + + priceText := formatDomainPricing(availability) + if !opts.ConfirmCharge { + if !u.IsTTY() || u.IsJSON() { + return nil, errors.New("domain registration is billable; rerun with --yes to confirm the charge") + } + if !u.Confirm(fmt.Sprintf("Register %s for %s? This is billable and non-refundable.", domainName, priceText), false) { + return nil, errors.New("domain registration cancelled") + } + } + + workflow, err := client.CreateRegistration(accountID, cloudflareRegistrationRequest{ + DomainName: domainName, + Years: opts.Years, + AutoRenew: opts.AutoRenew, + PrivacyMode: privacyMode, + }, opts.RespondAsync) + if err != nil { + return nil, err + } + + if workflow != nil && !workflow.Completed && workflow.Links.Self != "" { + workflow, err = waitForWorkflow(client, workflow, 6, 5*time.Second) + if err != nil { + return nil, err + } + } + if workflow != nil && workflow.Error != nil && workflow.State == "failed" { + return nil, fmt.Errorf("domain registration failed: %s", workflow.Error.Message) + } + + return &DomainRegisterResult{ + AccountID: accountID, + Availability: availability, + Workflow: workflow, + }, nil +} + +func Setup(cfg *config.Config, u *ui.UI, opts SetupOptions) (*SetupResult, error) { + hostname := normalizeHostname(opts.Hostname) + if hostname == "" { + return nil, errors.New("--hostname is required (e.g. stack.example.com)") + } + + management := strings.ToLower(strings.TrimSpace(opts.Management)) + if management == "" || management == "auto" { + if strings.TrimSpace(opts.APIToken) != "" { + management = tunnelManagementRemote + } else { + management = tunnelManagementLocal + } + } + + if management == tunnelManagementLocal { + if err := Login(cfg, u, LoginOptions{Hostname: hostname}); err != nil { + return nil, err + } + return &SetupResult{ + Hostname: hostname, + URL: "https://" + hostname, + Mode: tunnelExposurePersistent, + ManagementMode: tunnelManagementLocal, + }, nil + } + if management != tunnelManagementRemote { + return nil, fmt.Errorf("unsupported tunnel management mode %q", management) + } + if strings.TrimSpace(opts.APIToken) == "" { + return nil, errors.New("--api-token is required for remote tunnel setup") + } + + client := newCloudflareClient(opts.APIToken) + zone, err := client.ResolveZoneForHostname(hostname) + var workflow *cloudflareRegistrarWorkflow + if err != nil { + zoneName, zoneErr := extractZoneName(hostname) + if zoneErr != nil { + return nil, zoneErr + } + if !opts.RegisterDomain { + if u.IsTTY() && !u.IsJSON() { + opts.RegisterDomain = u.Confirm(fmt.Sprintf("Cloudflare does not have zone %s. Register it through Cloudflare Registrar now?", zoneName), false) + } + } + if !opts.RegisterDomain { + return nil, fmt.Errorf("could not resolve a Cloudflare zone for %s: %w. Add the domain to Cloudflare first or rerun with --register-domain", hostname, err) + } + + registerResult, regErr := RegisterDomain(u, DomainRegisterOptions{ + DomainName: zoneName, + Years: opts.Years, + AutoRenew: opts.AutoRenew, + PrivacyMode: opts.PrivacyMode, + ConfirmCharge: opts.ConfirmCharge, + AccountID: opts.AccountID, + APIToken: opts.APIToken, + }) + if regErr != nil { + return nil, regErr + } + workflow = registerResult.Workflow + zone, err = waitForZone(client, hostname, 12, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("domain registration started, but the Cloudflare zone is not ready yet: %w", err) + } + if opts.AccountID == "" { + opts.AccountID = registerResult.AccountID + } + } + + if zone != nil { + if opts.AccountID == "" { + opts.AccountID = zone.Account.ID + } + if opts.ZoneID == "" { + opts.ZoneID = zone.ID + } + } + + if err := Provision(cfg, u, ProvisionOptions{ + Hostname: hostname, + AccountID: opts.AccountID, + ZoneID: opts.ZoneID, + APIToken: opts.APIToken, + }); err != nil { + return nil, err + } + + return &SetupResult{ + Hostname: hostname, + URL: "https://" + hostname, + Mode: tunnelExposurePersistent, + ManagementMode: tunnelManagementRemote, + AccountID: opts.AccountID, + ZoneID: opts.ZoneID, + RegistrationStatus: workflow, + }, nil +} + +func findDomainAvailability(domains []cloudflareRegistrarDomain, domainName string) (cloudflareRegistrarDomain, error) { + for _, domain := range domains { + if strings.EqualFold(domain.Name, domainName) { + return domain, nil + } + } + return cloudflareRegistrarDomain{}, fmt.Errorf("cloudflare did not return availability data for %s", domainName) +} + +func formatDomainPricing(domain cloudflareRegistrarDomain) string { + if domain.Pricing == nil || domain.Pricing.RegistrationCost == "" { + return "current registry pricing" + } + if domain.Pricing.Currency == "" { + return domain.Pricing.RegistrationCost + } + return fmt.Sprintf("%s %s/year", domain.Pricing.Currency, domain.Pricing.RegistrationCost) +} + +func waitForWorkflow(client *cloudflareClient, workflow *cloudflareRegistrarWorkflow, attempts int, delay time.Duration) (*cloudflareRegistrarWorkflow, error) { + current := workflow + for range attempts { + if current == nil || current.Completed || current.Links.Self == "" { + return current, nil + } + switch current.State { + case "action_required", "blocked", "failed", "succeeded": + return current, nil + } + time.Sleep(delay) + next, err := client.GetWorkflowStatus(current.Links.Self) + if err != nil { + return current, err + } + current = next + } + return current, nil +} + +func waitForZone(client *cloudflareClient, hostname string, attempts int, delay time.Duration) (*cloudflareZone, error) { + var lastErr error + for range attempts { + zone, err := client.ResolveZoneForHostname(hostname) + if err == nil { + return zone, nil + } + lastErr = err + time.Sleep(delay) + } + if lastErr == nil { + lastErr = errors.New("zone not found") + } + return nil, lastErr +} diff --git a/internal/tunnel/login.go b/internal/tunnel/login.go index f0ba85fc..3691905a 100644 --- a/internal/tunnel/login.go +++ b/internal/tunnel/login.go @@ -103,6 +103,15 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { if err := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, hostname, tunnelID, cert, cred); err != nil { return err } + if err := deleteRemoteManagedK8sResources(cfg, u, kubeconfigPath); err != nil { + return err + } + if err := deleteRemoteTunnelToken(cfg); err != nil { + return err + } + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementLocal); err != nil { + return err + } // Re-render the chart so it flips from quick tunnel to locally-managed. if err := helmUpgradeCloudflared(cfg, u, kubeconfigPath); err != nil { @@ -114,10 +123,12 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { st = &tunnelState{} } - st.Mode = "dns" + st.ExposureMode = tunnelExposurePersistent + st.ManagementMode = tunnelManagementLocal st.Hostname = hostname + st.AccountID = "" + st.ZoneID = "" st.TunnelID = tunnelID - st.TunnelName = tunnelName if err := saveTunnelState(cfg, st); err != nil { return fmt.Errorf("tunnel created, but failed to save local state: %w", err) diff --git a/internal/tunnel/provision.go b/internal/tunnel/provision.go index 241e7d5e..dafb99b9 100644 --- a/internal/tunnel/provision.go +++ b/internal/tunnel/provision.go @@ -1,7 +1,6 @@ package tunnel import ( - "bytes" "errors" "fmt" "os" @@ -21,26 +20,19 @@ type ProvisionOptions struct { APIToken string } -// Provision provisions a persistent Cloudflare Tunnel routed via a proxied DNS record. -// -// Based on Cloudflare's "Create a tunnel (API)" guide: -// - POST /accounts/$ACCOUNT_ID/cfd_tunnel -// - PUT /accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations -// - POST /zones/$ZONE_ID/dns_records (proxied CNAME to .cfargotunnel.com) +type resolvedProvisionTarget struct { + Hostname string + AccountID string + ZoneID string + ZoneName string +} + +// Provision provisions a remotely managed persistent Cloudflare Tunnel routed via a proxied DNS record. func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { hostname := normalizeHostname(opts.Hostname) if hostname == "" { return errors.New("--hostname is required (e.g. stack.example.com)") } - - if opts.AccountID == "" { - return errors.New("--account-id is required (or set CLOUDFLARE_ACCOUNT_ID)") - } - - if opts.ZoneID == "" { - return errors.New("--zone-id is required (or set CLOUDFLARE_ZONE_ID)") - } - if opts.APIToken == "" { return errors.New("--api-token is required (or set CLOUDFLARE_API_TOKEN)") } @@ -56,31 +48,36 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { return errors.New("stack not initialized, run 'obol stack init' first") } - tunnelName := "obol-stack-" + stackID - client := newCloudflareClient(opts.APIToken) + target, err := resolveProvisionTarget(client, ProvisionOptions{ + Hostname: hostname, + AccountID: opts.AccountID, + ZoneID: opts.ZoneID, + APIToken: opts.APIToken, + }) + if err != nil { + return err + } - // Try to reuse existing local state to keep the same tunnel ID. + tunnelName := "obol-stack-" + stackID st, _ := loadTunnelState(cfg) - if st != nil && st.AccountID == opts.AccountID && st.ZoneID == opts.ZoneID && st.TunnelID != "" && st.TunnelName != "" { + if st != nil && st.AccountID == target.AccountID && st.TunnelID != "" && st.TunnelName != "" { tunnelName = st.TunnelName } u.Info("Provisioning Cloudflare Tunnel (API)...") - u.Detail("Hostname", hostname) + u.Detail("Hostname", target.Hostname) + u.Detail("Account", target.AccountID) + u.Detail("Zone", fmt.Sprintf("%s (%s)", target.ZoneName, target.ZoneID)) u.Detail("Tunnel", tunnelName) tunnelID := "" tunnelToken := "" - - if st != nil && st.AccountID == opts.AccountID && st.TunnelID != "" { + if st != nil && st.AccountID == target.AccountID && st.TunnelID != "" { tunnelID = st.TunnelID - - tok, err := client.GetTunnelToken(opts.AccountID, tunnelID) - if err != nil { - // If the tunnel no longer exists, create a new one. - u.Warnf("Existing tunnel token fetch failed (%v); creating a new tunnel...", err) - + tok, tokenErr := client.GetTunnelToken(target.AccountID, tunnelID) + if tokenErr != nil { + u.Warnf("Existing tunnel token fetch failed (%v); creating a new tunnel...", tokenErr) tunnelID = "" } else { tunnelToken = tok @@ -88,28 +85,34 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { } if tunnelID == "" { - t, err := client.CreateTunnel(opts.AccountID, tunnelName) - if err != nil { - return err + t, createErr := client.CreateTunnel(target.AccountID, tunnelName) + if createErr != nil { + return createErr } - tunnelID = t.ID tunnelToken = t.Token } - if err := client.UpdateTunnelConfiguration(opts.AccountID, tunnelID, hostname, "http://traefik.traefik.svc.cluster.local:80"); err != nil { + if err := client.UpdateTunnelConfiguration(target.AccountID, tunnelID, target.Hostname, "http://traefik.traefik.svc.cluster.local:80"); err != nil { return err } - - if err := client.UpsertTunnelDNSRecord(opts.ZoneID, hostname, tunnelID+".cfargotunnel.com"); err != nil { + if err := client.UpsertTunnelDNSRecord(target.ZoneID, target.Hostname, tunnelID+".cfargotunnel.com"); err != nil { return err } - + if err := saveRemoteTunnelToken(cfg, tunnelToken); err != nil { + return fmt.Errorf("save tunnel token locally: %w", err) + } if err := applyTunnelTokenSecret(cfg, u, kubeconfigPath, tunnelToken); err != nil { return err } + if err := deleteLocalManagedK8sResources(cfg, u, kubeconfigPath); err != nil { + return err + } + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementRemote); err != nil { + return err + } - // Ensure cloudflared switches to remotely-managed mode immediately (chart defaults to mode:auto). + // Ensure cloudflared switches to remotely-managed mode immediately. if err := helmUpgradeCloudflared(cfg, u, kubeconfigPath); err != nil { return err } @@ -117,38 +120,75 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { if st == nil { st = &tunnelState{} } - - st.Mode = "dns" - st.Hostname = hostname - st.AccountID = opts.AccountID - st.ZoneID = opts.ZoneID + st.ExposureMode = tunnelExposurePersistent + st.ManagementMode = tunnelManagementRemote + st.Hostname = target.Hostname + st.AccountID = target.AccountID + st.ZoneID = target.ZoneID st.TunnelID = tunnelID st.TunnelName = tunnelName - if err := saveTunnelState(cfg, st); err != nil { return fmt.Errorf("tunnel provisioned, but failed to save local state: %w", err) } - tunnelURL := "https://" + hostname - - // Inject AGENT_BASE_URL into obol-agent overlay if deployed. + tunnelURL := "https://" + target.Hostname if err := SyncAgentBaseURL(cfg, tunnelURL); err != nil { u.Warnf("could not sync AGENT_BASE_URL to obol-agent: %v", err) } - - // Write tunnel URL to ConfigMap so the frontend can read it. if err := SyncTunnelConfigMap(cfg, tunnelURL); err != nil { u.Warnf("could not sync tunnel URL to frontend ConfigMap: %v", err) } u.Blank() u.Success("Tunnel provisioned") - u.Printf("Persistent URL: https://%s", hostname) + u.Printf("Persistent URL: %s", tunnelURL) u.Print("Tip: run 'obol tunnel status' to verify the connector is active.") return nil } +func resolveProvisionTarget(client *cloudflareClient, opts ProvisionOptions) (*resolvedProvisionTarget, error) { + hostname := normalizeHostname(opts.Hostname) + if hostname == "" { + return nil, errors.New("hostname is required") + } + + zoneName, err := extractZoneName(hostname) + if err != nil { + return nil, err + } + + accountID := strings.TrimSpace(opts.AccountID) + zoneID := strings.TrimSpace(opts.ZoneID) + + if zoneID == "" { + zone, zoneErr := client.ResolveZoneForHostname(hostname) + if zoneErr != nil { + return nil, fmt.Errorf("could not resolve a Cloudflare zone for %s: %w. Either add the zone to Cloudflare first or use 'obol tunnel setup --register-domain'", hostname, zoneErr) + } + zoneID = zone.ID + zoneName = zone.Name + if accountID == "" { + accountID = zone.Account.ID + } + } + + if accountID == "" { + resolvedAccountID, resolveErr := client.ResolveAccountID(accountID) + if resolveErr != nil { + return nil, resolveErr + } + accountID = resolvedAccountID + } + + return &resolvedProvisionTarget{ + Hostname: hostname, + AccountID: accountID, + ZoneID: zoneID, + ZoneName: zoneName, + }, nil +} + func normalizeHostname(s string) string { s = strings.TrimSpace(s) s = strings.TrimSuffix(s, "/") @@ -159,11 +199,9 @@ func normalizeHostname(s string) string { if idx := strings.IndexByte(s, '/'); idx >= 0 { s = s[:idx] } - if idx := strings.IndexByte(s, '?'); idx >= 0 { s = s[:idx] } - if idx := strings.IndexByte(s, '#'); idx >= 0 { s = s[:idx] } @@ -172,32 +210,17 @@ func normalizeHostname(s string) string { } func applyTunnelTokenSecret(cfg *config.Config, u *ui.UI, kubeconfigPath, token string) error { - kubectlPath := filepath.Join(cfg.BinDir, "kubectl") - - createCmd := exec.Command(kubectlPath, - "--kubeconfig", kubeconfigPath, - "-n", tunnelNamespace, - "create", "secret", "generic", tunnelTokenSecretName, - fmt.Sprintf("--from-literal=%s=%s", tunnelTokenSecretKey, token), - "--dry-run=client", - "-o", "yaml", - ) - - out, err := createCmd.Output() - if err != nil { - return fmt.Errorf("failed to create secret manifest: %w", err) - } - - applyCmd := exec.Command(kubectlPath, - "--kubeconfig", kubeconfigPath, - "apply", "-f", "-", - ) - - applyCmd.Stdin = bytes.NewReader(out) - if err := u.Exec(ui.ExecConfig{ - Name: "Applying tunnel token secret", - Cmd: applyCmd, - }); err != nil { + manifest := fmt.Sprintf(`apiVersion: v1 +kind: Secret +metadata: + name: %s + namespace: %s +type: Opaque +stringData: + %s: %s +`, tunnelTokenSecretName, tunnelNamespace, tunnelTokenSecretKey, token) + + if err := kubectlApply(cfg, u, kubeconfigPath, []byte(manifest)); err != nil { return fmt.Errorf("failed to apply tunnel token secret: %w", err) } @@ -211,12 +234,10 @@ func helmUpgradeCloudflared(cfg *config.Config, u *ui.UI, kubeconfigPath string) if _, err := os.Stat(helmPath); os.IsNotExist(err) { return fmt.Errorf("helm not found at %s", helmPath) } - if _, err := os.Stat(filepath.Join(defaultsDir, "cloudflared", "Chart.yaml")); os.IsNotExist(err) { return fmt.Errorf("cloudflared chart not found in %s (re-run 'obol stack init --force' to refresh defaults)", defaultsDir) } - // Run from the defaults dir so "./cloudflared" resolves correctly. cmd := exec.Command(helmPath, "--kubeconfig", kubeconfigPath, "upgrade", diff --git a/internal/tunnel/resources.go b/internal/tunnel/resources.go new file mode 100644 index 00000000..e379c607 --- /dev/null +++ b/internal/tunnel/resources.go @@ -0,0 +1,54 @@ +package tunnel + +import ( + "fmt" + "os/exec" + "path/filepath" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +func applyManagementModeConfigMap(cfg *config.Config, u *ui.UI, kubeconfigPath, mode string) error { + manifest := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: %s + namespace: %s +data: + %s: %s +`, managementConfigMapName, tunnelNamespace, managementConfigModeKey, mode) + + return kubectlApply(cfg, u, kubeconfigPath, []byte(manifest)) +} + +func deleteRemoteManagedK8sResources(cfg *config.Config, u *ui.UI, kubeconfigPath string) error { + return kubectlDelete(cfg, u, kubeconfigPath, "secret", tunnelTokenSecretName) +} + +func deleteLocalManagedK8sResources(cfg *config.Config, u *ui.UI, kubeconfigPath string) error { + if err := kubectlDelete(cfg, u, kubeconfigPath, "secret", localManagedSecretName); err != nil { + return err + } + + return kubectlDelete(cfg, u, kubeconfigPath, "configmap", localManagedConfigMapName) +} + +func kubectlDelete(cfg *config.Config, u *ui.UI, kubeconfigPath, kind, name string) error { + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + cmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "delete", kind, name, + "-n", tunnelNamespace, + "--ignore-not-found", + ) + + if err := u.Exec(ui.ExecConfig{ + Name: fmt.Sprintf("Deleting %s/%s", kind, name), + Cmd: cmd, + }); err != nil { + return fmt.Errorf("kubectl delete %s/%s failed: %w", kind, name, err) + } + + return nil +} diff --git a/internal/tunnel/restore.go b/internal/tunnel/restore.go new file mode 100644 index 00000000..444a5ff7 --- /dev/null +++ b/internal/tunnel/restore.go @@ -0,0 +1,106 @@ +package tunnel + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +// RestorePersistentResources rehydrates the in-cluster cloudflared resources from +// local state so persistent tunnels survive stack recreation. +func RestorePersistentResources(cfg *config.Config, u *ui.UI) error { + st, err := loadTunnelState(cfg) + if err != nil { + return fmt.Errorf("load tunnel state: %w", err) + } + + if st == nil || !st.IsPersistent() || st.Hostname == "" { + return nil + } + + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return errors.New("stack not running, use 'obol stack up' first") + } + + switch st.Management() { + case tunnelManagementLocal: + return restoreLocalManagedResources(cfg, u, kubeconfigPath, st) + case tunnelManagementRemote: + return restoreRemoteManagedResources(cfg, u, kubeconfigPath, st) + default: + return nil + } +} + +func restoreLocalManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath string, st *tunnelState) error { + if st.TunnelID == "" { + return errors.New("persistent local tunnel is missing tunnel_id; rerun 'obol tunnel login --hostname '") + } + + cloudflaredDir := defaultCloudflaredDir() + certPath := filepath.Join(cloudflaredDir, "cert.pem") + credPath := filepath.Join(cloudflaredDir, st.TunnelID+".json") + + cert, err := os.ReadFile(certPath) + if err != nil { + return fmt.Errorf("read local cloudflared cert %s: %w", certPath, err) + } + + cred, err := os.ReadFile(credPath) + if err != nil { + return fmt.Errorf("read local cloudflared credentials %s: %w", credPath, err) + } + + if err := applyLocalManagedK8sResources(cfg, u, kubeconfigPath, st.Hostname, st.TunnelID, cert, cred); err != nil { + return err + } + if err := deleteRemoteManagedK8sResources(cfg, u, kubeconfigPath); err != nil { + return err + } + if err := deleteRemoteTunnelToken(cfg); err != nil { + return err + } + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementLocal); err != nil { + return err + } + + return helmUpgradeCloudflared(cfg, u, kubeconfigPath) +} + +func restoreRemoteManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath string, st *tunnelState) error { + token, err := loadRemoteTunnelToken(cfg) + if err != nil { + return fmt.Errorf("load stored tunnel token: %w", err) + } + + if token == "" && st.AccountID != "" && st.TunnelID != "" { + if apiToken := os.Getenv("CLOUDFLARE_API_TOKEN"); apiToken != "" { + client := newCloudflareClient(apiToken) + token, err = client.GetTunnelToken(st.AccountID, st.TunnelID) + if err == nil && token != "" { + _ = saveRemoteTunnelToken(cfg, token) + } + } + } + + if token == "" { + return errors.New("persistent remote tunnel token is unavailable locally; rerun 'obol tunnel provision --hostname ' or set CLOUDFLARE_API_TOKEN and retry") + } + + if err := applyTunnelTokenSecret(cfg, u, kubeconfigPath, token); err != nil { + return err + } + if err := deleteLocalManagedK8sResources(cfg, u, kubeconfigPath); err != nil { + return err + } + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementRemote); err != nil { + return err + } + + return helmUpgradeCloudflared(cfg, u, kubeconfigPath) +} diff --git a/internal/tunnel/state.go b/internal/tunnel/state.go index b0355e64..94257cc3 100644 --- a/internal/tunnel/state.go +++ b/internal/tunnel/state.go @@ -2,25 +2,105 @@ package tunnel import ( "encoding/json" + "errors" "os" "path/filepath" + "strings" "time" "github.com/ObolNetwork/obol-stack/internal/config" ) +const ( + tunnelExposureQuick = "quick" + tunnelExposurePersistent = "persistent" + + tunnelManagementQuick = "quick" + tunnelManagementLocal = "local" + tunnelManagementRemote = "remote" + + legacyTunnelModeQuick = "quick" + legacyTunnelModeDNS = "dns" + + managementConfigMapName = "cloudflared-management" + managementConfigModeKey = "management_mode" + + persistentReplicaCount = 2 + quickReplicaCount = 1 +) + type tunnelState struct { - Mode string `json:"mode"` // "quick" or "dns" - Hostname string `json:"hostname"` - AccountID string `json:"account_id,omitempty"` - ZoneID string `json:"zone_id,omitempty"` - TunnelID string `json:"tunnel_id,omitempty"` - TunnelName string `json:"tunnel_name,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + // Mode is kept for backward compatibility with older on-disk state files. + Mode string `json:"mode,omitempty"` + + ExposureMode string `json:"exposure_mode,omitempty"` + ManagementMode string `json:"management_mode,omitempty"` + Hostname string `json:"hostname,omitempty"` + AccountID string `json:"account_id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + TunnelID string `json:"tunnel_id,omitempty"` + TunnelName string `json:"tunnel_name,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func tunnelStateDir(cfg *config.Config) string { + return filepath.Join(cfg.ConfigDir, "tunnel") } func tunnelStatePath(cfg *config.Config) string { - return filepath.Join(cfg.ConfigDir, "tunnel", "cloudflared.json") + return filepath.Join(tunnelStateDir(cfg), "cloudflared.json") +} + +func remoteTunnelTokenPath(cfg *config.Config) string { + return filepath.Join(tunnelStateDir(cfg), "cloudflared-token") +} + +func normalizeTunnelState(st *tunnelState) *tunnelState { + if st == nil { + return nil + } + + clone := *st + + if clone.ExposureMode == "" { + switch clone.Mode { + case legacyTunnelModeDNS: + clone.ExposureMode = tunnelExposurePersistent + case legacyTunnelModeQuick: + clone.ExposureMode = tunnelExposureQuick + default: + if clone.Hostname != "" { + clone.ExposureMode = tunnelExposurePersistent + } else { + clone.ExposureMode = tunnelExposureQuick + } + } + } + + if clone.ManagementMode == "" { + switch clone.ExposureMode { + case tunnelExposurePersistent: + if clone.AccountID != "" || clone.ZoneID != "" { + clone.ManagementMode = tunnelManagementRemote + } else { + clone.ManagementMode = tunnelManagementLocal + } + default: + clone.ManagementMode = tunnelManagementQuick + } + } + + clone.Mode = legacyTunnelMode(clone.ExposureMode) + + return &clone +} + +func legacyTunnelMode(exposureMode string) string { + if exposureMode == tunnelExposurePersistent { + return legacyTunnelModeDNS + } + + return legacyTunnelModeQuick } func loadTunnelState(cfg *config.Config) (*tunnelState, error) { @@ -38,17 +118,21 @@ func loadTunnelState(cfg *config.Config) (*tunnelState, error) { return nil, err } - return &st, nil + return normalizeTunnelState(&st), nil } func saveTunnelState(cfg *config.Config, st *tunnelState) error { - if err := os.MkdirAll(filepath.Dir(tunnelStatePath(cfg)), 0o755); err != nil { - return err + if st == nil { + return errors.New("tunnel state is nil") } - st.UpdatedAt = time.Now().UTC() + normalized := normalizeTunnelState(st) + if err := os.MkdirAll(filepath.Dir(tunnelStatePath(cfg)), 0o700); err != nil { + return err + } - data, err := json.MarshalIndent(st, "", " ") + normalized.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(normalized, "", " ") if err != nil { return err } @@ -57,6 +141,40 @@ func saveTunnelState(cfg *config.Config, st *tunnelState) error { return os.WriteFile(tunnelStatePath(cfg), data, 0o600) } +func saveRemoteTunnelToken(cfg *config.Config, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return errors.New("tunnel token is empty") + } + + if err := os.MkdirAll(tunnelStateDir(cfg), 0o700); err != nil { + return err + } + + return os.WriteFile(remoteTunnelTokenPath(cfg), []byte(token), 0o600) +} + +func loadRemoteTunnelToken(cfg *config.Config) (string, error) { + data, err := os.ReadFile(remoteTunnelTokenPath(cfg)) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + + return "", err + } + + return strings.TrimSpace(string(data)), nil +} + +func deleteRemoteTunnelToken(cfg *config.Config) error { + if err := os.Remove(remoteTunnelTokenPath(cfg)); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + // TunnelState is an exported alias so other packages (agent, openclaw) // can read tunnel state without reaching into unexported types. type TunnelState = tunnelState @@ -67,10 +185,49 @@ func LoadTunnelState(cfg *config.Config) (*TunnelState, error) { return loadTunnelState(cfg) } +func (st *tunnelState) IsPersistent() bool { + normalized := normalizeTunnelState(st) + return normalized != nil && normalized.ExposureMode == tunnelExposurePersistent +} + +func (st *tunnelState) Management() string { + normalized := normalizeTunnelState(st) + if normalized == nil { + return tunnelManagementQuick + } + + return normalized.ManagementMode +} + +func (st *tunnelState) DisplayMode() string { + normalized := normalizeTunnelState(st) + if normalized == nil || normalized.ExposureMode != tunnelExposurePersistent { + return tunnelExposureQuick + } + + switch normalized.ManagementMode { + case tunnelManagementRemote: + return "persistent-remote" + case tunnelManagementLocal: + return "persistent-local" + default: + return tunnelExposurePersistent + } +} + +func desiredRuntimeReplicas(st *tunnelState) int { + if st != nil && st.IsPersistent() { + return persistentReplicaCount + } + + return quickReplicaCount +} + func tunnelModeAndURL(st *tunnelState) (mode, url string) { - if st != nil && st.Hostname != "" { - return "dns", "https://" + st.Hostname + normalized := normalizeTunnelState(st) + if normalized != nil && normalized.IsPersistent() && normalized.Hostname != "" { + return tunnelExposurePersistent, "https://" + normalized.Hostname } - return "quick", "" + return tunnelExposureQuick, "" } diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index ecf17d0b..20aac8d8 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -28,101 +28,183 @@ const ( tunnelTokenSecretKey = "TUNNEL_TOKEN" ) -// Status displays the current tunnel status and URL. // tunnelStatusResult is the JSON-serialisable result for `tunnel status`. type tunnelStatusResult struct { - Mode string `json:"mode"` - Status string `json:"status"` - URL string `json:"url"` - LastUpdated string `json:"last_updated"` + Mode string `json:"mode"` + ExposureMode string `json:"exposure_mode,omitempty"` + ManagementMode string `json:"management_mode,omitempty"` + Status string `json:"status"` + URL string `json:"url"` + Hostname string `json:"hostname,omitempty"` + DesiredReplicas int `json:"desired_replicas,omitempty"` + ReadyReplicas int `json:"ready_replicas,omitempty"` + AvailableReplicas int `json:"available_replicas,omitempty"` + PodStatus string `json:"pod_status,omitempty"` + ConnectorStatus string `json:"connector_status,omitempty"` + ActiveConnections int `json:"active_connections,omitempty"` + LastUpdated string `json:"last_updated"` +} + +type deploymentReplicaStatus struct { + Spec struct { + Replicas *int32 `json:"replicas,omitempty"` + } `json:"spec"` + Status struct { + Replicas int32 `json:"replicas,omitempty"` + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + AvailableReplicas int32 `json:"availableReplicas,omitempty"` + } `json:"status"` +} + +type podStatusList struct { + Items []struct { + Status struct { + Phase string `json:"phase"` + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + } `json:"status"` + } `json:"items"` +} + +type tunnelRuntimeHealth struct { + DesiredReplicas int + ReadyReplicas int + AvailableReplicas int + PodStatus string } // Status displays the current tunnel status and URL. func Status(cfg *config.Config, u *ui.UI) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") - - // Check if kubeconfig exists. if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { return errors.New("stack not running, use 'obol stack up' first") } + now := time.Now() st, _ := loadTunnelState(cfg) + mode, url := tunnelModeAndURL(st) + result := tunnelStatusResult{ + Mode: mode, + ExposureMode: tunnelExposureQuick, + ManagementMode: tunnelManagementQuick, + URL: url, + DesiredReplicas: desiredRuntimeReplicas(st), + LastUpdated: now.Format(time.RFC3339), + } + if st != nil { + result.ExposureMode = st.ExposureMode + result.ManagementMode = st.Management() + result.Hostname = st.Hostname + } + if result.ExposureMode == "" { + result.ExposureMode = tunnelExposureQuick + } + if result.ManagementMode == "" { + result.ManagementMode = tunnelManagementQuick + } - // Check pod status first. - podStatus, err := getPodStatus(kubectlPath, kubeconfigPath) + runtime, err := getTunnelRuntimeHealth(kubectlPath, kubeconfigPath) if err != nil { - mode, url := tunnelModeAndURL(st) - if mode == "quick" { - // Quick tunnel is dormant — activates on first `obol sell`. - if u.IsJSON() { - return u.JSON(tunnelStatusResult{Mode: "quick", Status: "dormant", URL: "(activates on 'obol sell')", LastUpdated: time.Now().Format(time.RFC3339)}) - } - printStatusBox(u, "quick", "dormant", "(activates on 'obol sell')", time.Now()) - u.Blank() - u.Print("The tunnel will start automatically when you sell a service.") - u.Print(" Start manually: obol tunnel restart") - u.Print(" Persistent URL: obol tunnel login --hostname stack.example.com") - - return nil + if mode == tunnelExposureQuick { + result.Status = "dormant" + result.URL = "(activates on 'obol sell')" + } else { + result.Status = "not running" } if u.IsJSON() { - return u.JSON(tunnelStatusResult{Mode: mode, Status: "not running", URL: url, LastUpdated: time.Now().Format(time.RFC3339)}) + return u.JSON(result) } - printStatusBox(u, mode, "not running", url, time.Now()) + printDetailedStatusBox(u, result, now) u.Blank() - u.Print("Troubleshooting:") - u.Print(" - Start the stack: obol stack up") - + if mode == tunnelExposureQuick { + u.Print("The tunnel will start automatically when you sell a service.") + u.Print(" Start manually: obol tunnel restart") + u.Print(" Persistent URL: obol tunnel setup --hostname stack.example.com") + } else { + u.Print("Troubleshooting:") + u.Print(" - Start the stack: obol stack up") + u.Print(" - Restore persistent tunnel resources: obol tunnel restart") + } return nil } - statusLabel := podStatus - if podStatus == "running" { - statusLabel = "active" - } + result.DesiredReplicas = runtime.DesiredReplicas + result.ReadyReplicas = runtime.ReadyReplicas + result.AvailableReplicas = runtime.AvailableReplicas + result.PodStatus = runtime.PodStatus - mode, url := tunnelModeAndURL(st) - if mode == "quick" { - // Quick tunnels only: try to get URL from logs. - tunnelURL, err := GetTunnelURL(cfg) - if err != nil { - if u.IsJSON() { - return u.JSON(tunnelStatusResult{Mode: mode, Status: podStatus, URL: "(not available)", LastUpdated: time.Now().Format(time.RFC3339)}) + if mode == tunnelExposureQuick { + tunnelURL, quickErr := GetTunnelURL(cfg) + if quickErr == nil { + result.URL = tunnelURL + } else { + result.URL = "(not available)" + } + } else if result.URL == "" && result.Hostname != "" { + result.URL = "https://" + result.Hostname + } + + if st != nil && st.Management() == tunnelManagementRemote && st.AccountID != "" && st.TunnelID != "" { + result.ConnectorStatus = "unknown" + if apiToken := strings.TrimSpace(os.Getenv("CLOUDFLARE_API_TOKEN")); apiToken != "" { + if tunnelInfo, tunnelErr := newCloudflareClient(apiToken).GetTunnel(st.AccountID, st.TunnelID); tunnelErr == nil { + result.ActiveConnections = len(tunnelInfo.Connections) + if result.ActiveConnections > 0 { + result.ConnectorStatus = "connected" + } else { + result.ConnectorStatus = "waiting_for_connections" + } } - printStatusBox(u, mode, podStatus, "(not available)", time.Now()) - u.Blank() - u.Print("Troubleshooting:") - u.Print(" - Check logs: obol tunnel logs") - u.Print(" - Restart tunnel: obol tunnel restart") - - return nil //nolint:nilerr // URL unavailable is a display-only issue; show troubleshooting hints instead } - - url = tunnelURL + } else if st != nil && st.IsPersistent() { + result.ConnectorStatus = "managed-locally" } + result.Status = summarizeTunnelStatus(result) if u.IsJSON() { - return u.JSON(tunnelStatusResult{Mode: mode, Status: statusLabel, URL: url, LastUpdated: time.Now().Format(time.RFC3339)}) + return u.JSON(result) } - printStatusBox(u, mode, statusLabel, url, time.Now()) - u.Printf("Test with: curl %s/", url) - - // Auto-inject tunnel URL into obol-agent so registration JSON uses it. - if url != "" && url != "(not available)" { - if err := InjectBaseURL(cfg, url); err == nil { - u.Dim("Agent base URL updated to " + url) - } - // Write tunnel URL to ConfigMap so the frontend can read it. - if err := SyncTunnelConfigMap(cfg, url); err != nil { - u.Dim("Could not sync tunnel URL to frontend ConfigMap: " + err.Error()) - } + printDetailedStatusBox(u, result, now) + if result.URL != "" && result.URL != "(not available)" && result.URL != "(activates on 'obol sell')" { + u.Printf("Test with: curl %s/", result.URL) + syncTunnelDependents(cfg, u, result.URL) + } + if result.Status != "active" { + u.Blank() + u.Print("Troubleshooting:") + u.Print(" - Check logs: obol tunnel logs") + u.Print(" - Restart tunnel: obol tunnel restart") } return nil } +func summarizeTunnelStatus(result tunnelStatusResult) string { + if result.DesiredReplicas == 0 { + if result.Mode == tunnelExposureQuick { + return "dormant" + } + return "stopped" + } + if result.ReadyReplicas == 0 { + return "starting" + } + if result.ReadyReplicas < result.DesiredReplicas || result.AvailableReplicas < result.DesiredReplicas { + return "degraded" + } + if result.Mode == tunnelExposureQuick && (result.URL == "" || result.URL == "(not available)") { + return "starting" + } + if result.ManagementMode == tunnelManagementRemote && result.ConnectorStatus == "waiting_for_connections" { + return "degraded" + } + return "active" +} + // InjectBaseURL sets AGENT_BASE_URL on the default Hermes deployment so that // monetize.py uses the tunnel URL in registration JSON. func InjectBaseURL(cfg *config.Config, tunnelURL string) error { @@ -192,37 +274,38 @@ func waitReadyTimeout() time.Duration { return defaultWaitReadyTimeout } -// WaitReady scales the cloudflared deployment up if needed, then polls until -// BOTH the deployment rollout is complete AND a public *.trycloudflare.com URL -// has been captured from the pod logs. The budget is bounded by -// waitReadyTimeout (defaultWaitReadyTimeout / FLOW_TUNNEL_TIMEOUT). On timeout -// it returns an error that names both subjects (deployment + URL) so callers -// can distinguish a half-baked tunnel from a missing one. +// WaitReady scales the cloudflared deployment to the desired replica count, +// waits for the deployment rollout, and returns the active public tunnel URL. +// +// For quick tunnels this polls pod logs for a public *.trycloudflare.com URL. +// For persistent tunnels this returns the configured hostname after rollout. // -// Side effects on success: injects AGENT_BASE_URL into the agent deployment and -// writes the tunnel URL to the obol-frontend ConfigMap consumed by the -// serviceoffer-controller for ERC-8004 registration metadata. +// Side effects on success: injects AGENT_BASE_URL into the agent deployment, +// writes the tunnel URL to the obol-frontend ConfigMap, and refreshes the +// storefront landing page for the public tunnel hostname. func WaitReady(cfg *config.Config, u *ui.UI) (string, error) { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") - if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { return "", errors.New("stack not running") } - // Fast path: tunnel pod is already running and exposing a URL. - if podStatus, err := getPodStatus(kubectlPath, kubeconfigPath); err == nil && podStatus == "running" { - if url, err := GetTunnelURL(cfg); err == nil { - return url, nil + st, _ := loadTunnelState(cfg) + desiredReplicas := desiredRuntimeReplicas(st) + if runtime, err := getTunnelRuntimeHealth(kubectlPath, kubeconfigPath); err == nil && runtime.ReadyReplicas >= desiredReplicas && runtime.AvailableReplicas >= desiredReplicas { + tunnelURL, err := currentTunnelURL(cfg, st) + if err != nil { + return "", err } + syncTunnelDependents(cfg, u, tunnelURL) + return tunnelURL, nil } - // Scale to 1 replica (idempotent). scaleCmd := exec.Command(kubectlPath, "--kubeconfig", kubeconfigPath, "scale", "deployment/cloudflared", "-n", tunnelNamespace, - "--replicas=1", + fmt.Sprintf("--replicas=%d", desiredReplicas), ) if err := scaleCmd.Run(); err != nil { return "", fmt.Errorf("failed to scale cloudflared: %w", err) @@ -247,35 +330,81 @@ func WaitReady(cfg *config.Config, u *ui.UI) (string, error) { Cmd: waitCmd, }) - // Stage 2: poll the tunnel pod logs for the trycloudflare URL until the - // remaining budget runs out. Even when the rollout above failed, we still - // give the URL probe a brief grace window in case the pod is up but the - // rollout watcher returned spuriously. var tunnelURL string - for time.Now().Before(deadline) { - if url, err := GetTunnelURL(cfg); err == nil && strings.HasPrefix(url, "https://") { - tunnelURL = url - break + if st != nil && st.IsPersistent() { + runtime, runtimeErr := getTunnelRuntimeHealth(kubectlPath, kubeconfigPath) + if runtimeErr != nil { + if rolloutErr != nil { + return "", fmt.Errorf("cloudflared rollout failed and runtime health could not be rechecked: %w", rolloutErr) + } + return "", runtimeErr } - time.Sleep(5 * time.Second) - } - - if tunnelURL == "" { - if rolloutErr != nil { - return "", fmt.Errorf("cloudflared not ready within %s: deployment rollout failed (%w) and no public *.trycloudflare.com URL captured", totalBudget, rolloutErr) + if runtime.ReadyReplicas < desiredReplicas || runtime.AvailableReplicas < desiredReplicas { + if rolloutErr != nil { + return "", fmt.Errorf("cloudflared not ready within %s: deployment rollout failed (%w)", totalBudget, rolloutErr) + } + return "", fmt.Errorf("cloudflared not ready within %s: %d/%d replicas ready", totalBudget, runtime.ReadyReplicas, desiredReplicas) + } + var err error + tunnelURL, err = currentTunnelURL(cfg, st) + if err != nil { + return "", err + } + } else { + // Quick tunnels: poll pod logs for the public trycloudflare URL until the + // remaining budget runs out. Even when the rollout above failed, we still + // give the URL probe a brief grace window in case the pod is up but the + // rollout watcher returned spuriously. + for time.Now().Before(deadline) { + if url, err := currentTunnelURL(cfg, st); err == nil && strings.HasPrefix(url, "https://") { + tunnelURL = url + break + } + time.Sleep(5 * time.Second) + } + if tunnelURL == "" { + if rolloutErr != nil { + return "", fmt.Errorf("cloudflared not ready within %s: deployment rollout failed (%w) and no public *.trycloudflare.com URL captured", totalBudget, rolloutErr) + } + return "", fmt.Errorf("cloudflared not ready within %s: deployment is rolled out but no public *.trycloudflare.com URL captured from pod logs", totalBudget) } - return "", fmt.Errorf("cloudflared not ready within %s: deployment is rolled out but no public *.trycloudflare.com URL captured from pod logs", totalBudget) } - // URL captured: propagate to agent + frontend ConfigMap. Best-effort. + syncTunnelDependents(cfg, u, tunnelURL) + + return tunnelURL, nil +} + +func syncTunnelDependents(cfg *config.Config, u *ui.UI, tunnelURL string) { if err := InjectBaseURL(cfg, tunnelURL); err == nil { u.Dim("Agent base URL updated to " + tunnelURL) } if err := SyncTunnelConfigMap(cfg, tunnelURL); err != nil { u.Dim("Could not sync tunnel URL to frontend ConfigMap: " + err.Error()) } + if err := CreateStorefront(cfg, tunnelURL); err != nil { + u.Dim("Could not create storefront: " + err.Error()) + } +} - return tunnelURL, nil +func currentTunnelURL(cfg *config.Config, st *tunnelState) (string, error) { + mode, url := tunnelModeAndURL(st) + if mode != tunnelExposureQuick { + if url == "" { + return "", errors.New("persistent tunnel hostname is not configured") + } + return url, nil + } + + url, err := GetTunnelURL(cfg) + if err != nil { + return "", err + } + if !strings.HasPrefix(url, "https://") { + return "", errors.New("quick tunnel URL is not yet available") + } + + return url, nil } // EnsureRunning is the historical alias for WaitReady. New callers should @@ -296,8 +425,8 @@ func EnsureRunning(cfg *config.Config, u *ui.UI) (string, error) { // re-syncing the chart kills the running pod and rotates the URL. func IsQuickTunnelHealthy(cfg *config.Config) bool { st, _ := loadTunnelState(cfg) - if st != nil && st.Hostname != "" { - return false // persistent (DNS) tunnel — chart already keeps it alive + if st != nil && st.IsPersistent() { + return false // persistent tunnel — chart already keeps it alive } kubectlPath := filepath.Join(cfg.BinDir, "kubectl") @@ -306,11 +435,15 @@ func IsQuickTunnelHealthy(cfg *config.Config) bool { return false } - if status, err := getPodStatus(kubectlPath, kubeconfigPath); err != nil || status != "running" { + runtime, err := getTunnelRuntimeHealth(kubectlPath, kubeconfigPath) + if err != nil { + return false + } + if runtime.ReadyReplicas < quickReplicaCount || runtime.AvailableReplicas < quickReplicaCount || runtime.PodStatus == "" { return false } - url, err := GetTunnelURL(cfg) + url, err := currentTunnelURL(cfg, st) if err != nil || !strings.HasPrefix(url, "https://") { return false } @@ -331,7 +464,7 @@ func IsQuickTunnelHealthy(cfg *config.Config) bool { // none). In non-interactive sessions, Confirm returns its default (true), so // automation and CI flows print the warning but do not block. func ConfirmQuickTunnelLoss(cfg *config.Config, u *ui.UI, currentURL, action string) bool { - if st, _ := loadTunnelState(cfg); st != nil && st.Hostname != "" { + if st, _ := loadTunnelState(cfg); st != nil && st.IsPersistent() { return true } @@ -366,11 +499,18 @@ func Restart(cfg *config.Config, u *ui.UI) error { return errors.New("stack not running, use 'obol stack up' first") } - currentURL, _ := GetTunnelURL(cfg) - if !ConfirmQuickTunnelLoss(cfg, u, currentURL, "obol tunnel restart") { - u.Info("Aborted.") + st, _ := loadTunnelState(cfg) + if st != nil && st.IsPersistent() { + if err := RestorePersistentResources(cfg, u); err != nil { + return fmt.Errorf("restore persistent tunnel resources: %w", err) + } + } else { + currentURL, _ := GetTunnelURL(cfg) + if !ConfirmQuickTunnelLoss(cfg, u, currentURL, "obol tunnel restart") { + u.Info("Aborted.") - return nil + return nil + } } cmd := exec.Command(kubectlPath, @@ -402,20 +542,21 @@ func Restart(cfg *config.Config, u *ui.UI) error { } // Capture the new URL and update everything that needs the base URL. - // WaitReady also calls InjectBaseURL + SyncTunnelConfigMap. + // WaitReady also refreshes AGENT_BASE_URL, the frontend tunnel ConfigMap, + // and the public storefront. newURL, err := WaitReady(cfg, u) if err != nil { return fmt.Errorf("tunnel restarted but new URL not captured: %w", err) } - // Refresh the storefront HTTPRoute (hostname-pinned to the tunnel domain). - if err := CreateStorefront(cfg, newURL); err != nil { - u.Warnf("could not refresh storefront for new URL: %v", err) - } - u.Blank() - u.Successf("Tunnel restarted: %s", newURL) - u.Dim(" /skill.md, /api/services.json, and the storefront now reflect the new URL.") + if st != nil && st.IsPersistent() { + u.Successf("Persistent tunnel active: %s", newURL) + u.Dim(" Resources restored and connector restarted.") + } else { + u.Successf("Tunnel restarted: %s", newURL) + u.Dim(" /skill.md, /api/services.json, and the storefront now reflect the new URL.") + } return nil } @@ -450,24 +591,103 @@ func Logs(cfg *config.Config, follow bool) error { // getPodStatus returns the status of the cloudflared pod. func getPodStatus(kubectlPath, kubeconfigPath string) (string, error) { - cmd := exec.Command(kubectlPath, + runtime, err := getTunnelRuntimeHealth(kubectlPath, kubeconfigPath) + if err != nil { + return "", err + } + if runtime.PodStatus == "" { + return "", errors.New("no pods found") + } + return runtime.PodStatus, nil +} + +func getTunnelRuntimeHealth(kubectlPath, kubeconfigPath string) (*tunnelRuntimeHealth, error) { + depCmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "get", "deployment/cloudflared", + "-n", tunnelNamespace, + "-o", "json", + ) + depOut, err := depCmd.Output() + if err != nil { + return nil, err + } + + var deployment deploymentReplicaStatus + if err := json.Unmarshal(depOut, &deployment); err != nil { + return nil, fmt.Errorf("parse cloudflared deployment status: %w", err) + } + + podsCmd := exec.Command(kubectlPath, "--kubeconfig", kubeconfigPath, "get", "pods", "-n", tunnelNamespace, "-l", tunnelLabelSelector, - "-o", "jsonpath={.items[0].status.phase}", + "-o", "json", ) - - output, err := cmd.Output() + podsOut, err := podsCmd.Output() if err != nil { - return "", err + return nil, err } - status := strings.TrimSpace(string(output)) - if status == "" { - return "", errors.New("no pods found") + var pods podStatusList + if err := json.Unmarshal(podsOut, &pods); err != nil { + return nil, fmt.Errorf("parse cloudflared pod status: %w", err) + } + + desiredReplicas := int(deployment.Status.Replicas) + if deployment.Spec.Replicas != nil { + desiredReplicas = int(*deployment.Spec.Replicas) + } + + phases := make([]string, 0, len(pods.Items)) + for _, pod := range pods.Items { + phase := strings.ToLower(strings.TrimSpace(pod.Status.Phase)) + if phase == "" { + phase = "unknown" + } + phases = append(phases, phase) } + podStatus := "" + if len(phases) > 0 { + podStatus = fmt.Sprintf("%d pod(s): %s", len(phases), strings.Join(phases, ",")) + } + + return &tunnelRuntimeHealth{ + DesiredReplicas: desiredReplicas, + ReadyReplicas: int(deployment.Status.ReadyReplicas), + AvailableReplicas: int(deployment.Status.AvailableReplicas), + PodStatus: podStatus, + }, nil +} - return strings.ToLower(status), nil +func printDetailedStatusBox(u *ui.UI, result tunnelStatusResult, lastUpdated time.Time) { + u.Blank() + u.Bold("Cloudflare Tunnel Status") + u.Print(strings.Repeat("─", 50)) + u.Detail("Mode", result.Mode) + if result.ManagementMode != "" { + u.Detail("Management", result.ManagementMode) + } + u.Detail("Status", result.Status) + if result.Hostname != "" { + u.Detail("Hostname", result.Hostname) + } + u.Detail("URL", result.URL) + if result.DesiredReplicas > 0 || result.ReadyReplicas > 0 { + u.Detail("Replicas", fmt.Sprintf("%d ready / %d desired", result.ReadyReplicas, result.DesiredReplicas)) + } + if result.PodStatus != "" { + u.Detail("Pods", result.PodStatus) + } + if result.ConnectorStatus != "" { + connector := result.ConnectorStatus + if result.ActiveConnections > 0 { + connector = fmt.Sprintf("%s (%d active)", connector, result.ActiveConnections) + } + u.Detail("Connectors", connector) + } + u.Detail("Last Updated", lastUpdated.Format(time.RFC3339)) + u.Print(strings.Repeat("─", 50)) } // printStatusBox prints a formatted status box. diff --git a/internal/tunnel/tunnel_lifecycle_test.go b/internal/tunnel/tunnel_lifecycle_test.go index 7fa2581a..3d679699 100644 --- a/internal/tunnel/tunnel_lifecycle_test.go +++ b/internal/tunnel/tunnel_lifecycle_test.go @@ -28,10 +28,7 @@ func testConfig(t *testing.T) *config.Config { func TestTunnelState_RoundTrip(t *testing.T) { cfg := testConfig(t) - st := &tunnelState{ - Mode: "quick", - Hostname: "abc-def.trycloudflare.com", - } + st := &tunnelState{Mode: "quick"} if err := saveTunnelState(cfg, st); err != nil { t.Fatalf("save: %v", err) @@ -45,11 +42,15 @@ func TestTunnelState_RoundTrip(t *testing.T) { if got.Mode != "quick" { t.Errorf("mode = %q, want quick", got.Mode) } - - if got.Hostname != "abc-def.trycloudflare.com" { - t.Errorf("hostname = %q, want abc-def.trycloudflare.com", got.Hostname) + if got.ExposureMode != tunnelExposureQuick { + t.Errorf("exposure_mode = %q, want quick", got.ExposureMode) + } + if got.ManagementMode != tunnelManagementQuick { + t.Errorf("management_mode = %q, want quick", got.ManagementMode) + } + if got.Hostname != "" { + t.Errorf("hostname = %q, want empty", got.Hostname) } - if got.UpdatedAt.IsZero() { t.Error("UpdatedAt should be set by save") } @@ -79,12 +80,47 @@ func TestTunnelState_DNSMode(t *testing.T) { if got.Mode != "dns" { t.Errorf("mode = %q, want dns", got.Mode) } - + if got.ExposureMode != tunnelExposurePersistent { + t.Errorf("exposure_mode = %q, want persistent", got.ExposureMode) + } + if got.ManagementMode != tunnelManagementRemote { + t.Errorf("management_mode = %q, want remote", got.ManagementMode) + } if got.TunnelID != "tun-789" { t.Errorf("tunnel_id = %q, want tun-789", got.TunnelID) } } +func TestTunnelState_LegacyQuickHostnameStaysQuick(t *testing.T) { + cfg := testConfig(t) + + if err := os.MkdirAll(filepath.Dir(tunnelStatePath(cfg)), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + legacy := `{"mode":"quick","hostname":"annual-arc-abilities-lenses.trycloudflare.com"}` + if err := os.WriteFile(tunnelStatePath(cfg), []byte(legacy), 0o600); err != nil { + t.Fatalf("write legacy state: %v", err) + } + + got, err := loadTunnelState(cfg) + if err != nil { + t.Fatalf("load: %v", err) + } + + if got.Mode != legacyTunnelModeQuick { + t.Fatalf("mode = %q, want %q", got.Mode, legacyTunnelModeQuick) + } + if got.ExposureMode != tunnelExposureQuick { + t.Fatalf("exposure_mode = %q, want %q", got.ExposureMode, tunnelExposureQuick) + } + if got.ManagementMode != tunnelManagementQuick { + t.Fatalf("management_mode = %q, want %q", got.ManagementMode, tunnelManagementQuick) + } + if got.IsPersistent() { + t.Fatal("legacy quick tunnel with hostname should not be treated as persistent") + } +} + func TestTunnelState_NotExist(t *testing.T) { cfg := testConfig(t) @@ -101,7 +137,7 @@ func TestTunnelState_NotExist(t *testing.T) { func TestTunnelState_Overwrite(t *testing.T) { cfg := testConfig(t) - st1 := &tunnelState{Mode: "quick", Hostname: "old.trycloudflare.com"} + st1 := &tunnelState{Mode: "quick"} if err := saveTunnelState(cfg, st1); err != nil { t.Fatalf("save 1: %v", err) } @@ -124,7 +160,7 @@ func TestTunnelState_Overwrite(t *testing.T) { func TestTunnelState_FilePermissions(t *testing.T) { cfg := testConfig(t) - st := &tunnelState{Mode: "quick", Hostname: "test.trycloudflare.com"} + st := &tunnelState{Mode: "quick"} if err := saveTunnelState(cfg, st); err != nil { t.Fatalf("save: %v", err) } @@ -166,7 +202,7 @@ func TestTunnelModeAndURL(t *testing.T) { { name: "dns mode with hostname", st: &tunnelState{Mode: "dns", Hostname: "stack.example.com"}, - wantMode: "dns", + wantMode: "persistent", wantURL: "https://stack.example.com", }, } @@ -197,8 +233,8 @@ func shouldAutoStopTunnel(remainingOffers string, st *tunnelState) bool { if remainingOffers != "[]" && remainingOffers != "" { return false } - // DNS (persistent) tunnels should not be auto-stopped. - if st != nil && st.Mode == "dns" { + // Persistent tunnels should not be auto-stopped. + if st != nil && st.IsPersistent() { return false } // Quick tunnels with no remaining offers: stop. @@ -268,10 +304,7 @@ func TestLoadTunnelState_Exported(t *testing.T) { } // Write state, then load via exported function. - if err := saveTunnelState(cfg, &tunnelState{ - Mode: "quick", - Hostname: "exported-test.trycloudflare.com", - }); err != nil { + if err := saveTunnelState(cfg, &tunnelState{Mode: "quick"}); err != nil { t.Fatalf("save: %v", err) } @@ -280,8 +313,8 @@ func TestLoadTunnelState_Exported(t *testing.T) { t.Fatalf("LoadTunnelState: %v", err) } - if st.Hostname != "exported-test.trycloudflare.com" { - t.Errorf("hostname = %q, want exported-test.trycloudflare.com", st.Hostname) + if st.Hostname != "" { + t.Errorf("hostname = %q, want empty", st.Hostname) } } From 29ac39c79f50c2c9be4243c38a520d3343641229 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 8 May 2026 14:16:43 +0800 Subject: [PATCH 2/5] test(flows): tolerate summarized sell status output and dynamic ingress --- flows/flow-05-network.sh | 5 ++++- flows/flow-07-sell-verify.sh | 8 +++++--- flows/flow-08-buy.sh | 3 ++- flows/lib.sh | 5 ++++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/flows/flow-05-network.sh b/flows/flow-05-network.sh index e580f2b6..310c70f5 100755 --- a/flows/flow-05-network.sh +++ b/flows/flow-05-network.sh @@ -4,6 +4,9 @@ # Covers only: network list, network add/remove RPC, eRPC gateway health. source "$(dirname "$0")/lib.sh" +refresh_obol_ingress_env +INGRESS_URL="${OBOL_INGRESS_URL%/}" + # List available networks (local nodes + remote RPCs) run_step_grep "network list" "ethereum\|Remote\|Local" "$OBOL" network list @@ -18,7 +21,7 @@ run_step_grep "base-sepolia in network list" "base-sepolia\|84532" "$OBOL" netwo # eRPC is accessible at /rpc/evm/ — base-sepolia is chain 84532 step "eRPC base-sepolia via Traefik (/rpc/evm/84532)" -out=$($CURL_OBOL -sf --max-time 10 "http://obol.stack:8080/rpc/evm/84532" \ +out=$($CURL_OBOL -sf --max-time 10 "$INGRESS_URL/rpc/evm/84532" \ -X POST -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' 2>&1) || true if echo "$out" | grep -q '"result"'; then diff --git a/flows/flow-07-sell-verify.sh b/flows/flow-07-sell-verify.sh index 3255a1d2..2466396a 100755 --- a/flows/flow-07-sell-verify.sh +++ b/flows/flow-07-sell-verify.sh @@ -164,8 +164,9 @@ fi # than mutating x402-pricing with dynamic route entries. step "ServiceOffer RoutePublished condition is True" status_out=$("$OBOL" sell status flow-qwen -n llm 2>&1) || true -if echo "$status_out" | grep -q "type: RoutePublished" && \ - echo "$status_out" | grep -B4 "type: RoutePublished" | grep -q 'status: "True"'; then +if { echo "$status_out" | grep -q "type: RoutePublished" && \ + echo "$status_out" | grep -B4 "type: RoutePublished" | grep -q 'status: "True"'; } || \ + echo "$status_out" | grep -Eq '^ ✓ RoutePublished:'; then pass "ServiceOffer flow-qwen has RoutePublished=True" else fail "ServiceOffer flow-qwen missing RoutePublished=True — ${status_out:0:200}" @@ -185,7 +186,8 @@ fi # RoutePublished, Registered, Ready step "obol sell status flow-qwen shows Ready conditions" status_out=$("$OBOL" sell status flow-qwen -n llm 2>&1) || true -if echo "$status_out" | grep -q 'type: Ready' && echo "$status_out" | grep -q "status: \"True\""; then +if { echo "$status_out" | grep -q 'type: Ready' && echo "$status_out" | grep -q "status: \"True\""; } || \ + echo "$status_out" | grep -Eq '^ ✓ Ready:'; then pass "ServiceOffer flow-qwen has Ready condition True" else fail "ServiceOffer status missing Ready condition — ${status_out:0:200}" diff --git a/flows/flow-08-buy.sh b/flows/flow-08-buy.sh index b76a24bc..f5d81a35 100755 --- a/flows/flow-08-buy.sh +++ b/flows/flow-08-buy.sh @@ -12,10 +12,11 @@ if [ -n "$TUNNEL_URL" ]; then "$TUNNEL_URL/services/flow-qwen/v1/chat/completions" \ -H "Content-Type: application/json" \ -d "{\"model\":\"$FLOW_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}" 2>/dev/null || echo "000") - if [ "$tunnel_probe" = "402" ]; then +if [ "$tunnel_probe" = "402" ]; then BASE_URL="$TUNNEL_URL" fi fi +export BASE_URL if [[ "$BASE_URL" == *"obol.stack"* ]]; then CURL_BASE="$CURL_OBOL" else diff --git a/flows/lib.sh b/flows/lib.sh index e8586d0e..0336f5b5 100755 --- a/flows/lib.sh +++ b/flows/lib.sh @@ -10,7 +10,10 @@ set -euo pipefail export PATH="$HOME/.foundry/bin:$HOME/.local/bin:/usr/local/go/bin:$PATH" OBOL_ROOT="${OBOL_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" -OBOL_INGRESS_URL_CALLER_OVERRIDE="${OBOL_INGRESS_URL:-}" +# Only an explicit override variable should pin the ingress URL. Using the +# computed/exported OBOL_INGRESS_URL itself here can leak a stale port (for +# example 8080 from another still-running stack) into later flow scripts. +OBOL_INGRESS_URL_CALLER_OVERRIDE="${OBOL_INGRESS_URL_OVERRIDE:-}" # Auto-load .env so flow scripts can read REMOTE_SIGNER_PRIVATE_KEY and any # FLOW*_PORT / FLOW*_URL overrides without re-exporting them every run. From 729422bc16474e458a50532459a476c8e83b5563 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 8 May 2026 15:11:01 +0800 Subject: [PATCH 3/5] [verified] fix(tunnel): harden persistent handoff and setup UX --- cmd/obol/main.go | 7 +++ internal/stack/stack.go | 2 +- internal/tunnel/cloudflare.go | 4 +- internal/tunnel/cloudflare_test.go | 25 +++++++++++ internal/tunnel/domain_setup.go | 22 +++++++++- internal/tunnel/login.go | 4 +- internal/tunnel/provision.go | 12 ++++-- internal/tunnel/restore.go | 4 +- internal/tunnel/state.go | 21 +++++++++ internal/tunnel/tunnel.go | 2 +- internal/tunnel/tunnel_lifecycle_test.go | 54 ++++++++++++++++++++++++ 11 files changed, 144 insertions(+), 13 deletions(-) diff --git a/cmd/obol/main.go b/cmd/obol/main.go index 0ba43eaa..068be526 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -97,11 +97,18 @@ COMMANDS: Tunnel Management: tunnel status Show tunnel status and public URL + tunnel setup Guided persistent tunnel setup with optional domain registration tunnel login Authenticate and create persistent tunnel (browser) tunnel provision Provision persistent tunnel (API token) tunnel restart Restart tunnel connector (quick tunnels get new URL) + tunnel stop Stop the tunnel connector tunnel logs View cloudflared logs + Domain Management: + domain search Search for available Cloudflare Registrar domains + domain check Check authoritative availability for one or more domains + domain register Register a domain through Cloudflare Registrar + Kubernetes Tools (with auto-configured KUBECONFIG): kubectl Run kubectl with stack kubeconfig (passthrough) helm Run helm with stack kubeconfig (passthrough) diff --git a/internal/stack/stack.go b/internal/stack/stack.go index cbc5618e..5967af62 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -494,7 +494,7 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s } else { u.Dim("Tunnel dormant (activates on first 'obol sell http')") u.Dim(" Start manually with: obol tunnel restart") - u.Dim(" For a persistent URL: obol tunnel login --hostname stack.example.com") + u.Dim(" For a persistent URL: obol tunnel setup --hostname stack.example.com") } return nil diff --git a/internal/tunnel/cloudflare.go b/internal/tunnel/cloudflare.go index 3efc81e5..eae6f57a 100644 --- a/internal/tunnel/cloudflare.go +++ b/internal/tunnel/cloudflare.go @@ -18,6 +18,8 @@ import ( const cloudflareAPIBaseURL = "https://api.cloudflare.com/client/v4" +var errCloudflareZoneNotFound = errors.New("cloudflare zone not found") + type cloudflareTunnel struct { ID string `json:"id"` Name string `json:"name,omitempty"` @@ -190,7 +192,7 @@ func (c *cloudflareClient) ResolveZoneForHostname(hostname string) (*cloudflareZ } } - return nil, fmt.Errorf("cloudflare zone %q was not found; add the zone to Cloudflare or register it first", zoneName) + return nil, fmt.Errorf("%w: %s", errCloudflareZoneNotFound, zoneName) } func (c *cloudflareClient) CreateTunnel(accountID, tunnelName string) (*cloudflareTunnel, error) { diff --git a/internal/tunnel/cloudflare_test.go b/internal/tunnel/cloudflare_test.go index 725f2f68..828e4fa6 100644 --- a/internal/tunnel/cloudflare_test.go +++ b/internal/tunnel/cloudflare_test.go @@ -2,6 +2,7 @@ package tunnel import ( "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -91,6 +92,30 @@ func TestCloudflareClientResolveZoneForHostname(t *testing.T) { } } +func TestCloudflareClientResolveZoneForHostnameNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/zones" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": []map[string]any{}, + }) + })) + defer server.Close() + + client := newCloudflareClient("token") + client.baseURL = server.URL + + _, err := client.ResolveZoneForHostname("stack.example.dev") + if err == nil { + t.Fatal("expected not-found error") + } + if !errors.Is(err, errCloudflareZoneNotFound) { + t.Fatalf("expected zone-not-found error, got %v", err) + } +} + func TestCloudflareClientRegistrarEndpoints(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { diff --git a/internal/tunnel/domain_setup.go b/internal/tunnel/domain_setup.go index 4e7c186e..00341ebf 100644 --- a/internal/tunnel/domain_setup.go +++ b/internal/tunnel/domain_setup.go @@ -205,8 +205,23 @@ func RegisterDomain(u *ui.UI, opts DomainRegisterOptions) (*DomainRegisterResult return nil, err } } - if workflow != nil && workflow.Error != nil && workflow.State == "failed" { - return nil, fmt.Errorf("domain registration failed: %s", workflow.Error.Message) + if workflow != nil { + switch workflow.State { + case "failed": + if workflow.Error != nil && workflow.Error.Message != "" { + return nil, fmt.Errorf("domain registration failed: %s", workflow.Error.Message) + } + return nil, errors.New("domain registration failed") + case "action_required", "blocked": + message := fmt.Sprintf("domain registration requires manual action in Cloudflare (%s)", workflow.State) + if workflow.Error != nil && workflow.Error.Message != "" { + message = fmt.Sprintf("domain registration requires manual action in Cloudflare: %s", workflow.Error.Message) + } + if workflow.Links.Self != "" { + message += ": " + workflow.Links.Self + } + return nil, errors.New(message) + } } return &DomainRegisterResult{ @@ -253,6 +268,9 @@ func Setup(cfg *config.Config, u *ui.UI, opts SetupOptions) (*SetupResult, error zone, err := client.ResolveZoneForHostname(hostname) var workflow *cloudflareRegistrarWorkflow if err != nil { + if !errors.Is(err, errCloudflareZoneNotFound) { + return nil, fmt.Errorf("cloudflare zone lookup failed for %s: %w", hostname, err) + } zoneName, zoneErr := extractZoneName(hostname) if zoneErr != nil { return nil, zoneErr diff --git a/internal/tunnel/login.go b/internal/tunnel/login.go index 3691905a..4ffefbaa 100644 --- a/internal/tunnel/login.go +++ b/internal/tunnel/login.go @@ -44,7 +44,8 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { return errors.New("stack not initialized, run 'obol stack init' first") } - tunnelName := "obol-stack-" + stackID + st, _ := loadTunnelState(cfg) + tunnelName := desiredPersistentTunnelName(stackID, st, tunnelManagementLocal) cloudflaredPath, err := exec.LookPath("cloudflared") if err != nil { @@ -118,7 +119,6 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { return err } - st, _ := loadTunnelState(cfg) if st == nil { st = &tunnelState{} } diff --git a/internal/tunnel/provision.go b/internal/tunnel/provision.go index dafb99b9..0bace9fd 100644 --- a/internal/tunnel/provision.go +++ b/internal/tunnel/provision.go @@ -59,9 +59,9 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { return err } - tunnelName := "obol-stack-" + stackID st, _ := loadTunnelState(cfg) - if st != nil && st.AccountID == target.AccountID && st.TunnelID != "" && st.TunnelName != "" { + tunnelName := desiredPersistentTunnelName(stackID, st, tunnelManagementRemote) + if st != nil && st.Management() == tunnelManagementRemote && st.AccountID == target.AccountID && st.TunnelID != "" && st.TunnelName != "" { tunnelName = st.TunnelName } @@ -73,7 +73,7 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { tunnelID := "" tunnelToken := "" - if st != nil && st.AccountID == target.AccountID && st.TunnelID != "" { + if st != nil && st.Management() == tunnelManagementRemote && st.AccountID == target.AccountID && st.TunnelID != "" { tunnelID = st.TunnelID tok, tokenErr := client.GetTunnelToken(target.AccountID, tunnelID) if tokenErr != nil { @@ -164,7 +164,11 @@ func resolveProvisionTarget(client *cloudflareClient, opts ProvisionOptions) (*r if zoneID == "" { zone, zoneErr := client.ResolveZoneForHostname(hostname) if zoneErr != nil { - return nil, fmt.Errorf("could not resolve a Cloudflare zone for %s: %w. Either add the zone to Cloudflare first or use 'obol tunnel setup --register-domain'", hostname, zoneErr) + if errors.Is(zoneErr, errCloudflareZoneNotFound) { + return nil, fmt.Errorf("could not resolve a Cloudflare zone for %s: %w. Either add the zone to Cloudflare first or use 'obol tunnel setup --register-domain'", hostname, zoneErr) + } + + return nil, fmt.Errorf("cloudflare zone lookup failed for %s: %w", hostname, zoneErr) } zoneID = zone.ID zoneName = zone.Name diff --git a/internal/tunnel/restore.go b/internal/tunnel/restore.go index 444a5ff7..b39334a5 100644 --- a/internal/tunnel/restore.go +++ b/internal/tunnel/restore.go @@ -39,7 +39,7 @@ func RestorePersistentResources(cfg *config.Config, u *ui.UI) error { func restoreLocalManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath string, st *tunnelState) error { if st.TunnelID == "" { - return errors.New("persistent local tunnel is missing tunnel_id; rerun 'obol tunnel login --hostname '") + return errors.New("persistent local tunnel is missing tunnel_id; rerun 'obol tunnel setup --management local --hostname '") } cloudflaredDir := defaultCloudflaredDir() @@ -89,7 +89,7 @@ func restoreRemoteManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath } if token == "" { - return errors.New("persistent remote tunnel token is unavailable locally; rerun 'obol tunnel provision --hostname ' or set CLOUDFLARE_API_TOKEN and retry") + return errors.New("persistent remote tunnel token is unavailable locally; rerun 'obol tunnel setup --management remote --hostname --api-token ...' or set CLOUDFLARE_API_TOKEN and retry") } if err := applyTunnelTokenSecret(cfg, u, kubeconfigPath, token); err != nil { diff --git a/internal/tunnel/state.go b/internal/tunnel/state.go index 94257cc3..03171cc4 100644 --- a/internal/tunnel/state.go +++ b/internal/tunnel/state.go @@ -55,6 +55,27 @@ func remoteTunnelTokenPath(cfg *config.Config) string { return filepath.Join(tunnelStateDir(cfg), "cloudflared-token") } +func defaultPersistentTunnelName(stackID, management string) string { + base := "obol-stack-" + strings.TrimSpace(stackID) + switch management { + case tunnelManagementLocal: + return base + "-local" + case tunnelManagementRemote: + return base + "-remote" + default: + return base + } +} + +func desiredPersistentTunnelName(stackID string, st *tunnelState, management string) string { + normalized := normalizeTunnelState(st) + if normalized != nil && normalized.ManagementMode == management && strings.TrimSpace(normalized.TunnelName) != "" { + return normalized.TunnelName + } + + return defaultPersistentTunnelName(stackID, management) +} + func normalizeTunnelState(st *tunnelState) *tunnelState { if st == nil { return nil diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 20aac8d8..59717565 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -476,7 +476,7 @@ func ConfirmQuickTunnelLoss(cfg *config.Config, u *ui.UI, currentURL, action str u.Warnf("Quick tunnel URL will be invalidated: %s", currentURL) u.Dim(fmt.Sprintf(" After `%s`, the next `obol sell http` brings up a fresh URL.", action)) u.Dim(" Buyers using the old URL will see 530 errors.") - u.Dim(" For a permanent URL: obol tunnel login --hostname stack.example.com") + u.Dim(" For a permanent URL: obol tunnel setup --hostname stack.example.com") return u.Confirm("Continue?", true) } diff --git a/internal/tunnel/tunnel_lifecycle_test.go b/internal/tunnel/tunnel_lifecycle_test.go index 3d679699..c908758d 100644 --- a/internal/tunnel/tunnel_lifecycle_test.go +++ b/internal/tunnel/tunnel_lifecycle_test.go @@ -221,6 +221,60 @@ func TestTunnelModeAndURL(t *testing.T) { } } +func TestDesiredPersistentTunnelName(t *testing.T) { + tests := []struct { + name string + stackID string + state *tunnelState + management string + want string + }{ + { + name: "new local tunnel gets local suffix", + stackID: "sunny-otter", + management: tunnelManagementLocal, + want: "obol-stack-sunny-otter-local", + }, + { + name: "new remote tunnel gets remote suffix", + stackID: "sunny-otter", + management: tunnelManagementRemote, + want: "obol-stack-sunny-otter-remote", + }, + { + name: "same management reuses existing tunnel name", + stackID: "sunny-otter", + management: tunnelManagementRemote, + state: &tunnelState{ + ExposureMode: tunnelExposurePersistent, + ManagementMode: tunnelManagementRemote, + TunnelName: "obol-stack-sunny-otter", + }, + want: "obol-stack-sunny-otter", + }, + { + name: "management handoff does not reuse opposite mode tunnel name", + stackID: "sunny-otter", + management: tunnelManagementLocal, + state: &tunnelState{ + ExposureMode: tunnelExposurePersistent, + ManagementMode: tunnelManagementRemote, + TunnelName: "obol-stack-sunny-otter", + }, + want: "obol-stack-sunny-otter-local", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := desiredPersistentTunnelName(tt.stackID, tt.state, tt.management) + if got != tt.want { + t.Fatalf("desiredPersistentTunnelName() = %q, want %q", got, tt.want) + } + }) + } +} + // --------------------------------------------------------------------------- // Auto-stop decision logic // --------------------------------------------------------------------------- From 7f7fb0e71a37731cba93a44033c51f729d5eb001 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 8 May 2026 15:55:42 +0800 Subject: [PATCH 4/5] test(flows): assert tunnel does not expose erpc --- flows/flow-07-sell-verify.sh | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/flows/flow-07-sell-verify.sh b/flows/flow-07-sell-verify.sh index 2466396a..16873191 100755 --- a/flows/flow-07-sell-verify.sh +++ b/flows/flow-07-sell-verify.sh @@ -60,6 +60,52 @@ else fail "No tunnel URL found — ${TUNNEL_OUTPUT:0:200}" fi +# Security: public tunnel must not expose local-only eRPC routes. +if [ -n "$TUNNEL_URL" ]; then + step "Public tunnel does not expose eRPC" + public_erpc_exposed=false + + tunnel_erpc_index_file=$(mktemp) + tunnel_erpc_index_code=$(curl -sS --max-time 10 -o "$tunnel_erpc_index_file" -w '%{http_code}' \ + "$TUNNEL_URL/rpc" 2>/dev/null || echo "000") + tunnel_erpc_index_body=$(<"$tunnel_erpc_index_file") + rm -f "$tunnel_erpc_index_file" + if echo "$tunnel_erpc_index_body" | python3 -c " +import sys, json +try: + payload = json.load(sys.stdin) +except Exception: + raise SystemExit(1) +raise SystemExit(0 if 'rpc' in payload else 1) +" 2>/dev/null; then + public_erpc_exposed=true + fail "Public tunnel unexpectedly exposed eRPC /rpc (HTTP $tunnel_erpc_index_code)" + fi + + tunnel_erpc_chain_file=$(mktemp) + tunnel_erpc_chain_code=$(curl -sS --max-time 15 -o "$tunnel_erpc_chain_file" -w '%{http_code}' -X POST \ + "$TUNNEL_URL/rpc/evm/84532" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' 2>/dev/null || echo "000") + tunnel_erpc_chain_body=$(<"$tunnel_erpc_chain_file") + rm -f "$tunnel_erpc_chain_file" + if echo "$tunnel_erpc_chain_body" | python3 -c " +import sys, json +try: + payload = json.load(sys.stdin) +except Exception: + raise SystemExit(1) +raise SystemExit(0 if payload.get('result') else 1) +" 2>/dev/null; then + public_erpc_exposed=true + fail "Public tunnel unexpectedly served eRPC eth_chainId successfully (HTTP $tunnel_erpc_chain_code)" + fi + + if [ "$public_erpc_exposed" = false ]; then + pass "Public tunnel did not expose eRPC (index HTTP $tunnel_erpc_index_code, JSON-RPC HTTP $tunnel_erpc_chain_code)" + fi +fi + # §1.6: Verify paths # Wait for x402-verifier pods to be ready — Kubernetes Reloader restarts them when From f2d1187d4cc1b81b3372edb0bb0f66d582f45e63 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 8 May 2026 17:19:09 +0800 Subject: [PATCH 5/5] feat(tunnel): add configurable cloudflared transport --- cmd/obol/tunnel_domain.go | 48 +++++++++----- .../cloudflared/templates/deployment.yaml | 11 +++- .../infrastructure/cloudflared/values.yaml | 4 ++ internal/tunnel/domain_setup.go | 43 +++++++------ internal/tunnel/login.go | 10 ++- internal/tunnel/provision.go | 16 +++-- internal/tunnel/resources.go | 12 +++- internal/tunnel/restore.go | 4 +- internal/tunnel/state.go | 62 ++++++++++++++++--- internal/tunnel/tunnel.go | 56 ++++++++++++++--- internal/tunnel/tunnel_lifecycle_test.go | 46 ++++++++++++-- 11 files changed, 241 insertions(+), 71 deletions(-) diff --git a/cmd/obol/tunnel_domain.go b/cmd/obol/tunnel_domain.go index cfae7ee7..ff9a83f7 100644 --- a/cmd/obol/tunnel_domain.go +++ b/cmd/obol/tunnel_domain.go @@ -55,9 +55,13 @@ func tunnelCommand(cfg *config.Config) *cli.Command { Usage: "Public hostname to route (e.g. stack.example.com)", Required: true, }, + tunnelTransportProtocolFlag(), }, Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Login(cfg, getUI(cmd), tunnel.LoginOptions{Hostname: cmd.String("hostname")}) + return tunnel.Login(cfg, getUI(cmd), tunnel.LoginOptions{ + Hostname: cmd.String("hostname"), + TransportProtocol: cmd.String("transport-protocol"), + }) }, }, { @@ -88,21 +92,24 @@ func tunnelCommand(cfg *config.Config) *cli.Command { Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN"), }, + tunnelTransportProtocolFlag(), }, Action: func(ctx context.Context, cmd *cli.Command) error { return tunnel.Provision(cfg, getUI(cmd), tunnel.ProvisionOptions{ - Hostname: cmd.String("hostname"), - AccountID: cmd.String("account-id"), - ZoneID: cmd.String("zone-id"), - APIToken: cmd.String("api-token"), + Hostname: cmd.String("hostname"), + AccountID: cmd.String("account-id"), + ZoneID: cmd.String("zone-id"), + APIToken: cmd.String("api-token"), + TransportProtocol: cmd.String("transport-protocol"), }) }, }, { Name: "restart", Usage: "Restart the tunnel connector (quick tunnels get a new URL)", + Flags: []cli.Flag{tunnelTransportProtocolFlag()}, Action: func(ctx context.Context, cmd *cli.Command) error { - return tunnel.Restart(cfg, getUI(cmd)) + return tunnel.Restart(cfg, getUI(cmd), tunnel.RestartOptions{TransportProtocol: cmd.String("transport-protocol")}) }, }, { @@ -220,10 +227,18 @@ func domainCommand(cfg *config.Config) *cli.Command { } } +func tunnelTransportProtocolFlag() cli.Flag { + return &cli.StringFlag{ + Name: "transport-protocol", + Usage: "Cloudflared edge transport: auto, quic, or http2 (defaults to auto)", + } +} + func tunnelSetupFlags() []cli.Flag { return []cli.Flag{ &cli.StringFlag{Name: "hostname", Aliases: []string{"H"}, Usage: "Public hostname to route (e.g. stack.example.com)"}, &cli.StringFlag{Name: "management", Usage: "Tunnel management mode: local or remote", Value: "auto"}, + tunnelTransportProtocolFlag(), &cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")}, &cli.StringFlag{Name: "zone-id", Aliases: []string{"z"}, Usage: "Cloudflare zone ID (auto-detected when omitted)", Sources: cli.EnvVars("CLOUDFLARE_ZONE_ID")}, &cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")}, @@ -261,16 +276,17 @@ func setupOptionsFromCommand(cmd *cli.Command, u interface { } return tunnel.SetupOptions{ - Hostname: hostname, - Management: cmd.String("management"), - AccountID: cmd.String("account-id"), - ZoneID: cmd.String("zone-id"), - APIToken: cmd.String("api-token"), - RegisterDomain: cmd.Bool("register-domain"), - Years: cmd.Int("years"), - AutoRenew: cmd.Bool("auto-renew"), - PrivacyMode: cmd.String("privacy-mode"), - ConfirmCharge: cmd.Bool("yes"), + Hostname: hostname, + Management: cmd.String("management"), + TransportProtocol: cmd.String("transport-protocol"), + AccountID: cmd.String("account-id"), + ZoneID: cmd.String("zone-id"), + APIToken: cmd.String("api-token"), + RegisterDomain: cmd.Bool("register-domain"), + Years: cmd.Int("years"), + AutoRenew: cmd.Bool("auto-renew"), + PrivacyMode: cmd.String("privacy-mode"), + ConfirmCharge: cmd.Bool("yes"), }, nil } diff --git a/internal/embed/infrastructure/cloudflared/templates/deployment.yaml b/internal/embed/infrastructure/cloudflared/templates/deployment.yaml index 4256382c..a640bdfb 100644 --- a/internal/embed/infrastructure/cloudflared/templates/deployment.yaml +++ b/internal/embed/infrastructure/cloudflared/templates/deployment.yaml @@ -6,16 +6,23 @@ {{- $localTunnelIDKey := default "tunnel_id" .Values.localManaged.tunnelIDKey -}} {{- $managementConfigMapName := default "cloudflared-management" .Values.management.configMapName -}} {{- $managementModeKey := default "management_mode" .Values.management.modeKey -}} +{{- $managementProtocolKey := default "transport_protocol" .Values.management.protocolKey -}} {{- $persistentReplicas := int (default 2 .Values.persistent.replicaCount) -}} +{{- $defaultProtocol := default "auto" .Values.transport.protocol -}} +{{- $mgmt := lookup "v1" "ConfigMap" .Release.Namespace $managementConfigMapName -}} {{- $effectiveMode := $mode -}} {{- if eq $mode "auto" -}} -{{- $mgmt := lookup "v1" "ConfigMap" .Release.Namespace $managementConfigMapName -}} {{- if and $mgmt $mgmt.data (hasKey $mgmt.data $managementModeKey) -}} {{- $effectiveMode = get $mgmt.data $managementModeKey | default "auto" -}} {{- end -}} {{- end -}} +{{- $effectiveProtocol := $defaultProtocol -}} +{{- if and $mgmt $mgmt.data (hasKey $mgmt.data $managementProtocolKey) -}} +{{- $effectiveProtocol = get $mgmt.data $managementProtocolKey | default $defaultProtocol -}} +{{- end -}} + {{- $useLocal := false -}} {{- if eq $effectiveMode "local" -}} {{- $useLocal = true -}} @@ -69,6 +76,8 @@ spec: - --no-autoupdate - --metrics - {{ .Values.metrics.address | quote }} + - --protocol + - {{ $effectiveProtocol | quote }} {{- if $useLocal }} - --origincert - /etc/cloudflared/cert.pem diff --git a/internal/embed/infrastructure/cloudflared/values.yaml b/internal/embed/infrastructure/cloudflared/values.yaml index 2fb32c3d..a41a4715 100644 --- a/internal/embed/infrastructure/cloudflared/values.yaml +++ b/internal/embed/infrastructure/cloudflared/values.yaml @@ -1,5 +1,8 @@ mode: auto +transport: + protocol: "auto" + image: repository: cloudflare/cloudflared tag: "2026.3.0" @@ -16,6 +19,7 @@ persistent: management: configMapName: "cloudflared-management" modeKey: "management_mode" + protocolKey: "transport_protocol" remoteManaged: tokenSecretName: "cloudflared-tunnel-token" diff --git a/internal/tunnel/domain_setup.go b/internal/tunnel/domain_setup.go index 00341ebf..c5de87b0 100644 --- a/internal/tunnel/domain_setup.go +++ b/internal/tunnel/domain_setup.go @@ -52,16 +52,17 @@ type DomainRegisterResult struct { } type SetupOptions struct { - Hostname string - Management string - AccountID string - ZoneID string - APIToken string - RegisterDomain bool - Years int - AutoRenew bool - PrivacyMode string - ConfirmCharge bool + Hostname string + Management string + TransportProtocol string + AccountID string + ZoneID string + APIToken string + RegisterDomain bool + Years int + AutoRenew bool + PrivacyMode string + ConfirmCharge bool } type SetupResult struct { @@ -69,6 +70,7 @@ type SetupResult struct { URL string `json:"url"` Mode string `json:"mode"` ManagementMode string `json:"management_mode"` + TransportProtocol string `json:"transport_protocol,omitempty"` AccountID string `json:"account_id,omitempty"` ZoneID string `json:"zone_id,omitempty"` RegistrationStatus *cloudflareRegistrarWorkflow `json:"registration_status,omitempty"` @@ -247,14 +249,15 @@ func Setup(cfg *config.Config, u *ui.UI, opts SetupOptions) (*SetupResult, error } if management == tunnelManagementLocal { - if err := Login(cfg, u, LoginOptions{Hostname: hostname}); err != nil { + if err := Login(cfg, u, LoginOptions{Hostname: hostname, TransportProtocol: opts.TransportProtocol}); err != nil { return nil, err } return &SetupResult{ - Hostname: hostname, - URL: "https://" + hostname, - Mode: tunnelExposurePersistent, - ManagementMode: tunnelManagementLocal, + Hostname: hostname, + URL: "https://" + hostname, + Mode: tunnelExposurePersistent, + ManagementMode: tunnelManagementLocal, + TransportProtocol: normalizeTunnelTransportProtocol(opts.TransportProtocol), }, nil } if management != tunnelManagementRemote { @@ -316,10 +319,11 @@ func Setup(cfg *config.Config, u *ui.UI, opts SetupOptions) (*SetupResult, error } if err := Provision(cfg, u, ProvisionOptions{ - Hostname: hostname, - AccountID: opts.AccountID, - ZoneID: opts.ZoneID, - APIToken: opts.APIToken, + Hostname: hostname, + AccountID: opts.AccountID, + ZoneID: opts.ZoneID, + APIToken: opts.APIToken, + TransportProtocol: opts.TransportProtocol, }); err != nil { return nil, err } @@ -329,6 +333,7 @@ func Setup(cfg *config.Config, u *ui.UI, opts SetupOptions) (*SetupResult, error URL: "https://" + hostname, Mode: tunnelExposurePersistent, ManagementMode: tunnelManagementRemote, + TransportProtocol: normalizeTunnelTransportProtocol(opts.TransportProtocol), AccountID: opts.AccountID, ZoneID: opts.ZoneID, RegistrationStatus: workflow, diff --git a/internal/tunnel/login.go b/internal/tunnel/login.go index 4ffefbaa..9c28a488 100644 --- a/internal/tunnel/login.go +++ b/internal/tunnel/login.go @@ -16,7 +16,8 @@ import ( ) type LoginOptions struct { - Hostname string + Hostname string + TransportProtocol string } // Login provisions a locally-managed tunnel using `cloudflared tunnel login` (browser auth), @@ -32,6 +33,10 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { if hostname == "" { return errors.New("--hostname is required (e.g. stack.example.com)") } + transportProtocol, err := validateTunnelTransportProtocol(opts.TransportProtocol) + if err != nil { + return err + } // Stack must be running so we can write secrets/config to the cluster. kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -110,7 +115,7 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { if err := deleteRemoteTunnelToken(cfg); err != nil { return err } - if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementLocal); err != nil { + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementLocal, transportProtocol); err != nil { return err } @@ -125,6 +130,7 @@ func Login(cfg *config.Config, u *ui.UI, opts LoginOptions) error { st.ExposureMode = tunnelExposurePersistent st.ManagementMode = tunnelManagementLocal + st.TransportProtocol = transportProtocol st.Hostname = hostname st.AccountID = "" st.ZoneID = "" diff --git a/internal/tunnel/provision.go b/internal/tunnel/provision.go index 0bace9fd..e11bdf19 100644 --- a/internal/tunnel/provision.go +++ b/internal/tunnel/provision.go @@ -14,10 +14,11 @@ import ( // ProvisionOptions configures `obol tunnel provision`. type ProvisionOptions struct { - Hostname string - AccountID string - ZoneID string - APIToken string + Hostname string + AccountID string + ZoneID string + APIToken string + TransportProtocol string } type resolvedProvisionTarget struct { @@ -36,6 +37,10 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { if opts.APIToken == "" { return errors.New("--api-token is required (or set CLOUDFLARE_API_TOKEN)") } + transportProtocol, err := validateTunnelTransportProtocol(opts.TransportProtocol) + if err != nil { + return err + } // Stack must be running so we can store the tunnel token in-cluster. kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -108,7 +113,7 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { if err := deleteLocalManagedK8sResources(cfg, u, kubeconfigPath); err != nil { return err } - if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementRemote); err != nil { + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementRemote, transportProtocol); err != nil { return err } @@ -122,6 +127,7 @@ func Provision(cfg *config.Config, u *ui.UI, opts ProvisionOptions) error { } st.ExposureMode = tunnelExposurePersistent st.ManagementMode = tunnelManagementRemote + st.TransportProtocol = transportProtocol st.Hostname = target.Hostname st.AccountID = target.AccountID st.ZoneID = target.ZoneID diff --git a/internal/tunnel/resources.go b/internal/tunnel/resources.go index e379c607..e36044de 100644 --- a/internal/tunnel/resources.go +++ b/internal/tunnel/resources.go @@ -9,15 +9,21 @@ import ( "github.com/ObolNetwork/obol-stack/internal/ui" ) -func applyManagementModeConfigMap(cfg *config.Config, u *ui.UI, kubeconfigPath, mode string) error { +func applyManagementModeConfigMap(cfg *config.Config, u *ui.UI, kubeconfigPath, mode, transportProtocol string) error { + protocol := normalizeTunnelTransportProtocol(transportProtocol) + if protocol == "" { + protocol = tunnelTransportAuto + } + manifest := fmt.Sprintf(`apiVersion: v1 kind: ConfigMap metadata: name: %s namespace: %s data: - %s: %s -`, managementConfigMapName, tunnelNamespace, managementConfigModeKey, mode) + %s: %q + %s: %q +`, managementConfigMapName, tunnelNamespace, managementConfigModeKey, mode, managementConfigProtocolKey, protocol) return kubectlApply(cfg, u, kubeconfigPath, []byte(manifest)) } diff --git a/internal/tunnel/restore.go b/internal/tunnel/restore.go index b39334a5..8962ad26 100644 --- a/internal/tunnel/restore.go +++ b/internal/tunnel/restore.go @@ -65,7 +65,7 @@ func restoreLocalManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath s if err := deleteRemoteTunnelToken(cfg); err != nil { return err } - if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementLocal); err != nil { + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementLocal, tunnelTransportProtocol(st)); err != nil { return err } @@ -98,7 +98,7 @@ func restoreRemoteManagedResources(cfg *config.Config, u *ui.UI, kubeconfigPath if err := deleteLocalManagedK8sResources(cfg, u, kubeconfigPath); err != nil { return err } - if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementRemote); err != nil { + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementRemote, tunnelTransportProtocol(st)); err != nil { return err } diff --git a/internal/tunnel/state.go b/internal/tunnel/state.go index 03171cc4..fbcb5a9b 100644 --- a/internal/tunnel/state.go +++ b/internal/tunnel/state.go @@ -19,11 +19,16 @@ const ( tunnelManagementLocal = "local" tunnelManagementRemote = "remote" + tunnelTransportAuto = "auto" + tunnelTransportQUIC = "quic" + tunnelTransportHTTP2 = "http2" + legacyTunnelModeQuick = "quick" legacyTunnelModeDNS = "dns" - managementConfigMapName = "cloudflared-management" - managementConfigModeKey = "management_mode" + managementConfigMapName = "cloudflared-management" + managementConfigModeKey = "management_mode" + managementConfigProtocolKey = "transport_protocol" persistentReplicaCount = 2 quickReplicaCount = 1 @@ -33,14 +38,15 @@ type tunnelState struct { // Mode is kept for backward compatibility with older on-disk state files. Mode string `json:"mode,omitempty"` - ExposureMode string `json:"exposure_mode,omitempty"` - ManagementMode string `json:"management_mode,omitempty"` - Hostname string `json:"hostname,omitempty"` - AccountID string `json:"account_id,omitempty"` - ZoneID string `json:"zone_id,omitempty"` - TunnelID string `json:"tunnel_id,omitempty"` - TunnelName string `json:"tunnel_name,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + ExposureMode string `json:"exposure_mode,omitempty"` + ManagementMode string `json:"management_mode,omitempty"` + TransportProtocol string `json:"transport_protocol,omitempty"` + Hostname string `json:"hostname,omitempty"` + AccountID string `json:"account_id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + TunnelID string `json:"tunnel_id,omitempty"` + TunnelName string `json:"tunnel_name,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } func tunnelStateDir(cfg *config.Config) string { @@ -67,6 +73,37 @@ func defaultPersistentTunnelName(stackID, management string) string { } } +func normalizeTunnelTransportProtocol(protocol string) string { + switch strings.ToLower(strings.TrimSpace(protocol)) { + case "", tunnelTransportAuto: + return tunnelTransportAuto + case tunnelTransportQUIC: + return tunnelTransportQUIC + case tunnelTransportHTTP2, "http/2": + return tunnelTransportHTTP2 + default: + return "" + } +} + +func validateTunnelTransportProtocol(protocol string) (string, error) { + normalized := normalizeTunnelTransportProtocol(protocol) + if normalized == "" { + return "", errors.New("unsupported transport protocol (expected auto, quic, or http2)") + } + + return normalized, nil +} + +func tunnelTransportProtocol(st *tunnelState) string { + normalized := normalizeTunnelState(st) + if normalized == nil || normalized.TransportProtocol == "" { + return tunnelTransportAuto + } + + return normalized.TransportProtocol +} + func desiredPersistentTunnelName(stackID string, st *tunnelState, management string) string { normalized := normalizeTunnelState(st) if normalized != nil && normalized.ManagementMode == management && strings.TrimSpace(normalized.TunnelName) != "" { @@ -111,6 +148,11 @@ func normalizeTunnelState(st *tunnelState) *tunnelState { } } + clone.TransportProtocol = normalizeTunnelTransportProtocol(clone.TransportProtocol) + if clone.TransportProtocol == "" { + clone.TransportProtocol = tunnelTransportAuto + } + clone.Mode = legacyTunnelMode(clone.ExposureMode) return &clone diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 59717565..367ad877 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -33,6 +33,7 @@ type tunnelStatusResult struct { Mode string `json:"mode"` ExposureMode string `json:"exposure_mode,omitempty"` ManagementMode string `json:"management_mode,omitempty"` + TransportProtocol string `json:"transport_protocol,omitempty"` Status string `json:"status"` URL string `json:"url"` Hostname string `json:"hostname,omitempty"` @@ -75,6 +76,10 @@ type tunnelRuntimeHealth struct { PodStatus string } +type RestartOptions struct { + TransportProtocol string +} + // Status displays the current tunnel status and URL. func Status(cfg *config.Config, u *ui.UI) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") @@ -87,16 +92,18 @@ func Status(cfg *config.Config, u *ui.UI) error { st, _ := loadTunnelState(cfg) mode, url := tunnelModeAndURL(st) result := tunnelStatusResult{ - Mode: mode, - ExposureMode: tunnelExposureQuick, - ManagementMode: tunnelManagementQuick, - URL: url, - DesiredReplicas: desiredRuntimeReplicas(st), - LastUpdated: now.Format(time.RFC3339), + Mode: mode, + ExposureMode: tunnelExposureQuick, + ManagementMode: tunnelManagementQuick, + TransportProtocol: tunnelTransportAuto, + URL: url, + DesiredReplicas: desiredRuntimeReplicas(st), + LastUpdated: now.Format(time.RFC3339), } if st != nil { result.ExposureMode = st.ExposureMode result.ManagementMode = st.Management() + result.TransportProtocol = tunnelTransportProtocol(st) result.Hostname = st.Hostname } if result.ExposureMode == "" { @@ -490,7 +497,7 @@ func ConfirmQuickTunnelLoss(cfg *config.Config, u *ui.UI, currentURL, action str // - the storefront HTTPRoute is hostname-pinned; without an update it points // at the old tunnel hostname and traffic to the new hostname's `/` falls // through to the frontend catch-all -func Restart(cfg *config.Config, u *ui.UI) error { +func Restart(cfg *config.Config, u *ui.UI, opts RestartOptions) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -500,6 +507,28 @@ func Restart(cfg *config.Config, u *ui.UI) error { } st, _ := loadTunnelState(cfg) + transportOverrideSpecified := strings.TrimSpace(opts.TransportProtocol) != "" + if transportOverrideSpecified { + transportProtocol, err := validateTunnelTransportProtocol(opts.TransportProtocol) + if err != nil { + return err + } + if st == nil { + st = &tunnelState{} + } + if !st.IsPersistent() { + st.ExposureMode = tunnelExposureQuick + st.ManagementMode = tunnelManagementQuick + st.Hostname = "" + st.AccountID = "" + st.ZoneID = "" + } + st.TransportProtocol = transportProtocol + if err := saveTunnelState(cfg, st); err != nil { + return fmt.Errorf("save tunnel state: %w", err) + } + } + if st != nil && st.IsPersistent() { if err := RestorePersistentResources(cfg, u); err != nil { return fmt.Errorf("restore persistent tunnel resources: %w", err) @@ -511,6 +540,16 @@ func Restart(cfg *config.Config, u *ui.UI) error { return nil } + + transportProtocol := tunnelTransportProtocol(st) + if transportOverrideSpecified || transportProtocol != tunnelTransportAuto { + if err := applyManagementModeConfigMap(cfg, u, kubeconfigPath, tunnelManagementQuick, transportProtocol); err != nil { + return err + } + if err := helmUpgradeCloudflared(cfg, u, kubeconfigPath); err != nil { + return fmt.Errorf("failed to apply cloudflared transport settings: %w", err) + } + } } cmd := exec.Command(kubectlPath, @@ -668,6 +707,9 @@ func printDetailedStatusBox(u *ui.UI, result tunnelStatusResult, lastUpdated tim if result.ManagementMode != "" { u.Detail("Management", result.ManagementMode) } + if result.TransportProtocol != "" { + u.Detail("Transport", result.TransportProtocol) + } u.Detail("Status", result.Status) if result.Hostname != "" { u.Detail("Hostname", result.Hostname) diff --git a/internal/tunnel/tunnel_lifecycle_test.go b/internal/tunnel/tunnel_lifecycle_test.go index c908758d..91de8fa5 100644 --- a/internal/tunnel/tunnel_lifecycle_test.go +++ b/internal/tunnel/tunnel_lifecycle_test.go @@ -51,6 +51,9 @@ func TestTunnelState_RoundTrip(t *testing.T) { if got.Hostname != "" { t.Errorf("hostname = %q, want empty", got.Hostname) } + if got.TransportProtocol != tunnelTransportAuto { + t.Errorf("transport_protocol = %q, want %q", got.TransportProtocol, tunnelTransportAuto) + } if got.UpdatedAt.IsZero() { t.Error("UpdatedAt should be set by save") } @@ -60,12 +63,13 @@ func TestTunnelState_DNSMode(t *testing.T) { cfg := testConfig(t) st := &tunnelState{ - Mode: "dns", - Hostname: "stack.example.com", - AccountID: "acct-123", - ZoneID: "zone-456", - TunnelID: "tun-789", - TunnelName: "my-tunnel", + Mode: "dns", + Hostname: "stack.example.com", + AccountID: "acct-123", + ZoneID: "zone-456", + TunnelID: "tun-789", + TunnelName: "my-tunnel", + TransportProtocol: tunnelTransportHTTP2, } if err := saveTunnelState(cfg, st); err != nil { @@ -86,6 +90,9 @@ func TestTunnelState_DNSMode(t *testing.T) { if got.ManagementMode != tunnelManagementRemote { t.Errorf("management_mode = %q, want remote", got.ManagementMode) } + if got.TransportProtocol != tunnelTransportHTTP2 { + t.Errorf("transport_protocol = %q, want %q", got.TransportProtocol, tunnelTransportHTTP2) + } if got.TunnelID != "tun-789" { t.Errorf("tunnel_id = %q, want tun-789", got.TunnelID) } @@ -221,6 +228,33 @@ func TestTunnelModeAndURL(t *testing.T) { } } +func TestNormalizeTunnelTransportProtocol(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "default empty", input: "", want: tunnelTransportAuto}, + {name: "auto", input: "auto", want: tunnelTransportAuto}, + {name: "quic", input: "quic", want: tunnelTransportQUIC}, + {name: "http2", input: "http2", want: tunnelTransportHTTP2}, + {name: "http slash 2 alias", input: "http/2", want: tunnelTransportHTTP2}, + {name: "uppercase trims", input: " HTTP2 ", want: tunnelTransportHTTP2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeTunnelTransportProtocol(tt.input); got != tt.want { + t.Fatalf("normalizeTunnelTransportProtocol(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } + + if got := normalizeTunnelTransportProtocol("bogus"); got != "" { + t.Fatalf("normalizeTunnelTransportProtocol(%q) = %q, want empty", "bogus", got) + } +} + func TestDesiredPersistentTunnelName(t *testing.T) { tests := []struct { name string