Skip to content

Commit 44f86fe

Browse files
committed
refactor(cloudflare): create tunnel + DNS via API, fallback deb install
The previous orchestrator relied on \`cloudflared tunnel create\` and \`cloudflared tunnel route dns\`, both of which need the browser-based \`cloudflared tunnel login\` flow (writes ~/.cloudflared/cert.pem) — not scriptable. Rewrite around the Cloudflare REST API instead: - CloudflareApi gains getAccountId, findTunnel, createTunnel, createDnsRecord/updateDnsRecord/upsertDnsRecord. - Orchestrator.init finds-or-creates the tunnel via API with a caller-generated secret, writes the credentials JSON at /etc/cloudflared/<id>.json, upserts CNAME records pointing at <id>.cfargotunnel.com, and enables/restarts the systemd unit. - Reusing a tunnel by name requires its credentials file to already be on the host — Cloudflare doesn't echo the secret back on GET, so we can't reconstruct it. Error message points at the fix. Also switch cloudflared install to fall back on the upstream .deb from GitHub when pkg.cloudflare.com has no package for the current codename (fresh Ubuntu releases like 26.04/resolute).
1 parent 6f97240 commit 44f86fe

2 files changed

Lines changed: 177 additions & 22 deletions

File tree

src/infrastructure/cloudflare/api.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
const CF_API = 'https://api.cloudflare.com/client/v4';
22

3+
export interface CfTunnel {
4+
id: string;
5+
name: string;
6+
account_tag: string;
7+
}
8+
9+
export interface CfDnsRecord {
10+
id: string;
11+
type: string;
12+
name: string;
13+
content: string;
14+
proxied?: boolean;
15+
}
16+
317
export class CloudflareApi {
418
constructor(private apiToken: string) {}
519

@@ -15,7 +29,7 @@ export class CloudflareApi {
1529
const json = await res.json() as { success: boolean; errors?: { message: string }[]; result: T };
1630
if (!json.success) {
1731
const msg = json.errors?.map((e) => e.message).join(', ') ?? 'Unknown error';
18-
throw new Error(`Cloudflare API error: ${msg}`);
32+
throw new Error(`Cloudflare API error (${path}): ${msg}`);
1933
}
2034
return json.result;
2135
}
@@ -26,8 +40,63 @@ export class CloudflareApi {
2640
return zones[0].id;
2741
}
2842

29-
async getDnsRecords(zoneId: string, hostname: string): Promise<{ type: string; content: string }[]> {
30-
return this.fetch(`/zones/${zoneId}/dns_records?name=${hostname}`);
43+
/**
44+
* Returns the first account the token has access to. Tokens created for a
45+
* specific account only see that account, which is the intended usage.
46+
*/
47+
async getAccountId(): Promise<string> {
48+
const accounts = await this.fetch<{ id: string; name: string }[]>('/accounts');
49+
if (!accounts.length) throw new Error('Token has no account access — check its scope');
50+
return accounts[0].id;
51+
}
52+
53+
async findTunnel(accountId: string, name: string): Promise<CfTunnel | null> {
54+
const tunnels = await this.fetch<CfTunnel[]>(
55+
`/accounts/${accountId}/cfd_tunnel?name=${encodeURIComponent(name)}&is_deleted=false`,
56+
);
57+
return tunnels.find((t) => t.name === name) ?? null;
58+
}
59+
60+
/**
61+
* Creates a tunnel with a caller-supplied secret so we can construct the
62+
* credentials file locally (Cloudflare only echoes the secret back in the
63+
* POST response; there's no way to re-fetch it later). config_src=local
64+
* means cloudflared reads /etc/cloudflared/config.yml on the host — the
65+
* config-file model shipnode has always used.
66+
*/
67+
async createTunnel(accountId: string, name: string, secretBase64: string): Promise<CfTunnel> {
68+
return this.fetch<CfTunnel>(`/accounts/${accountId}/cfd_tunnel`, {
69+
method: 'POST',
70+
body: JSON.stringify({ name, tunnel_secret: secretBase64, config_src: 'local' }),
71+
});
72+
}
73+
74+
async getDnsRecords(zoneId: string, hostname: string): Promise<CfDnsRecord[]> {
75+
return this.fetch<CfDnsRecord[]>(`/zones/${zoneId}/dns_records?name=${hostname}`);
76+
}
77+
78+
async createDnsRecord(zoneId: string, record: { type: string; name: string; content: string; proxied?: boolean }): Promise<CfDnsRecord> {
79+
return this.fetch<CfDnsRecord>(`/zones/${zoneId}/dns_records`, {
80+
method: 'POST',
81+
body: JSON.stringify({ ...record, ttl: 1 }),
82+
});
83+
}
84+
85+
async updateDnsRecord(zoneId: string, recordId: string, record: { type: string; name: string; content: string; proxied?: boolean }): Promise<CfDnsRecord> {
86+
return this.fetch<CfDnsRecord>(`/zones/${zoneId}/dns_records/${recordId}`, {
87+
method: 'PUT',
88+
body: JSON.stringify({ ...record, ttl: 1 }),
89+
});
90+
}
91+
92+
/**
93+
* Idempotent: creates a record if none exists for the hostname, or updates
94+
* the existing one to point at `content`. Returns the resulting record.
95+
*/
96+
async upsertDnsRecord(zoneId: string, record: { type: string; name: string; content: string; proxied?: boolean }): Promise<CfDnsRecord> {
97+
const existing = await this.getDnsRecords(zoneId, record.name);
98+
if (existing.length === 0) return this.createDnsRecord(zoneId, record);
99+
return this.updateDnsRecord(zoneId, existing[0].id, record);
31100
}
32101

33102
async getCloudflareIps(): Promise<{ ipv4_cidrs: string[]; ipv6_cidrs: string[] }> {

src/services/cloudflare.orchestrator.ts

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { randomBytes } from 'node:crypto';
12
import type { ShipnodeConfig } from '../shared/types.js';
23
import type { RemoteExecutor } from '../domain/remote/executor.js';
3-
import { CloudflareApi } from '../infrastructure/cloudflare/api.js';
4+
import { CloudflareApi, type CfTunnel } from '../infrastructure/cloudflare/api.js';
45
import { Tunnel } from '../domain/cloudflare/tunnel.js';
56

7+
const CFD_INGRESS_TARGET = '.cfargotunnel.com';
8+
69
export class CloudflareOrchestrator {
710
private api: CloudflareApi;
811

@@ -19,24 +22,60 @@ export class CloudflareOrchestrator {
1922
if (!cf) throw new Error('No cloudflare config found.');
2023

2124
await this.ensureCloudflaredInstalled();
25+
26+
const accountId = await this.api.getAccountId();
27+
const zoneId = await this.api.getZoneId(cf.zone);
2228
const tunnelName = cf.tunnelName ?? `shipnode-${this.config.ssh.host.replace(/\./g, '-')}`;
2329

24-
await this.executor.exec(`cloudflared tunnel create "${tunnelName}" 2>&1`);
25-
const tunnelId = await this.resolveTunnelId();
30+
// Reuse an existing tunnel by name if present. We can only mint credentials
31+
// when we create the tunnel (Cloudflare doesn't echo the secret again), so
32+
// if it already exists we assume credentials are already on the host and
33+
// just refresh config + DNS. A brand-new host reusing an old tunnel name
34+
// will need `cloudflared tunnel delete` first — flagged loudly.
35+
const existing = await this.api.findTunnel(accountId, tunnelName);
36+
let tunnelId: string;
37+
if (existing) {
38+
tunnelId = existing.id;
39+
const credsExists = await this.executor.exec(
40+
`test -f "/etc/cloudflared/${tunnelId}.json" && echo YES || echo NO`,
41+
);
42+
if (credsExists.stdout.trim() !== 'YES') {
43+
throw new Error(
44+
`Tunnel "${tunnelName}" already exists (id ${tunnelId}) but its credentials file is not on this host. ` +
45+
`Delete the tunnel in the Cloudflare dashboard (or via API) and re-run \`shipnode cloudflare init\`, ` +
46+
`or copy /etc/cloudflared/${tunnelId}.json from the host that owns it.`,
47+
);
48+
}
49+
} else {
50+
const secret = randomBytes(32).toString('base64');
51+
const created: CfTunnel = await this.api.createTunnel(accountId, tunnelName, secret);
52+
tunnelId = created.id;
53+
await this.writeCredentials(tunnelId, tunnelName, accountId, secret);
54+
}
2655

