From 93190e7a5b209a7ceb3e353f622eac69539e1d01 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Thu, 16 Jul 2026 23:23:42 +0900 Subject: [PATCH] fix(cfsetup): regenerate missing tunnel credentials on re-bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cloudflared writes the tunnel credentials JSON only at `tunnel create`. An idempotent re-run against a tunnel that already exists — or a reset + re-bootstrap where the old box's ~/.cloudflared was wiped but the Cloudflare-side tunnel survived — finds no local credentials file, and the connector crash-loops with "Tunnel credentials file doesn't exist". A tunnel that never comes up leaves op.console unreachable, so the one-time setup link minted just before it ages out (30-min TTL) unredeemed. CreateTunnel now resolves the tunnel id on both paths (fresh create and already-exists) and routes through ensureCredentials, which re-fetches the token with `cloudflared tunnel token --cred-file` (authenticating via cert.pem, preserving the same id / DNS / Access) when the file is absent. The secret is written to the file, not stdout, and the file is chmod 0600 so it is not left world-readable next to cert.pem. The self-heal is unconditional on re-bootstrap: Setup gates on Pre.check() (cert.pem present) before CreateTunnel, so the token re-fetch always has its cert.pem authority. --- internal/cfsetup/runner.go | 47 ++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/internal/cfsetup/runner.go b/internal/cfsetup/runner.go index ee86ddf..32b7f52 100644 --- a/internal/cfsetup/runner.go +++ b/internal/cfsetup/runner.go @@ -102,19 +102,52 @@ var tunnelIDRE = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4 // credentials JSON under ~/.cloudflared/.json and prints the id; we parse it // out. If the tunnel already exists this returns its id (idempotent re-run). func (r *ExecRunner) CreateTunnel(ctx context.Context, name string) (string, string, error) { + var id string out, err := r.runCloudflared(ctx, "tunnel", "create", name) if err != nil { // An "already exists" is not fatal — recover the id via `tunnel list`. - if id, lerr := r.lookupTunnel(ctx, name); lerr == nil && id != "" { - return id, r.credentialsPath(id), nil + lid, lerr := r.lookupTunnel(ctx, name) + if lerr != nil || lid == "" { + return "", "", err } - return "", "", err - } - id := tunnelIDRE.FindString(out) - if id == "" { + id = lid + } else if id = tunnelIDRE.FindString(out); id == "" { return "", "", fmt.Errorf("cfsetup: could not parse tunnel id from cloudflared output: %s", out) } - return id, r.credentialsPath(id), nil + cred := r.credentialsPath(id) + if err := r.ensureCredentials(ctx, id, cred); err != nil { + return "", "", err + } + return id, cred, nil +} + +// ensureCredentials guarantees the tunnel credentials JSON exists at credPath. +// cloudflared writes that file only at `tunnel create`, so an idempotent re-run +// against a tunnel that already exists — or a reset+re-bootstrap where the old +// box's ~/.cloudflared was wiped but the Cloudflare-side tunnel survived — finds +// no local file, and the connector crash-loops with "Tunnel credentials file +// doesn't exist". `cloudflared tunnel token --cred-file` re-fetches the token into +// the file (authenticating with cert.pem, keeping the same id/DNS/Access), healing +// the re-run. The secret is written to the file, not stdout. +func (r *ExecRunner) ensureCredentials(ctx context.Context, id, credPath string) error { + // ponytail: any existing file counts as healthy; re-fetch only on absence + // (the failure actually seen). A truncated/zero-byte file would still + // crash-loop — validate the JSON here if that ever shows up. + if _, err := os.Stat(credPath); err == nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(credPath), 0o700); err != nil { + return fmt.Errorf("cfsetup: preparing credentials dir for tunnel %s: %w", id, err) + } + if _, err := r.runCloudflared(ctx, "tunnel", "token", "--cred-file", credPath, id); err != nil { + return fmt.Errorf("cfsetup: tunnel %s credentials file %s is missing and could not be regenerated: %w", id, credPath, err) + } + // The credentials file is a secret sitting next to cert.pem; don't rely on + // cloudflared's umask to keep it owner-only. + if err := os.Chmod(credPath, 0o600); err != nil { + return fmt.Errorf("cfsetup: securing credentials file %s: %w", credPath, err) + } + return nil } // lookupTunnel finds an existing tunnel's id by name via `tunnel list`.