1+ import { randomBytes } from 'node:crypto' ;
12import type { ShipnodeConfig } from '../shared/types.js' ;
23import 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' ;
45import { Tunnel } from '../domain/cloudflare/tunnel.js' ;
56
7+ const CFD_INGRESS_TARGET = '.cfargotunnel.com' ;
8+
69export 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