27-
const tunnel = new Tunnel(tunnelName, tunnelId, `/root/.cloudflared/${tunnelId}.json`);
56+
const tunnel = new Tunnel(tunnelName, tunnelId, `/etc/cloudflared/${tunnelId}.json`);
2857

2958
for (const app of this.config.apps) {
3059
const webApp = app.pm2?.apps.find((a) => a.port !== undefined);
3160
if (app.domain && webApp) {
3261
tunnel.addIngress(app.domain, `http://localhost:${webApp.port}`);
33-
await this.executor.exec(`cloudflared tunnel route dns "${tunnelName}" "${app.domain}"`);
62+
await this.api.upsertDnsRecord(zoneId, {
63+
type: 'CNAME',
64+
name: app.domain,
65+
content: `${tunnelId}${CFD_INGRESS_TARGET}`,
66+
proxied: true,
67+
});
3468
}
3569
}
3670

3771
if (cf.sshHostname) {
3872
tunnel.addIngress(cf.sshHostname, 'ssh://localhost:22');
39-
await this.executor.exec(`cloudflared tunnel route dns "${tunnelName}" "${cf.sshHostname}"`);
73+
await this.api.upsertDnsRecord(zoneId, {
74+
type: 'CNAME',
75+
name: cf.sshHostname,
76+
content: `${tunnelId}${CFD_INGRESS_TARGET}`,
77+
proxied: true,
78+
});
4079
}
4180

