Skip to content

Commit 7e2eca4

Browse files
committed
fix: reuse host Cloudflare tunnel and keep Caddy ingress for blue-green.
Prevent cloudflare init from minting a mismatched tunnel id/credentials pair, merge existing ingress (including originRequest), route zero-downtime apps through local Caddy, and bump to 3.2.0-alpha.6.
1 parent fb44a44 commit 7e2eca4

5 files changed

Lines changed: 163 additions & 26 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@devalade/shipnode",
3-
"version": "3.2.0-alpha.5",
3+
"version": "3.2.0-alpha.6",
44
"description": "Deploy Node.js apps to a single VPS with zero configuration",
55
"type": "module",
66
"main": "dist/index.js",

src/domain/cloudflare/ingress.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
export interface IngressOriginRequest {
2+
noTLSVerify?: boolean;
3+
originServerName?: string;
4+
httpHostHeader?: string;
5+
}
6+
17
export interface Ingress {
28
hostname?: string;
39
service: string;
10+
originRequest?: IngressOriginRequest;
411
}

src/domain/cloudflare/tunnel.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Ingress } from './ingress.js';
1+
import { Ingress, type IngressOriginRequest } from './ingress.js';
22

33
export class Tunnel {
44
constructor(
@@ -9,15 +9,21 @@ export class Tunnel {
99
public catchAll: string = 'http_status:404',
1010
) {}
1111

12-
addIngress(hostname: string | undefined, service: string): void {
12+
addIngress(
13+
hostname: string | undefined,
14+
service: string,
15+
originRequest?: IngressOriginRequest,
16+
): void {
1317
if (!hostname) return;
1418
const existing = this.ingress.findIndex(
1519
(e) => e.hostname === hostname,
1620
);
21+
const entry: Ingress = { hostname, service };
22+
if (originRequest) entry.originRequest = originRequest;
1723
if (existing !== -1) {
18-
this.ingress[existing].service = service;
24+
this.ingress[existing] = entry;
1925
} else {
20-
this.ingress.push({ hostname, service });
26+
this.ingress.push(entry);
2127
}
2228
}
2329

@@ -46,6 +52,18 @@ export class Tunnel {
4652
if (entry.hostname) {
4753
lines.push(` - hostname: ${entry.hostname}`);
4854
lines.push(` service: ${entry.service}`);
55+
if (entry.originRequest) {
56+
lines.push(` originRequest:`);
57+
if (entry.originRequest.noTLSVerify) {
58+
lines.push(` noTLSVerify: true`);
59+
}
60+
if (entry.originRequest.originServerName) {
61+
lines.push(` originServerName: ${entry.originRequest.originServerName}`);
62+
}
63+
if (entry.originRequest.httpHostHeader) {
64+
lines.push(` httpHostHeader: ${entry.originRequest.httpHostHeader}`);
65+
}
66+
}
4967
}
5068
}
5169
lines.push(` - service: ${this.catchAll}`);
@@ -59,25 +77,51 @@ export class Tunnel {
5977
const lines = content.split('\n');
6078

6179
let inIngress = false;
80+
let inOriginRequest = false;
6281

6382
for (const raw of lines) {
6483
const line = raw.trimEnd();
6584
const trimmed = line.trimStart();
6685
if (line.startsWith('tunnel: ')) {
6786
tunnel.id = line.slice('tunnel: '.length);
87+
inOriginRequest = false;
6888
} else if (line.startsWith('credentials-file: ')) {
6989
tunnel.credentialsPath = line.slice('credentials-file: '.length);
90+
inOriginRequest = false;
7091
} else if (trimmed === 'ingress:') {
7192
inIngress = true;
93+
inOriginRequest = false;
7294
} else if (inIngress && trimmed.startsWith('- hostname: ')) {
95+
inOriginRequest = false;
7396
const hostname = trimmed.slice('- hostname: '.length);
7497
tunnel.ingress.push({ hostname, service: '' });
98+
} else if (inIngress && trimmed === 'originRequest:') {
99+
inOriginRequest = true;
100+
const last = tunnel.ingress[tunnel.ingress.length - 1];
101+
if (last) last.originRequest = {};
102+
} else if (inIngress && inOriginRequest && trimmed.startsWith('noTLSVerify:')) {
103+
const last = tunnel.ingress[tunnel.ingress.length - 1];
104+
if (last?.originRequest) {
105+
last.originRequest.noTLSVerify = trimmed.includes('true');
106+
}
107+
} else if (inIngress && inOriginRequest && trimmed.startsWith('originServerName: ')) {
108+
const last = tunnel.ingress[tunnel.ingress.length - 1];
109+
if (last?.originRequest) {
110+
last.originRequest.originServerName = trimmed.slice('originServerName: '.length);
111+
}
112+
} else if (inIngress && inOriginRequest && trimmed.startsWith('httpHostHeader: ')) {
113+
const last = tunnel.ingress[tunnel.ingress.length - 1];
114+
if (last?.originRequest) {
115+
last.originRequest.httpHostHeader = trimmed.slice('httpHostHeader: '.length);
116+
}
75117
} else if (inIngress && trimmed.startsWith('service: ') && tunnel.ingress.length > 0) {
118+
inOriginRequest = false;
76119
const last = tunnel.ingress[tunnel.ingress.length - 1];
77120
if (last.service === '') {
78121
last.service = trimmed.slice('service: '.length);
79122
}
80123
} else if (inIngress && trimmed.startsWith('- service: ')) {
124+
inOriginRequest = false;
81125
tunnel.catchAll = trimmed.slice('- service: '.length);
82126
}
83127
}

src/services/cloudflare.orchestrator.ts

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,38 +27,55 @@ export class CloudflareOrchestrator {
2727
const zoneId = await this.api.getZoneId(cf.zone);
2828
const tunnelName = cf.tunnelName ?? `shipnode-${this.config.ssh.host.replace(/\./g, '-')}`;
2929

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);
30+
// Prefer the tunnel already running on this host. Creating a new tunnel (or
31+
// picking a same-named API tunnel) while leaving the old credentials path
32+
// in config.yml produces a silent id/creds mismatch and takes every app
33+
// offline. Only mint a new tunnel when the host has no usable config.
34+
const hostTunnel = await this.readHostTunnel();
3635
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`,
36+
let tunnel: Tunnel;
37+
38+
if (hostTunnel?.id && hostTunnel.credentialsPath) {
39+
const credsOk = await this.executor.exec(
40+
`test -f "${hostTunnel.credentialsPath}" && echo YES || echo NO`,
4141
);
42-
if (credsExists.stdout.trim() !== 'YES') {
42+
if (credsOk.stdout.trim() !== 'YES') {
4343
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.`,
44+
`Host config references tunnel ${hostTunnel.id} but credentials ` +
45+
`${hostTunnel.credentialsPath} are missing. Restore the credentials ` +
46+
`file or delete /etc/cloudflared/config.yml and re-run init.`,
4747
);
4848
}
49+
tunnelId = hostTunnel.id;
50+
tunnel = hostTunnel;
51+
tunnel.name = tunnelName;
4952
} 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);
53+
const existing = await this.api.findTunnel(accountId, tunnelName);
54+
if (existing) {
55+
tunnelId = existing.id;
56+
const credsExists = await this.executor.exec(
57+
`test -f "/etc/cloudflared/${tunnelId}.json" && echo YES || echo NO`,
58+
);
59+
if (credsExists.stdout.trim() !== 'YES') {
60+
throw new Error(
61+
`Tunnel "${tunnelName}" already exists (id ${tunnelId}) but its credentials file is not on this host. ` +
62+
`Delete the tunnel in the Cloudflare dashboard (or via API) and re-run \`shipnode cloudflare init\`, ` +
63+
`or copy /etc/cloudflared/${tunnelId}.json from the host that owns it.`,
64+
);
65+
}
66+
} else {
67+
const secret = randomBytes(32).toString('base64');
68+
const created: CfTunnel = await this.api.createTunnel(accountId, tunnelName, secret);
69+
tunnelId = created.id;
70+
await this.writeCredentials(tunnelId, tunnelName, accountId, secret);
71+
}
72+
tunnel = new Tunnel(tunnelName, tunnelId, `/etc/cloudflared/${tunnelId}.json`);
5473
}
5574

