Skip to content

Commit 0fc8902

Browse files
committed
feat(cloudflare): multi-hostname tunnel via domain/cloudflare model (sprint 4)
1 parent 29cbe1a commit 0fc8902

8 files changed

Lines changed: 213 additions & 49 deletions

File tree

src/cli/commands/cloudflare.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ export async function cmdCloudflareAudit(
3030
const orchestrator = new CloudflareOrchestrator(executor, config, requireToken());
3131
const result = await orchestrator.audit();
3232
console.log(` Zone: ${result.zone.name} (${result.zone.id}) — ${result.zone.status}`);
33-
if (result.dns) console.log(` DNS: ${result.dns}`);
33+
for (const app of result.apps) {
34+
console.log(` DNS ${app.domain} → localhost:${app.port}`);
35+
}
3436
console.log(` cloudflared tunnels:\n${result.tunnelList}`);
3537
console.log(` cloudflared service: ${result.service}`);
3638
}, { configPath: options.config });

src/config/schema.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ export const BackupConfigSchema = z.object({
6969

7070
export const CloudflareConfigSchema = z.object({
7171
zone: z.string().min(1, 'Cloudflare zone is required'),
72-
appHostname: z.string().optional(),
7372
sshHostname: z.string().optional(),
7473
tunnelName: z.string().optional(),
7574
lockdownFirewall: z.boolean().default(false),

src/domain/cloudflare/ingress.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface Ingress {
2+
hostname?: string;
3+
service: string;
4+
}

src/domain/cloudflare/tunnel.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Ingress } from './ingress.js';
2+
3+
export class Tunnel {
4+
constructor(
5+
public name: string,
6+
public id?: string,
7+
public credentialsPath?: string,
8+
public ingress: Ingress[] = [],
9+
public catchAll: string = 'http_status:404',
10+
) {}
11+
12+
addIngress(hostname: string | undefined, service: string): void {
13+
if (!hostname) return;
14+
const existing = this.ingress.findIndex(
15+
(e) => e.hostname === hostname,
16+
);
17+
if (existing !== -1) {
18+
this.ingress[existing].service = service;
19+
} else {
20+
this.ingress.push({ hostname, service });
21+
}
22+
}
23+
24+
removeIngress(hostname: string): void {
25+
this.ingress = this.ingress.filter((e) => e.hostname !== hostname);
26+
}
27+
28+
toYaml(): string {
29+
const lines: string[] = [];
30+
if (this.id) {
31+
lines.push(`tunnel: ${this.id}`);
32+
}
33+
if (this.credentialsPath) {
34+
lines.push(`credentials-file: ${this.credentialsPath}`);
35+
}
36+
lines.push('');
37+
lines.push('ingress:');
38+
39+
const sorted = [...this.ingress].sort((a, b) => {
40+
const ha = a.hostname ?? '';
41+
const hb = b.hostname ?? '';
42+
return ha.localeCompare(hb);
43+
});
44+
45+
for (const entry of sorted) {
46+
if (entry.hostname) {
47+
lines.push(` - hostname: ${entry.hostname}`);
48+
lines.push(` service: ${entry.service}`);
49+
}
50+
}
51+
lines.push(` - service: ${this.catchAll}`);
52+
lines.push('');
53+
54+
return lines.join('\n');
55+
}
56+
57+
static fromYaml(content: string): Tunnel {
58+
const tunnel = new Tunnel('');
59+
const lines = content.split('\n');
60+
61+
let inIngress = false;
62+
63+
for (const raw of lines) {
64+
const line = raw.trimEnd();
65+
const trimmed = line.trimStart();
66+
if (line.startsWith('tunnel: ')) {
67+
tunnel.id = line.slice('tunnel: '.length);
68+
} else if (line.startsWith('credentials-file: ')) {
69+
tunnel.credentialsPath = line.slice('credentials-file: '.length);
70+
} else if (trimmed === 'ingress:') {
71+
inIngress = true;
72+
} else if (inIngress && trimmed.startsWith('- hostname: ')) {
73+
const hostname = trimmed.slice('- hostname: '.length);
74+
tunnel.ingress.push({ hostname, service: '' });
75+
} else if (inIngress && trimmed.startsWith('service: ') && tunnel.ingress.length > 0) {
76+
const last = tunnel.ingress[tunnel.ingress.length - 1];
77+
if (last.service === '') {
78+
last.service = trimmed.slice('service: '.length);
79+
}
80+
} else if (inIngress && trimmed.startsWith('- service: ')) {
81+
tunnel.catchAll = trimmed.slice('- service: '.length);
82+
}
83+
}
84+
85+
return tunnel;
86+
}
87+
}

src/services/cloudflare.orchestrator.ts

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ShipnodeConfig } from '../shared/types.js';
22
import type { RemoteExecutor } from '../domain/remote/executor.js';
33
import { CloudflareApi } from '../infrastructure/cloudflare/api.js';
4-
import { getWebApp } from '../domain/pm2/apps.js';
4+
import { Tunnel } from '../domain/cloudflare/tunnel.js';
55

66
export class CloudflareOrchestrator {
77
private api: CloudflareApi;
@@ -19,21 +19,27 @@ export class CloudflareOrchestrator {
1919
if (!cf) throw new Error('No cloudflare config found.');
2020

2121
await this.ensureCloudflaredInstalled();
22-
2322
const tunnelName = cf.tunnelName ?? `shipnode-${this.config.ssh.host.replace(/\./g, '-')}`;
2423

2524
await this.executor.exec(`cloudflared tunnel create "${tunnelName}" 2>&1`);
2625
const tunnelId = await this.resolveTunnelId();
2726

28-
if (cf.appHostname) {
29-
await this.executor.exec(`cloudflared tunnel route dns "${tunnelName}" "${cf.appHostname}"`);
27+
const tunnel = new Tunnel(tunnelName, tunnelId, `/root/.cloudflared/${tunnelId}.json`);
28+
29+
for (const app of this.config.apps) {
30+
const webApp = app.pm2?.apps.find((a) => a.port !== undefined);
31+
if (app.domain && webApp) {
32+
tunnel.addIngress(app.domain, `http://localhost:${webApp.port}`);
33+
await this.executor.exec(`cloudflared tunnel route dns "${tunnelName}" "${app.domain}"`);
34+
}
3035
}
3136

3237
if (cf.sshHostname) {
38+
tunnel.addIngress(cf.sshHostname, 'ssh://localhost:22');
3339
await this.executor.exec(`cloudflared tunnel route dns "${tunnelName}" "${cf.sshHostname}"`);
3440
}
3541

36-
await this.writeTunnelConfig(tunnelId, cf);
42+
await this.writeConfig(tunnel);
3743
await this.installAndStartService();
3844

3945
if (cf.lockdownFirewall) {
@@ -45,18 +51,19 @@ export class CloudflareOrchestrator {
4551
}
4652
}
4753

48-
async audit(): Promise<{ zone: { name: string; id: string; status: string }; dns: string; tunnelList: string; service: string }> {
54+
async audit(): Promise<{ zone: { name: string; id: string; status: string }; apps: { domain: string; port: number | undefined }[]; tunnelList: string; service: string }> {
4955
const cf = this.config.cloudflare;
5056
if (!cf) throw new Error('No cloudflare config found.');
5157

5258
const zoneId = await this.api.getZoneId(cf.zone);
5359
const zoneInfo = await this.api.fetch<{ id: string; name: string; status: string }>(`/zones/${zoneId}`);
5460

55-
let dns = '';
56-
if (cf.appHostname) {
57-
const records = await this.api.getDnsRecords(zoneId, cf.appHostname);
58-
dns = records.length ? `${records[0].type}${records[0].content}` : 'NOT FOUND';
59-
}
61+
const apps = this.config.apps
62+
.map((a) => {
63+
const web = a.pm2?.apps.find((p) => p.port !== undefined);
64+
return web && a.domain ? { domain: a.domain, port: web.port } : null;
65+
})
66+
.filter((x): x is NonNullable<typeof x> => x !== null);
6067

6168
const tunnelResult = await this.executor.exec('cloudflared tunnel list 2>/dev/null || echo MISSING');
6269
const tunnelList = tunnelResult.stdout.includes('MISSING') ? 'not installed' : tunnelResult.stdout.trim();
@@ -65,7 +72,7 @@ export class CloudflareOrchestrator {
6572

6673
return {
6774
zone: { name: zoneInfo.name, id: zoneInfo.id, status: zoneInfo.status },
68-
dns,
75+
apps,
6976
tunnelList,
7077
service: svcResult.stdout.trim(),
7178
};
@@ -100,28 +107,9 @@ export class CloudflareOrchestrator {
100107
return tunnelId;
101108
}
102109

103-
private async writeTunnelConfig(tunnelId: string, cf: NonNullable<ShipnodeConfig['cloudflare']>): Promise<void> {
104-
const webApp = getWebApp(this.config);
105-
if (cf.appHostname && !webApp) {
106-
throw new Error('cloudflare.appHostname is set but no pm2.apps entry declares a port — nothing to route HTTP to.');
107-
}
108-
const appIngress = cf.appHostname && webApp
109-
? ` - hostname: ${cf.appHostname}\n service: http://localhost:${webApp.port}`
110-
: '';
111-
const sshIngress = cf.sshHostname
112-
? ` - hostname: ${cf.sshHostname}\n service: ssh://localhost:22`
113-
: '';
114-
115-
const cfConfig = `tunnel: ${tunnelId}
116-
credentials-file: /root/.cloudflared/${tunnelId}.json
117-
118-
ingress:
119-
${appIngress}
120-
${sshIngress}
121-
- service: http_status:404
122-
`;
123-
124-
const b64 = Buffer.from(cfConfig).toString('base64');
110+
private async writeConfig(tunnel: Tunnel): Promise<void> {
111+
const yaml = tunnel.toYaml();
112+
const b64 = Buffer.from(yaml).toString('base64');
125113
await this.executor.exec(
126114
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
127115
`mkdir -p /etc/cloudflared && ` +
@@ -137,19 +125,25 @@ ${sshIngress}
137125
}
138126

139127
private async lockdownFirewall(): Promise<void> {
140-
const webApp = getWebApp(this.config);
141-
if (!webApp) return;
128+
const webPorts = this.config.apps
129+
.flatMap((a) => a.pm2?.apps ?? [])
130+
.filter((a) => a.port !== undefined)
131+
.map((a) => a.port!);
132+
133+
if (!webPorts.length) return;
142134

143135
const cfIps = await this.api.getCloudflareIps();
144-
for (const cidr of cfIps.ipv4_cidrs ?? []) {
136+
for (const port of webPorts) {
137+
for (const cidr of cfIps.ipv4_cidrs ?? []) {
138+
await this.executor.exec(
139+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
140+
`$SUDO ufw allow from ${cidr} to any port ${port} 2>/dev/null || true`,
141+
);
142+
}
145143
await this.executor.exec(
146144
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
147-
`$SUDO ufw allow from ${cidr} to any port ${webApp.port} 2>/dev/null || true`,
145+
`$SUDO ufw deny ${port} 2>/dev/null || true`,
148146
);
149147
}
150-
await this.executor.exec(
151-
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
152-
`$SUDO ufw deny ${webApp.port} 2>/dev/null || true`,
153-
);
154148
}
155149
}

src/shared/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ export interface BackupConfig {
6666

6767
export interface CloudflareConfig {
6868
zone: string;
69-
appHostname?: string;
7069
sshHostname?: string;
7170
tunnelName?: string;
7271
lockdownFirewall?: boolean;

tests/unit/builder.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe('ShipnodeBuilder', () => {
2929
.database({ type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p' })
3030
.redis({ host: 'localhost', port: 6379, password: 'rp' })
3131
.backup({ s3Bucket: 'backups', s3Prefix: 'prod', schedule: 'daily', retentionDays: 30 })
32-
.cloudflare({ zone: 'example.com', appHostname: 'api.example.com', tunnelName: 't', lockdownFirewall: true })
32+
.cloudflare({ zone: 'example.com', tunnelName: 't', lockdownFirewall: true })
3333
.preDeploy(preDeploy)
3434
.postDeploy(postDeploy)
3535
.aliases({ migrate: 'pnpm db:apply', seed: 'pnpm db:seed' })
@@ -55,7 +55,7 @@ describe('ShipnodeBuilder', () => {
5555
expect(config.database).toMatchObject({ type: 'postgres', host: 'localhost', port: 5432, name: 'db', user: 'u', password: 'p' });
5656
expect(config.redis).toEqual({ host: 'localhost', port: 6379, password: 'rp' });
5757
expect(config.backup).toMatchObject({ s3Bucket: 'backups', s3Prefix: 'prod', schedule: 'daily', retentionDays: 30 });
58-
expect(config.cloudflare).toMatchObject({ zone: 'example.com', appHostname: 'api.example.com', tunnelName: 't', lockdownFirewall: true });
58+
expect(config.cloudflare).toMatchObject({ zone: 'example.com', tunnelName: 't', lockdownFirewall: true });
5959
expect(config.apps[0].hooks?.preDeploy).toBe(preDeploy);
6060
expect(config.apps[0].hooks?.postDeploy).toBe(postDeploy);
6161
expect(config.aliases).toEqual({ migrate: 'pnpm db:apply', seed: 'pnpm db:seed' });
@@ -316,11 +316,10 @@ describe('ShipnodeBuilder', () => {
316316
.backend()
317317
.ssh({ host: '1.2.3.4', user: 'deploy' })
318318
.deployTo('/var/www/app')
319-
.cloudflare({ zone: 'example.com', appHostname: 'app.example.com', lockdownFirewall: true })
319+
.cloudflare({ zone: 'example.com', tunnelName: 'app', lockdownFirewall: true })
320320
.build();
321321

322322
expect(config.cloudflare?.zone).toBe('example.com');
323-
expect(config.cloudflare?.appHostname).toBe('app.example.com');
324323
expect(config.cloudflare?.lockdownFirewall).toBe(true);
325324
});
326325
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { Tunnel } from '../../src/domain/cloudflare/tunnel.js';
3+
4+
const sampleYaml = `tunnel: abc123
5+
credentials-file: /root/.cloudflared/abc123.json
6+
7+
ingress:
8+
- hostname: api.example.com
9+
service: http://localhost:3000
10+
- hostname: ssh.example.com
11+
service: ssh://localhost:22
12+
- service: http_status:404
13+
`;
14+
15+
describe('Tunnel', () => {
16+
it('round-trips YAML', () => {
17+
const tunnel = Tunnel.fromYaml(sampleYaml);
18+
expect(tunnel.id).toBe('abc123');
19+
expect(tunnel.credentialsPath).toBe('/root/.cloudflared/abc123.json');
20+
expect(tunnel.ingress).toHaveLength(2);
21+
expect(tunnel.ingress[0]).toEqual({ hostname: 'api.example.com', service: 'http://localhost:3000' });
22+
expect(tunnel.ingress[1]).toEqual({ hostname: 'ssh.example.com', service: 'ssh://localhost:22' });
23+
expect(tunnel.catchAll).toBe('http_status:404');
24+
25+
const output = tunnel.toYaml();
26+
expect(output).toBe(sampleYaml);
27+
});
28+
29+
it('adds an ingress entry', () => {
30+
const tunnel = Tunnel.fromYaml(sampleYaml);
31+
tunnel.addIngress('admin.example.com', 'http://localhost:4000');
32+
expect(tunnel.ingress).toHaveLength(3);
33+
});
34+
35+
it('replaces an existing ingress entry by hostname', () => {
36+
const tunnel = Tunnel.fromYaml(sampleYaml);
37+
tunnel.addIngress('api.example.com', 'http://localhost:5000');
38+
expect(tunnel.ingress).toHaveLength(2);
39+
const api = tunnel.ingress.find((i) => i.hostname === 'api.example.com');
40+
expect(api?.service).toBe('http://localhost:5000');
41+
});
42+
43+
it('skips addIngress when hostname is empty', () => {
44+
const tunnel = Tunnel.fromYaml(sampleYaml);
45+
tunnel.addIngress(undefined, 'http://localhost:4000');
46+
expect(tunnel.ingress).toHaveLength(2);
47+
});
48+
49+
it('removes an ingress entry', () => {
50+
const tunnel = Tunnel.fromYaml(sampleYaml);
51+
tunnel.removeIngress('ssh.example.com');
52+
expect(tunnel.ingress).toHaveLength(1);
53+
expect(tunnel.ingress[0].hostname).toBe('api.example.com');
54+
});
55+
56+
it('sorts ingress entries by hostname', () => {
57+
const tunnel = new Tunnel('test', 'abc', '/creds');
58+
tunnel.addIngress('z.example.com', 'http://localhost:3000');
59+
tunnel.addIngress('a.example.com', 'http://localhost:4000');
60+
tunnel.addIngress('m.example.com', 'http://localhost:5000');
61+
62+
const yaml = tunnel.toYaml();
63+
const lines = yaml.split('\n');
64+
const hostnameLines = lines.filter((l) => l.startsWith(' - hostname:'));
65+
expect(hostnameLines[0]).toContain('a.example.com');
66+
expect(hostnameLines[1]).toContain('m.example.com');
67+
expect(hostnameLines[2]).toContain('z.example.com');
68+
});
69+
70+
it('serializes without id or credentials', () => {
71+
const tunnel = new Tunnel('test');
72+
tunnel.addIngress('app.example.com', 'http://localhost:8080');
73+
74+
const yaml = tunnel.toYaml();
75+
expect(yaml).not.toContain('tunnel:');
76+
expect(yaml).not.toContain('credentials-file:');
77+
expect(yaml).toContain('app.example.com');
78+
expect(yaml).toContain('http_status:404');
79+
});
80+
});

0 commit comments

Comments
 (0)