@@ -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 }
0 commit comments