11import { runRemoteCommand } from '../runner.js' ;
22import { ui } from '../ui.js' ;
3- import { getWebApp } from '../../domain/pm2/apps.js' ;
4-
5- const CF_API = 'https://api.cloudflare.com/client/v4' ;
6-
7- interface CfApiOptions {
8- apiToken : string ;
9- }
10-
11- async function cfFetch ( path : string , opts : CfApiOptions , init : RequestInit = { } ) : Promise < unknown > {
12- const res = await fetch ( `${ CF_API } ${ path } ` , {
13- ...init ,
14- headers : {
15- 'Authorization' : `Bearer ${ opts . apiToken } ` ,
16- 'Content-Type' : 'application/json' ,
17- ...( init . headers as Record < string , string > ?? { } ) ,
18- } ,
19- } ) ;
20- const json = await res . json ( ) as { success : boolean ; errors ?: { message : string } [ ] ; result : unknown } ;
21- if ( ! json . success ) {
22- const msg = json . errors ?. map ( ( e ) => e . message ) . join ( ', ' ) ?? 'Unknown error' ;
23- throw new Error ( `Cloudflare API error: ${ msg } ` ) ;
24- }
25- return json . result ;
26- }
3+ import { CloudflareOrchestrator } from '../../services/cloudflare.orchestrator.js' ;
274
285function requireToken ( ) : string {
296 const token = process . env [ 'CLOUDFLARE_API_TOKEN' ] ?? process . env [ 'CF_API_TOKEN' ] ;
@@ -38,212 +15,34 @@ export async function cmdCloudflareInit(
3815 cwd : string ,
3916 options : { config ?: string } ,
4017) : Promise < void > {
41- await runRemoteCommand (
42- cwd ,
43- async ( { config, executor } ) => {
44- if ( ! config . cloudflare ) {
45- ui . error ( 'No cloudflare config found. Add a cloudflare block to shipnode.config.ts' ) ;
46- process . exit ( 1 ) ;
47- }
48-
49- const cf = config . cloudflare ;
50- const apiToken = requireToken ( ) ;
51-
52- ui . info ( 'Installing cloudflared...' ) ;
53-
54- const installCheck = await executor . exec ( 'command -v cloudflared 2>/dev/null || echo MISSING' ) ;
55- if ( installCheck . stdout . trim ( ) === 'MISSING' ) {
56- await executor . exec (
57- `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
58- `curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | $SUDO tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null && ` +
59- `echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | ` +
60- `$SUDO tee /etc/apt/sources.list.d/cloudflared.list && ` +
61- `$SUDO apt-get update -qq && $SUDO apt-get install -y -qq cloudflared` ,
62- ) ;
63- ui . success ( 'cloudflared installed' ) ;
64- } else {
65- ui . info ( 'cloudflared already installed' ) ;
66- }
67-
68- const tunnelName = cf . tunnelName ?? `shipnode-${ config . ssh . host . replace ( / \. / g, '-' ) } ` ;
69- ui . info ( `Creating tunnel: ${ tunnelName } ` ) ;
70-
71- // Create tunnel via API to get ID, then install on server
72- const cfApiOpts : CfApiOptions = { apiToken } ;
73-
74- // Get zone ID
75- const zones = await cfFetch ( `/zones?name=${ cf . zone } ` , cfApiOpts ) as { id : string } [ ] ;
76- if ( ! zones . length ) throw new Error ( `Zone "${ cf . zone } " not found` ) ;
77- const zoneId = zones [ 0 ] . id ;
78-
79- // Create tunnel (remote execution: cloudflared tunnel create)
80- const createResult = await executor . exec (
81- `cloudflared tunnel create "${ tunnelName } " 2>&1` ,
82- ) ;
83- ui . info ( createResult . stdout . trim ( ) ) ;
84-
85- // Get tunnel UUID from list
86- const listResult = await executor . exec (
87- `cloudflared tunnel list --output json 2>/dev/null | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4` ,
88- ) ;
89- const tunnelId = listResult . stdout . trim ( ) ;
90-
91- if ( ! tunnelId ) {
92- ui . error ( 'Could not determine tunnel ID. Check: cloudflared tunnel list' ) ;
93- process . exit ( 1 ) ;
94- }
95-
96- // Create DNS records if appHostname set
97- if ( cf . appHostname ) {
98- await executor . exec (
99- `cloudflared tunnel route dns "${ tunnelName } " "${ cf . appHostname } "` ,
100- ) ;
101- ui . success ( `DNS: ${ cf . appHostname } → tunnel` ) ;
102- }
103-
104- if ( cf . sshHostname ) {
105- await executor . exec (
106- `cloudflared tunnel route dns "${ tunnelName } " "${ cf . sshHostname } "` ,
107- ) ;
108- ui . success ( `DNS: ${ cf . sshHostname } → tunnel` ) ;
109- }
110-
111- // Generate config and start as systemd service
112- const webApp = getWebApp ( config ) ;
113- if ( cf . appHostname && ! webApp ) {
114- throw new Error ( 'cloudflare.appHostname is set but no pm2.apps entry declares a port — nothing to route HTTP to.' ) ;
115- }
116- const appIngress = cf . appHostname && webApp
117- ? ` - hostname: ${ cf . appHostname } \n service: http://localhost:${ webApp . port } `
118- : '' ;
119- const sshIngress = cf . sshHostname
120- ? ` - hostname: ${ cf . sshHostname } \n service: ssh://localhost:22`
121- : '' ;
122-
123- const cfConfig = `tunnel: ${ tunnelId }
124- credentials-file: /root/.cloudflared/${ tunnelId } .json
125-
126- ingress:
127- ${ appIngress }
128- ${ sshIngress }
129- - service: http_status:404
130- ` ;
131-
132- const b64 = Buffer . from ( cfConfig ) . toString ( 'base64' ) ;
133- await executor . exec (
134- `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
135- `mkdir -p /etc/cloudflared && ` +
136- `printf '%s' '${ b64 } ' | base64 -d | $SUDO tee /etc/cloudflared/config.yml > /dev/null` ,
137- ) ;
138-
139- await executor . exec (
140- `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
141- `$SUDO cloudflared service install && $SUDO systemctl start cloudflared` ,
142- ) ;
143-
144- // Setup firewall lockdown if requested
145- if ( cf . lockdownFirewall ) {
146- if ( ! webApp ) {
147- ui . warn ( 'Skipping firewall lockdown: no web app (no pm2.apps entry with a port) to expose.' ) ;
148- } else {
149- ui . info ( 'Locking down firewall to Cloudflare IPs only...' ) ;
150- const cfIpv4 = await cfFetch ( '/ips' , cfApiOpts ) as { ipv4_cidrs : string [ ] } ;
151- for ( const cidr of cfIpv4 . ipv4_cidrs ?? [ ] ) {
152- await executor . exec (
153- `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
154- `$SUDO ufw allow from ${ cidr } to any port ${ webApp . port } 2>/dev/null || true` ,
155- ) ;
156- }
157- await executor . exec (
158- `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
159- `$SUDO ufw deny ${ webApp . port } 2>/dev/null || true` ,
160- ) ;
161- ui . success ( 'Firewall locked to Cloudflare IPs' ) ;
162- }
163- }
164-
165- // Setup Access policy if accessEmails present
166- if ( cf . accessEmails ?. length && zoneId ) {
167- ui . info ( 'Setting up Cloudflare Access policy...' ) ;
168- await cfFetch ( `/accounts` , cfApiOpts ) ; // just to verify token works
169- ui . info ( `Access policy: configure manually at dash.cloudflare.com for zone ${ cf . zone } ` ) ;
170- ui . info ( `Allowed emails: ${ cf . accessEmails . join ( ', ' ) } ` ) ;
171- }
172-
173- ui . success ( `Cloudflare tunnel "${ tunnelName } " initialized.` ) ;
174- } ,
175- { configPath : options . config } ,
176- ) ;
18+ await runRemoteCommand ( cwd , async ( { config, executor } ) => {
19+ const orchestrator = new CloudflareOrchestrator ( executor , config , requireToken ( ) ) ;
20+ await orchestrator . init ( ) ;
21+ ui . success ( `Cloudflare tunnel initialized.` ) ;
22+ } , { configPath : options . config } ) ;
17723}
17824
17925export async function cmdCloudflareAudit (
18026 cwd : string ,
18127 options : { config ?: string } ,
18228) : Promise < void > {
183- await runRemoteCommand (
184- cwd ,
185- async ( { config, executor } ) => {
186- if ( ! config . cloudflare ) {
187- ui . error ( 'No cloudflare config found.' ) ;
188- process . exit ( 1 ) ;
189- }
190-
191- const apiToken = requireToken ( ) ;
192- const cf = config . cloudflare ;
193- const cfApiOpts : CfApiOptions = { apiToken } ;
194-
195- ui . info ( `Auditing Cloudflare config for zone: ${ cf . zone } ` ) ;
196-
197- const zones = await cfFetch ( `/zones?name=${ cf . zone } ` , cfApiOpts ) as { id : string ; name : string ; status : string } [ ] ;
198- if ( ! zones . length ) {
199- ui . error ( `Zone "${ cf . zone } " not found in account` ) ;
200- return ;
201- }
202-
203- const zone = zones [ 0 ] ;
204- console . log ( ` Zone: ${ zone . name } (${ zone . id } ) — ${ zone . status } ` ) ;
205-
206- // Check DNS records
207- if ( cf . appHostname ) {
208- const records = await cfFetch (
209- `/zones/${ zone . id } /dns_records?name=${ cf . appHostname } ` ,
210- cfApiOpts ,
211- ) as { type : string ; content : string } [ ] ;
212- if ( records . length ) {
213- console . log ( ` DNS ${ cf . appHostname } : ${ records [ 0 ] . type } → ${ records [ 0 ] . content } ` ) ;
214- } else {
215- ui . warn ( ` DNS ${ cf . appHostname } : NOT FOUND` ) ;
216- }
217- }
218-
219- // Check tunnel on server
220- const tunnelCheck = await executor . exec ( 'cloudflared tunnel list 2>/dev/null || echo MISSING' ) ;
221- if ( tunnelCheck . stdout . includes ( 'MISSING' ) ) {
222- ui . warn ( ' cloudflared: not installed' ) ;
223- } else {
224- console . log ( ` cloudflared tunnels:\n${ tunnelCheck . stdout . trim ( ) } ` ) ;
225- }
226-
227- // Check service
228- const svcCheck = await executor . exec ( 'systemctl is-active cloudflared 2>/dev/null || echo inactive' ) ;
229- console . log ( ` cloudflared service: ${ svcCheck . stdout . trim ( ) } ` ) ;
230- } ,
231- { configPath : options . config } ,
232- ) ;
29+ await runRemoteCommand ( cwd , async ( { config, executor } ) => {
30+ const orchestrator = new CloudflareOrchestrator ( executor , config , requireToken ( ) ) ;
31+ const result = await orchestrator . audit ( ) ;
32+ console . log ( ` Zone: ${ result . zone . name } (${ result . zone . id } ) — ${ result . zone . status } ` ) ;
33+ if ( result . dns ) console . log ( ` DNS: ${ result . dns } ` ) ;
34+ console . log ( ` cloudflared tunnels:\n${ result . tunnelList } ` ) ;
35+ console . log ( ` cloudflared service: ${ result . service } ` ) ;
36+ } , { configPath : options . config } ) ;
23337}
23438
23539export async function cmdCloudflareStatus (
23640 cwd : string ,
23741 options : { config ?: string } ,
23842) : Promise < void > {
239- await runRemoteCommand (
240- cwd ,
241- async ( { executor } ) => {
242- const result = await executor . exec (
243- `systemctl status cloudflared 2>&1; echo "---"; cloudflared tunnel info 2>&1 || true` ,
244- ) ;
245- console . log ( result . stdout ) ;
246- } ,
247- { configPath : options . config } ,
248- ) ;
43+ await runRemoteCommand ( cwd , async ( { config, executor } ) => {
44+ const orchestrator = new CloudflareOrchestrator ( executor , config , requireToken ( ) ) ;
45+ const output = await orchestrator . status ( ) ;
46+ console . log ( output ) ;
47+ } , { configPath : options . config } ) ;
24948}
0 commit comments