Skip to content

Commit 8f0e620

Browse files
committed
First attempt at cleaning up tunnel impl
1 parent b76098e commit 8f0e620

17 files changed

Lines changed: 801 additions & 1350 deletions

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ obol
8787
│ └── skills add, remove, list
8888
├── model setup (has sub: custom), status, token, sync, pull, list, prefer, discover, remove
8989
├── app install, sync, list, delete
90-
├── tunnel status, setup, login, provision, restart, stop, logs
90+
├── tunnel status, setup, restart, stop, logs (login hidden: browser-managed fallback)
9191
├── domain search, check, register
9292
├── kubectl/helm/helmfile/k9s Passthrough (auto KUBECONFIG)
9393
├── update Helm + CLI version check (--json)
@@ -102,6 +102,7 @@ obol
102102
- `sell info <name>` prints purchase instructions (URL, model, buy.py command).
103103
- `sell mcp [name]` runs a foreground x402-paid MCP server: forwards buyer JSON args to a backend HTTP service, injecting the seller's own API key (buyer never sees it). Payment rides MCP `_meta` (`internal/x402mcp`).
104104
- `sell resume` replays every persisted sell offer (inference incl. detached host-gateway relaunch; http/agent/demo-agent via the manifest ledger at `$OBOL_CONFIG_DIR/sell-http/`) — run after a host reboot; `obol stack up` runs the same path. `--install-boot-unit` adds a systemd user unit (Linux). `sell mcp` is foreground-only, no offer, not resumed.
105+
- `tunnel setup [<token>]`: the one permanent-URL command. Connector-token based (dashboard-managed) — no host binary, no account-wide API key. Accepts the bare connector token, the `--token` flag, a positional arg, or the whole `cloudflared tunnel run --token …` line (prefix stripped via `extractConnectorToken`). Reuses the remote runtime (`ProvisionWithToken` → `TUNNEL_TOKEN` secret, chart `management_mode=remote`); DNS/ingress are configured by the user in the Cloudflare dashboard (route Public Hostname → `http://traefik.traefik.svc.cluster.local:80`), not via API. The API-token provisioning path was removed (no more `tunnel provision`, no setup `--api-token/--account-id/--zone-id/--register-domain`). `--management local` (alias hidden `tunnel login`) is the browser fallback (needs `cloudflared`). `tunnel status` reads connector health from cloudflared's in-cluster `/ready`+`/metrics` (port 2000, no token) plus a public HTTP probe; concise by default, `--verbose` for replicas/pods, `--no-probe` to stay offline. Domain registration still lives under `obol domain register` (still uses a Cloudflare API token).
105106
- `hermes` is passthrough to native hermes CLI via `hermes.CLI()` (cmd/obol/hermes.go:27). No Go-level subcommands registered.
106107
- `bootstrap` (cmd/obol/bootstrap.go) is a hidden command for installer use only — not user-facing.
107108

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -234,20 +234,25 @@ Skills are delivered via host-path PVC injection — no ConfigMap size limits, w
234234

235235
## Public Access (Cloudflare Tunnel)
236236

237-
Expose your stack to the internet via Cloudflare Tunnel:
237+
A tunnel exposes your stack to the public internet so buyers can discover and
238+
pay for the services you sell. You don't need it for local use — set one up once
239+
you're ready to sell, to get a permanent URL.
238240

239241
```bash
240-
# Check tunnel status (quick tunnel mode is the default)
242+
# Check tunnel status (a temporary quick-tunnel URL is the default)
241243
obol tunnel status
242244

243-
# Use a persistent hostname
244-
obol tunnel login --hostname stack.example.com
245-
246-
# Or provision via API
247-
obol tunnel provision --hostname stack.example.com \
248-
--account-id ... --zone-id ... --api-token ...
245+
# Create a permanent URL. Create a tunnel in the Cloudflare dashboard
246+
# (Networks → Tunnels), route its Public Hostname to
247+
# http://traefik.traefik.svc.cluster.local:80, then paste the connector token —
248+
# you can paste the whole `cloudflared tunnel run --token …` line:
249+
obol tunnel setup --hostname stack.example.com <connector-token>
249250
```
250251

