fix(cfsetup): regenerate missing tunnel credentials on re-bootstrap

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.
This commit is contained in:
flyemoji committed 2026-07-16 23:23:42 +09:00
1 parent 5fbb1db4ec
commit 93190e7a5b
1 file changed
+40 -7
+40 -7
View File
@@ -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/<id>.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`.