Skip to content

Commit 4ec0ea3

Browse files
committed
refactor(cloudflare): extract orchestrator to services/cloudflare.orchestrator (sprint 3)
1 parent f7a29be commit 4ec0ea3

3 files changed

Lines changed: 214 additions & 220 deletions

File tree

src/cli/commands/cloudflare.ts

Lines changed: 19 additions & 220 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,6 @@
11
import { runRemoteCommand } from '../runner.js';
22
import { 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

285
function 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

17925
export 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

23539
export 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
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const CF_API = 'https://api.cloudflare.com/client/v4';
2+
3+
export class CloudflareApi {
4+
constructor(private apiToken: string) {}
5+
6+
async fetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
7+
const res = await fetch(`${CF_API}${path}`, {
8+
...init,
9+
headers: {
10+
'Authorization': `Bearer ${this.apiToken}`,
11+
'Content-Type': 'application/json',
12+
...(init.headers as Record<string, string> ?? {}),
13+
},
14+
});
15+
const json = await res.json() as { success: boolean; errors?: { message: string }[]; result: T };
16+
if (!json.success) {
17+
const msg = json.errors?.map((e) => e.message).join(', ') ?? 'Unknown error';
18+
throw new Error(`Cloudflare API error: ${msg}`);
19+
}
20+
return json.result;
21+
}
22+
23+
async getZoneId(zone: string): Promise<string> {
24+
const zones = await this.fetch<{ id: string }[]>(`/zones?name=${zone}`);
25+
if (!zones.length) throw new Error(`Zone "${zone}" not found`);
26+
return zones[0].id;
27+
}
28+
29+
async getDnsRecords(zoneId: string, hostname: string): Promise<{ type: string; content: string }[]> {
30+
return this.fetch(`/zones/${zoneId}/dns_records?name=${hostname}`);
31+
}
32+
33+
async getCloudflareIps(): Promise<{ ipv4_cidrs: string[]; ipv6_cidrs: string[] }> {
34+
return this.fetch('/ips');
35+
}
36+
37+
async verifyToken(): Promise<void> {
38+
await this.fetch('/accounts');
39+
}
40+
}

0 commit comments

Comments
 (0)