252+
This uses a least-privilege, single-tunnel connector token — no account-wide API
253+
key required. (Advanced: `obol tunnel setup --management local` uses a browser
254+
login on this machine instead, which needs `cloudflared` installed.)
255+
251256
## Managing the Stack
252257

253258
```bash

cmd/obol/main.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,7 @@ COMMANDS:
102102
103103
Tunnel Management:
104104
tunnel status Show tunnel status and public URL
105-
tunnel setup Guided persistent tunnel setup with optional domain registration
106-
tunnel login Authenticate and create persistent tunnel (browser)
107-
tunnel provision Provision persistent tunnel (API token)
105+
tunnel setup Create a permanent public URL with a Cloudflare tunnel
108106
tunnel restart Restart tunnel connector (quick tunnels get new URL)
109107
tunnel stop Stop the tunnel connector
110108
tunnel logs View cloudflared logs

cmd/obol/tunnel_domain.go

Lines changed: 30 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,31 @@ func tunnelCommand(cfg *config.Config) *cli.Command {
1919
{
2020
Name: "status",
2121
Usage: "Show tunnel status and public URL",
22+
Flags: []cli.Flag{
23+
&cli.BoolFlag{Name: "no-probe", Usage: "Skip connector and public reachability probes (offline/fast)"},
24+
},
2225
Action: func(ctx context.Context, cmd *cli.Command) error {
23-
return tunnel.Status(cfg, getUI(cmd))
26+
return tunnel.Status(cfg, getUI(cmd), tunnel.StatusOptions{NoProbe: cmd.Bool("no-probe")})
2427
},
2528
},
2629
{
27-
Name: "setup",
28-
Usage: "Guided persistent tunnel setup with optional domain registration",
30+
Name: "setup",
31+
Usage: "Create a permanent public URL with a Cloudflare tunnel",
32+
ArgsUsage: "[<connector-token>]",
33+
Description: "A tunnel exposes your stack to the public internet so buyers can discover and\n" +
34+
"pay for the services you sell. You don't need it for local use — set one up\n" +
35+
"once you're ready to sell, to get a permanent URL.\n\n" +
36+
"By default Obol wires a dashboard-managed tunnel from a Cloudflare connector\n" +
37+
"token (least privilege, no API key, no local install). Create the tunnel in the\n" +
38+
"Cloudflare dashboard, route its Public Hostname to\n" +
39+
"http://traefik.traefik.svc.cluster.local:80, then paste the token here — you can\n" +
40+
"paste the whole 'cloudflared tunnel run --token …' line and Obol extracts it.\n\n" +
41+
"Advanced: '--management local' uses a browser login on this machine instead\n" +
42+
"(needs cloudflared installed); 'obol tunnel login' is the same flow directly.",
2943
Flags: tunnelSetupFlags(),
3044
Action: func(ctx context.Context, cmd *cli.Command) error {
3145
u := getUI(cmd)
32-
opts, err := setupOptionsFromCommand(cmd, u)
46+
opts, err := setupOptionsFromCommand(cmd)
3347
if err != nil {
3448
return err
3549
}
@@ -46,8 +60,9 @@ func tunnelCommand(cfg *config.Config) *cli.Command {
4660
},
4761
},
4862
{
49-
Name: "login",
50-
Usage: "Authenticate via browser and create a locally-managed tunnel (no API token)",
63+
Name: "login",
64+
Hidden: true,
65+
Usage: "Advanced: create a locally-managed tunnel via browser login (no token)",
5166
Flags: []cli.Flag{
5267
&cli.StringFlag{
5368
Name: "hostname",
@@ -69,46 +84,6 @@ func tunnelCommand(cfg *config.Config) *cli.Command {
6984
})
7085
},
7186
},
72-
{
73-
Name: "provision",
74-
Usage: "Provision a persistent remote-managed Cloudflare Tunnel",
75-
Flags: []cli.Flag{
76-
&cli.StringFlag{
77-
Name: "hostname",
78-
Aliases: []string{"H"},
79-
Usage: "Public hostname to route (e.g. stack.example.com)",
80-
Required: true,
81-
},
82-
&cli.StringFlag{
83-
Name: "account-id",
84-
Aliases: []string{"a"},
85-
Usage: "Cloudflare account ID (optional if the API token can access a single account)",
86-
Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID"),
87-
},
88-
&cli.StringFlag{
89-
Name: "zone-id",
90-
Aliases: []string{"z"},
91-
Usage: "Cloudflare zone ID for the hostname (auto-detected when omitted)",
92-
Sources: cli.EnvVars("CLOUDFLARE_ZONE_ID"),
93-
},
94-
&cli.StringFlag{
95-
Name: "api-token",
96-
Aliases: []string{"t"},
97-
Usage: "Cloudflare API token",
98-
Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN"),
99-
},
100-
tunnelTransportProtocolFlag(),
101-
},
102-
Action: func(ctx context.Context, cmd *cli.Command) error {
103-
return tunnel.Provision(cfg, getUI(cmd), tunnel.ProvisionOptions{
104-
Hostname: cmd.String("hostname"),
105-
AccountID: cmd.String("account-id"),
106-
ZoneID: cmd.String("zone-id"),
107-
APIToken: cmd.String("api-token"),
108-
TransportProtocol: cmd.String("transport-protocol"),
109-
})
110-
},
111-
},
11287
{
11388
Name: "restart",
11489
Usage: "Restart the tunnel connector (quick tunnels get a new URL)",
@@ -242,25 +217,15 @@ func tunnelTransportProtocolFlag() cli.Flag {
242217
func tunnelSetupFlags() []cli.Flag {
243218
return []cli.Flag{
244219
&cli.StringFlag{Name: "hostname", Aliases: []string{"H"}, Usage: "Public hostname to route (e.g. stack.example.com)"},
245-
&cli.StringFlag{Name: "management", Usage: "Tunnel management mode: local or remote", Value: "auto"},
220+
&cli.StringFlag{Name: "token", Aliases: []string{"t"}, Usage: "Cloudflare tunnel connector token (or pass it as a positional argument)"},
221+
&cli.StringFlag{Name: "management", Usage: "Tunnel management: connector (default) or local (browser fallback)", Value: "connector"},
246222
tunnelTransportProtocolFlag(),
247-
&cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")},
248-
&cli.StringFlag{Name: "zone-id", Aliases: []string{"z"}, Usage: "Cloudflare zone ID (auto-detected when omitted)", Sources: cli.EnvVars("CLOUDFLARE_ZONE_ID")},
249-
&cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")},
250-
&cli.BoolFlag{Name: "register-domain", Usage: "Register the domain apex via Cloudflare Registrar when the zone is missing"},
251-
&cli.IntFlag{Name: "years", Usage: "Domain registration term in years", Value: 1},
252-
&cli.BoolFlag{Name: "auto-renew", Usage: "Enable domain auto-renew when registering a domain"},
253-
&cli.StringFlag{Name: "privacy-mode", Usage: "WHOIS privacy mode for registration", Value: "redaction"},
254-
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Confirm billable domain registration without prompting"},
255-
&cli.BoolFlag{Name: "overwrite-dns", Usage: "Replace any existing A/AAAA/CNAME at the hostname (forwards --overwrite-dns to cloudflared in local-managed mode)"},
223+
&cli.BoolFlag{Name: "overwrite-dns", Usage: "Local-managed only: replace any existing A/AAAA/CNAME at the hostname"},
256224
&cli.StringFlag{Name: "from-json", Usage: "Read setup options from JSON file (or - for stdin)"},
257225
}
258226
}
259227

260-
func setupOptionsFromCommand(cmd *cli.Command, u interface {
261-
Input(string, string) (string, error)
262-
},
263-
) (tunnel.SetupOptions, error) {
228+
func setupOptionsFromCommand(cmd *cli.Command) (tunnel.SetupOptions, error) {
264229
if jsonPath := cmd.String("from-json"); jsonPath != "" {
265230
var opts tunnel.SetupOptions
266231
data, err := readJSONInput(jsonPath)
@@ -273,27 +238,16 @@ func setupOptionsFromCommand(cmd *cli.Command, u interface {
273238
return opts, nil
274239
}
275240

276-
hostname := cmd.String("hostname")
277-
if strings.TrimSpace(hostname) == "" {
278-
input, err := u.Input("Public hostname", "")
279-
if err != nil {
280-
return tunnel.SetupOptions{}, err
281-
}
282-
hostname = input
241+
token := strings.TrimSpace(cmd.String("token"))
242+
if token == "" {
243+
token = strings.TrimSpace(cmd.Args().First())
283244
}
284245

285246
return tunnel.SetupOptions{
286-
Hostname: hostname,
247+
Hostname: cmd.String("hostname"),
287248
Management: cmd.String("management"),
249+
ConnectorToken: token,
288250
TransportProtocol: cmd.String("transport-protocol"),
289-
AccountID: cmd.String("account-id"),
290-
ZoneID: cmd.String("zone-id"),
291-
APIToken: cmd.String("api-token"),
292-
RegisterDomain: cmd.Bool("register-domain"),
293-
Years: cmd.Int("years"),
294-
AutoRenew: cmd.Bool("auto-renew"),
295-
PrivacyMode: cmd.String("privacy-mode"),
296-
ConfirmCharge: cmd.Bool("yes"),
297251
OverwriteDNS: cmd.Bool("overwrite-dns"),
298252
}, nil
299253
}

docs/guides/monetize-inference.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,15 @@ If the tunnel isn't running or you want a fresh URL:
236236
obol tunnel restart
237237
```
238238