56-
const tunnel = new Tunnel(tunnelName, tunnelId, `/etc/cloudflared/${tunnelId}.json`);
57-
5875
for (const app of this.config.apps) {
5976
const webApp = app.pm2?.apps.find((a) => a.port !== undefined);
6077
if (app.domain && webApp) {
61-
tunnel.addIngress(app.domain, `http://localhost:${webApp.port}`);
78+
this.addAppIngress(tunnel, app.domain, webApp.port!, Boolean(app.zeroDowntime));
6279
await this.api.upsertDnsRecord(zoneId, {
6380
type: 'CNAME',
6481
name: app.domain,
@@ -78,6 +95,10 @@ export class CloudflareOrchestrator {
7895
});
7996
}
8097

98+
// Credentials path must always match the tunnel id we advertise in DNS.
99+
tunnel.id = tunnelId;
100+
tunnel.credentialsPath = `/etc/cloudflared/${tunnelId}.json`;
101+
81102
await this.writeConfig(tunnel);
82103
await this.installAndStartService();
83104

@@ -183,12 +204,44 @@ export class CloudflareOrchestrator {
183204
);
184205
}
185206

207+
/**
208+
* Blue-green apps flip Caddy's upstream between colours. Pointing the tunnel
209+
* at a fixed app port would pin traffic to one colour and break rollouts.
210+
* Route those hostnames through local Caddy (TLS) with the public Host header.
211+
*/
212+
private addAppIngress(
213+
tunnel: Tunnel,
214+
domain: string,
215+
port: number,
216+
zeroDowntime: boolean,
217+
): void {
218+
if (zeroDowntime) {
219+
tunnel.addIngress(domain, 'https://localhost:443', {
220+
noTLSVerify: true,
221+
originServerName: domain,
222+
httpHostHeader: domain,
223+
});
224+
return;
225+
}
226+
tunnel.addIngress(domain, `http://localhost:${port}`);
227+
}
228+
229+
private async readHostTunnel(): Promise<Tunnel | null> {
230+
const existing = await this.executor.exec(
231+
`cat /etc/cloudflared/config.yml 2>/dev/null || echo ""`,
232+
);
233+
const yaml = existing.stdout.trim();
234+
if (!yaml) return null;
235+
return Tunnel.fromYaml(yaml);
236+
}
237+
186238
private async writeConfig(tunnel: Tunnel): Promise<void> {
187239
const yaml = tunnel.toYaml();
188240
const b64 = Buffer.from(yaml).toString('base64');
189241
await this.executor.execOrThrow(
190242
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
191243
`$SUDO mkdir -p /etc/cloudflared && ` +
244+
`$SUDO cp /etc/cloudflared/config.yml "/etc/cloudflared/config.yml.bak.$(date +%Y%m%d%H%M%S)" 2>/dev/null || true; ` +
192245
`printf '%s' '${b64}' | base64 -d | $SUDO tee /etc/cloudflared/config.yml > /dev/null`,
193246
);
194247
}
@@ -206,6 +259,11 @@ export class CloudflareOrchestrator {
206259
await this.executor.execOrThrow(
207260
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
208261
`$SUDO systemctl enable cloudflared 2>/dev/null; ` +
262+
// Type=notify + fresh QUIC handshake can exceed the default 15s start
263+
// timeout on a busy host; bump before restart so init doesn't false-fail.
264+
`$SUDO mkdir -p /etc/systemd/system/cloudflared.service.d && ` +
265+
`printf '%s\n' '[Service]' 'TimeoutStartSec=60' | $SUDO tee /etc/systemd/system/cloudflared.service.d/timeout.conf > /dev/null && ` +
266+
`$SUDO systemctl daemon-reload && ` +
209267
`$SUDO systemctl restart cloudflared`,
210268
);
211269
}

tests/unit/cloudflare.tunnel.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ ingress:
1212
- service: http_status:404
1313
`;
1414

15+
const caddyYaml = `tunnel: abc123
16+
credentials-file: /etc/cloudflared/abc123.json
17+
18+
ingress:
19+
- hostname: app.example.com
20+
service: https://localhost:443
21+
originRequest:
22+
noTLSVerify: true
23+
originServerName: app.example.com
24+
httpHostHeader: app.example.com
25+
- service: http_status:404
26+
`;
27+
1528
describe('Tunnel', () => {
1629
it('round-trips YAML', () => {
1730
const tunnel = Tunnel.fromYaml(sampleYaml);
@@ -26,6 +39,21 @@ describe('Tunnel', () => {
2639
expect(output).toBe(sampleYaml);
2740
});
2841

42+
it('round-trips Caddy-fronted originRequest blocks', () => {
43+
const tunnel = Tunnel.fromYaml(caddyYaml);
44+
expect(tunnel.ingress).toHaveLength(1);
45+
expect(tunnel.ingress[0]).toEqual({
46+
hostname: 'app.example.com',
47+
service: 'https://localhost:443',
48+
originRequest: {
49+
noTLSVerify: true,
50+
originServerName: 'app.example.com',
51+
httpHostHeader: 'app.example.com',
52+
},
53+
});
54+
expect(tunnel.toYaml()).toBe(caddyYaml);
55+
});
56+
2957
it('adds an ingress entry', () => {
3058
const tunnel = Tunnel.fromYaml(sampleYaml);
3159
tunnel.addIngress('admin.example.com', 'http://localhost:4000');

0 commit comments

Comments
 (0)