4281
await this.writeConfig(tunnel);
@@ -80,47 +119,94 @@ export class CloudflareOrchestrator {
80119

81120
async status(): Promise<string> {
82121
const result = await this.executor.exec(
83-
`systemctl status cloudflared 2>&1; echo "---"; cloudflared tunnel info 2>&1 || true`,
122+
`systemctl status cloudflared 2>&1; echo "---"; cat /etc/cloudflared/config.yml 2>&1 || true`,
84123
);
85124
return result.stdout;
86125
}
87126

127+
/**
128+
* Install cloudflared from Cloudflare's apt repo, falling back to the
129+
* upstream .deb from GitHub when the repo has no package for this codename
130+
* (fresh Ubuntu releases like 26.04/resolute lag behind pkg.cloudflare.com).
131+
*/
88132
private async ensureCloudflaredInstalled(): Promise<void> {
89133
const check = await this.executor.exec('command -v cloudflared 2>/dev/null || echo MISSING');
90134
if (check.stdout.trim() !== 'MISSING') return;
91135

92-
await this.executor.exec(
136+
const repoResult = await this.executor.exec(
93137
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
94138
`curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | $SUDO tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null && ` +
95139
`echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | ` +
96-
`$SUDO tee /etc/apt/sources.list.d/cloudflared.list && ` +
97-
`$SUDO apt-get update -qq && $SUDO apt-get install -y -qq cloudflared`,
140+
`$SUDO tee /etc/apt/sources.list.d/cloudflared.list > /dev/null && ` +
141+
`$SUDO apt-get update -qq 2>&1 && $SUDO apt-get install -y -qq cloudflared`,
142+
);
143+
if (repoResult.exitCode === 0) return;
144+
145+
// apt failed (usually because the codename isn't published yet). Remove the
146+
// stale sources entry so future `apt update` doesn't keep erroring, then
147+
// fall back to the upstream .deb.
148+
await this.executor.exec(
149+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO rm -f /etc/apt/sources.list.d/cloudflared.list && $SUDO apt-get update -qq >/dev/null 2>&1 || true`,
150+
);
151+
await this.executor.execOrThrow(
152+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
153+
`ARCH=$(dpkg --print-architecture) && ` +
154+
`TMP=$(mktemp --suffix=.deb) && ` +
155+
`curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$ARCH.deb" -o "$TMP" && ` +
156+
`$SUDO dpkg -i "$TMP" && rm -f "$TMP"`,
98157
);
99158
}
100159

101-
private async resolveTunnelId(): Promise<string> {
102-
const result = await this.executor.exec(
103-
`cloudflared tunnel list --output json 2>/dev/null | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4`,
160+
/**
161+
* Write the credentials JSON cloudflared expects when tunnel_secret was
162+
* supplied at creation time. Format is stable and undocumented but widely
163+
* used; see https://github.com/cloudflare/cloudflared/blob/master/tunnel/token.go
164+
*/
165+
private async writeCredentials(
166+
tunnelId: string,
167+
tunnelName: string,
168+
accountId: string,
169+
secretBase64: string,
170+
): Promise<void> {
171+
const payload = JSON.stringify({
172+
AccountTag: accountId,
173+
TunnelSecret: secretBase64,
174+
TunnelID: tunnelId,
175+
TunnelName: tunnelName,
176+
});
177+
const b64 = Buffer.from(payload).toString('base64');
178+
await this.executor.execOrThrow(
179+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
180+
`$SUDO mkdir -p /etc/cloudflared && ` +
181+
`printf '%s' '${b64}' | base64 -d | $SUDO tee "/etc/cloudflared/${tunnelId}.json" > /dev/null && ` +
182+
`$SUDO chmod 600 "/etc/cloudflared/${tunnelId}.json"`,
104183
);
105-
const tunnelId = result.stdout.trim();
106-
if (!tunnelId) throw new Error('Could not determine tunnel ID. Check: cloudflared tunnel list');
107-
return tunnelId;
108184
}
109185

110186
private async writeConfig(tunnel: Tunnel): Promise<void> {
111187
const yaml = tunnel.toYaml();
112188
const b64 = Buffer.from(yaml).toString('base64');
113-
await this.executor.exec(
189+
await this.executor.execOrThrow(
114190
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
115-
`mkdir -p /etc/cloudflared && ` +
191+
`$SUDO mkdir -p /etc/cloudflared && ` +
116192
`printf '%s' '${b64}' | base64 -d | $SUDO tee /etc/cloudflared/config.yml > /dev/null`,
117193
);
118194
}
119195

196+
/**
197+
* `cloudflared service install` writes the systemd unit that reads
198+
* /etc/cloudflared/config.yml. Idempotent: re-installing an existing unit
199+
* updates it; a running service is restarted to pick up config changes.
200+
*/
120201
private async installAndStartService(): Promise<void> {
121202
await this.executor.exec(
122203
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
123-
`$SUDO cloudflared service install && $SUDO systemctl start cloudflared`,
204+
`$SUDO cloudflared service install 2>/dev/null || true`,
205+
);
206+
await this.executor.execOrThrow(
207+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
208+
`$SUDO systemctl enable cloudflared 2>/dev/null; ` +
209+
`$SUDO systemctl restart cloudflared`,
124210
);
125211
}
126212

0 commit comments

Comments
 (0)