239+
> **Get a permanent URL once you're selling.** Quick-tunnel URLs change on every
240+
> restart, so buyers who bookmarked the old one hit errors. When you're ready to
241+
> attract buyers, create a stable hostname: create a tunnel in the Cloudflare
242+
> dashboard (Networks → Tunnels), route its Public Hostname to
243+
> `http://traefik.traefik.svc.cluster.local:80`, then run
244+
> `obol tunnel setup --hostname stack.example.com <connector-token>` (paste the
245+
> whole `cloudflared tunnel run --token …` line — Obol extracts the token). This
246+
> needs only a least-privilege connector token, not an account-wide API key.
247+
239248
### 1.6 Verify Your Paths
240249

241250
Test each route to confirm everything is wired correctly:

internal/tunnel/agent.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/ObolNetwork/obol-stack/internal/agentruntime"
1111
"github.com/ObolNetwork/obol-stack/internal/config"
12+
"github.com/ObolNetwork/obol-stack/internal/helmcmd"
1213
)
1314

1415
const agentDeploymentID = agentruntime.DefaultInstanceID
@@ -54,7 +55,13 @@ func SyncAgentBaseURL(cfg *config.Config, tunnelURL string) error {
5455

5556
fmt.Printf("Syncing AGENT_BASE_URL=%s to obol-agent...\n", tunnelURL)
5657

57-
cmd := exec.Command(helmfileBin, "-f", helmfilePath, "sync")
58+
// Match every other helmfile-sync path (stack/app/openclaw/hermes): on Helm 4+
59+
// append --force-conflicts so the SSA upgrade can take ownership of the
60+
// AGENT_BASE_URL env field, which InjectBaseURL previously wrote via
61+
// `kubectl set env` (field manager "kubectl-set"). Without this, Helm 4's
62+
// server-side apply refuses the field and the sync fails with a conflict.
63+
syncArgs := append([]string{"-f", helmfilePath, "sync"}, helmcmd.SyncFlagsForVersion(filepath.Join(cfg.BinDir, "helm"))...)
64+
cmd := exec.Command(helmfileBin, syncArgs...)
5865
cmd.Dir = deploymentDir
5966

6067
cmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath)

0 commit comments

Comments
 (0)