Skip to content

Commit f6cf785

Browse files
committed
Give apps an address for accessories on other servers
Docker networks are host-local, so --network connects nothing across machines and no connection string was ever generated — the app got whatever was in .env, and the shipnode default of localhost silently pointed at nothing once the database moved to its own box. Every app is now handed SHIPNODE_<ACCESSORY>_HOST for each accessory it depends on: 127.0.0.1 when co-located, the host's privateHost when not. The variable exists either way, so moving an accessory to its own server is a config change and not a code change. Declared env wins, which is the escape hatch for a managed database shipnode cannot see. Injection happens during config assembly because scoping to one app or one server drops the servers the dependencies are on — by then the address is unknowable. An accessory published only on loopback but consumed from another server is now a config error: no connection string can reach 127.0.0.1 on another machine. A missing privateHost stays a warning, since the user may be supplying the address themselves. harden gains the matching firewall rules — a non-standard replica port for the load balancer, and accessory ports opened to the specific privateHosts that consume them rather than to the internet. It also fans out over every server instead of only the first; its seven prompts are hoisted to one up-front answer set, which previously would have asked each question once per server with earlier servers already changed. Fixes deploy --dry-run --app <name> hiding cross-server dependency warnings: it resolved them against the scoped config, which no longer contains the accessory.
1 parent e4bb725 commit f6cf785

8 files changed

Lines changed: 643 additions & 84 deletions

File tree

src/cli/commands/deploy.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { type ServerTargetError } from '../../shared/result-errors.js';
1515
import { runDeployWatch } from './deploy-watch.js';
1616
import type { BuildLocation } from '../../domain/deploy/hot-sync.js';
1717
import { rollFleet, type FleetEvent } from '../../domain/deploy/fleet.js';
18+
import { accessoryHostVar } from '../../domain/networking.js';
1819
import { newReleaseId } from '../../domain/deploy/orchestrator.js';
1920
import { SshConnection } from '../../infrastructure/ssh/connection.js';
2021

@@ -47,7 +48,7 @@ export async function cmdDeploy(cwd: string, options: { dryRun?: boolean; skipBu
4748
process.exit(1);
4849
return;
4950
}
50-
printDryRun(targetConfig, options.skipBuild ?? false);
51+
printDryRun(targetConfig, options.skipBuild ?? false, config);
5152
return;
5253
}
5354

@@ -322,7 +323,12 @@ async function startWatch(
322323
}
323324
}
324325

325-
function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: boolean): string {
326+
function renderAppPlan(
327+
config: ShipnodeConfig,
328+
app: ShipnodeApp,
329+
skipBuild: boolean,
330+
workspace: ShipnodeConfig = config,
331+
): string {
326332
const namespace = app.pm2?.apps[0]?.name;
327333
const web = app.pm2?.apps.find((a) => a.port !== undefined);
328334

@@ -363,7 +369,7 @@ function renderAppPlan(config: ShipnodeConfig, app: ShipnodeApp, skipBuild: bool
363369
if (app.domain) serverRows.push(['Domain', app.domain]);
364370
if (app.dependsOn?.length) serverRows.push(['Depends on', app.dependsOn.join(', ')]);
365371

366-
const dependencyWarnings = renderDependencyWarnings(config, app);
372+
const dependencyWarnings = renderDependencyWarnings(workspace, app);
367373
const fleetWarnings = renderFleetWarnings(app, replicas);
368374

369375
const caddyPreview = renderCaddyPreview(config, app);
@@ -452,12 +458,18 @@ function renderDependencyWarnings(config: ShipnodeConfig, app: ShipnodeApp): str
452458
const [accessoryServer] = resolveServerNames(config, accessory.on);
453459
if (accessoryServer === undefined) continue;
454460
const strangers = appServers.filter((server) => server !== accessoryServer);
455-
if (strangers.length > 0) {
456-
warnings.push(
457-
`${name} runs on ${accessoryServer}; ${app.name} runs on ${strangers.join(', ')}. ` +
458-
`Confirm reachable networking.`,
459-
);
460-
}
461+
if (strangers.length === 0) continue;
462+
463+
const privateHost = config.servers[accessoryServer]?.privateHost;
464+
warnings.push(
465+
privateHost
466+
? `${name} runs on ${accessoryServer}; ${app.name} runs on ${strangers.join(', ')}. ` +
467+
`${accessoryHostVar(name)}=${privateHost} is set for ${app.name} — make sure your ` +
468+
`connection string reads it, and that ${accessoryServer} accepts the connection.`
469+
: `${name} runs on ${accessoryServer}; ${app.name} runs on ${strangers.join(', ')}, ` +
470+
`and ${accessoryServer} has no privateHost — shipnode has no address to hand ${app.name}. ` +
471+
`Add privateHost to ${accessoryServer}, or set the host yourself in ${app.envFile}.`,
472+
);
461473
}
462474
return warnings;
463475
}
@@ -488,7 +500,19 @@ function renderCaddyPreview(config: ShipnodeConfig, app: ShipnodeApp): string |
488500
return generateBackendCaddyfile(app, web.port);
489501
}
490502

491-
export function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
503+
/**
504+
* `workspace` is the *unscoped* config. `--app api` narrows `config` to that app
505+
* and the servers it runs on, which drops the server a cross-server accessory
506+
* lives on — and with it the warning that the app has to reach across the
507+
* network. The real deploy path already resolves dependencies against the whole
508+
* workspace; the preview has to as well or it is quietest exactly when it
509+
* matters most.
510+
*/
511+
export function printDryRun(
512+
config: ShipnodeConfig,
513+
skipBuild: boolean,
514+
workspace: ShipnodeConfig = config,
515+
): void {
492516
ui.banner();
493517

494518
const servers = getServerTargets(config).map((target) => `${target.name}=${target.ssh.user}@${target.ssh.host}:${target.ssh.port}`).join(', ');
@@ -503,7 +527,7 @@ export function printDryRun(config: ShipnodeConfig, skipBuild: boolean): void {
503527
// Per app, not per server: a fleet app runs on several servers and would
504528
// otherwise be printed once for each of them.
505529
const perApp = config.apps
506-
.map((app) => renderAppPlan(config, app, skipBuild))
530+
.map((app) => renderAppPlan(config, app, skipBuild, workspace))
507531
.join('\n\n');
508532

509533
const accessories = renderAccessoriesPlan(config);

src/cli/commands/harden.ts

Lines changed: 109 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,120 @@
11
import { confirm } from '../prompt.js';
2-
import { runRemoteCommand } from '../runner.js';
2+
import { runRemoteCommandForTargets } from '../runner.js';
33
import { ui } from '../ui.js';
44
import * as sec from '../../infrastructure/provisioning/security.js';
5+
import { fleetFirewallRules } from '../../domain/networking.js';
56

6-
export async function cmdHarden(cwd: string, options: { config?: string }): Promise<void> {
7-
await runRemoteCommand(
7+
/**
8+
* What the operator wants done, decided once.
9+
*
10+
* Hardening fans out over every server, and the prompts used to live inside that
11+
* loop — so a three-server workspace asked the same seven questions three times,
12+
* and answering "no" on the second server left the first already changed. The
13+
* answers are collected up front instead; each server then applies whatever its
14+
* own state makes applicable, and says what it skipped.
15+
*/
16+
interface HardenChoices {
17+
ssh: boolean;
18+
disableRootLogin: boolean;
19+
disablePasswordAuth: boolean;
20+
installUfw: boolean;
21+
configureUfw: boolean;
22+
pm2Save: boolean;
23+
disableStaleUnits: boolean;
24+
fail2ban: boolean;
25+
}
26+
27+
async function askChoices(serverCount: number): Promise<HardenChoices> {
28+
if (serverCount > 1) {
29+
ui.info(`These answers apply to all ${serverCount} servers in this workspace.`);
30+
}
31+
32+
ui.heading('SSH Hardening');
33+
const ssh = await confirm('Harden SSH?');
34+
const disableRootLogin = ssh && (await confirm('Disable root login?'));
35+
const disablePasswordAuth = ssh && (await confirm('Disable password auth (keys only)?'));
36+
37+
ui.heading('Firewall (UFW)');
38+
const installUfw = await confirm('Install UFW where it is missing?');
39+
const configureUfw = await confirm('Configure UFW (allow SSH/80/443 plus this workspace\'s own ports, deny all else)?');
40+
41+
ui.heading('PM2 boot resurrection');
42+
const pm2Save = await confirm('Refresh the saved PM2 process list (pm2 save)?');
43+
const disableStaleUnits = await confirm('Disable stale PM2 units so only the current user\'s resurrects at boot?');
44+
45+
ui.heading('Fail2ban');
46+
const fail2ban = await confirm('Install and configure fail2ban where it is inactive?');
47+
48+
return { ssh, disableRootLogin, disablePasswordAuth, installUfw, configureUfw, pm2Save, disableStaleUnits, fail2ban };
49+
}
50+
51+
export async function cmdHarden(cwd: string, options: { config?: string; on?: string }): Promise<void> {
52+
const { loadConfig } = await import('../../config/loader.js');
53+
const workspace = await loadConfig(cwd, options.config);
54+
const serverCount = options.on ? 1 : Object.keys(workspace.servers).length;
55+
56+
const choices = await askChoices(serverCount);
57+
if (!Object.values(choices).some(Boolean)) {
58+
ui.info('Nothing selected — no changes made.');
59+
return;
60+
}
61+
62+
await runRemoteCommandForTargets(
863
cwd,
9-
async ({ config, executor }) => {
64+
async ({ config, executor, serverName }) => {
1065
const changes: string[] = [];
1166
const currentUser = config.ssh.user;
1267

13-
ui.heading('SSH Hardening');
14-
const sshActive = (await executor.exec(sec.sshCheckActiveCommand())).stdout.includes('active');
15-
ui.info(`SSH service: ${sshActive ? 'active' : 'inactive'}`);
16-
const sshdResult = await executor.exec(sec.sshCheckConfigCommand());
17-
ui.info(`Current SSH config:\n ${sshdResult.stdout.split('\n').join('\n ')}`);
18-
19-
if (await confirm('Harden SSH?')) {
20-
const sudoUsers = (await executor.exec(sec.sshCheckSudoUsersCommand())).stdout.trim();
21-
if (sudoUsers) {
22-
ui.info(`Sudo users: ${sudoUsers.split('\n').join(', ')}`);
23-
if (await confirm('Disable root login?')) {
68+
ui.heading(`${serverName} (${currentUser}@${config.ssh.host})`);
69+
70+
if (choices.ssh) {
71+
const sshdResult = await executor.exec(sec.sshCheckConfigCommand());
72+
ui.info(`Current SSH config:\n ${sshdResult.stdout.split('\n').join('\n ')}`);
73+
74+
if (choices.disableRootLogin) {
75+
const sudoUsers = (await executor.exec(sec.sshCheckSudoUsersCommand())).stdout.trim();
76+
if (sudoUsers) {
2477
await executor.exec(sec.sshDisableRootLoginCommand());
2578
await executor.exec(sec.sshRestartCommand());
26-
ui.success('Root login disabled');
2779
changes.push('SSH: PermitRootLogin set to no');
80+
} else {
81+
ui.warn('No sudo users — skipping root disable (would lock you out).');
2882
}
29-
} else {
30-
ui.warn('No sudo users — skipping root disable (would lock you out).');
3183
}
32-
if (await confirm('Disable password auth (keys only)?')) {
84+
85+
if (choices.disablePasswordAuth) {
3386
await executor.exec(sec.sshDisablePasswordAuthCommand());
3487
await executor.exec(sec.sshRestartCommand());
35-
ui.success('Password auth disabled');
3688
changes.push('SSH: PasswordAuthentication set to no');
3789
}
3890
}
3991

40-
ui.heading('Firewall (UFW)');
4192
const ufwResult = await executor.exec(sec.ufwCheckInstalledCommand());
42-
if (ufwResult.stdout.includes('NOT_INSTALLED')) {
43-
ui.warn('UFW not installed.');
44-
if (await confirm('Install UFW?')) {
45-
await executor.exec(sec.ufwInstallCommand());
46-
ui.success('UFW installed');
47-
changes.push('UFW: installed');
48-
}
49-
} else {
50-
ui.info(`UFW status:\n ${ufwResult.stdout.split('\n').slice(0, 3).join('\n ')}`);
93+
const ufwMissing = ufwResult.stdout.includes('NOT_INSTALLED');
94+
if (ufwMissing && choices.installUfw) {
95+
await executor.exec(sec.ufwInstallCommand());
96+
changes.push('UFW: installed');
97+
} else if (ufwMissing) {
98+
ui.warn('UFW not installed — skipping firewall configuration.');
5199
}
52100

53-
if (await confirm('Configure UFW (allow SSH/80/443, deny all else)?')) {
54-
for (const cmd of sec.ufwConfigureCommands()) {
101+
if (choices.configureUfw && (!ufwMissing || choices.installUfw)) {
102+
// Rules derived from the workspace, not from this server alone: which
103+
// holes this box needs depends on where the apps that consume it run.
104+
const extra = fleetFirewallRules(workspace, serverName);
105+
for (const cmd of sec.ufwConfigureCommands(extra)) {
55106
await executor.exec(cmd);
56107
}
57-
ui.success('UFW configured and enabled');
58-
changes.push('UFW: configured (SSH, 80, 443 allowed)');
108+
changes.push(
109+
extra.length === 0
110+
? 'UFW: configured (SSH, 80, 443 allowed)'
111+
: `UFW: configured (SSH, 80, 443, plus ${extra.length} workspace rule(s))`,
112+
);
113+
for (const rule of extra) {
114+
ui.info(` allowed ${rule.port}/tcp${rule.from ? ` from ${rule.from}` : ''}${rule.comment}`);
115+
}
59116
}
60117

61-
ui.heading('PM2 boot resurrection');
62118
// List installed pm2 systemd units so we can spot stale ones from a previous
63119
// root-scoped setup after switching to a deploy user.
64120
const unitsResult = await executor.exec(
@@ -69,54 +125,47 @@ export async function cmdHarden(cwd: string, options: { config?: string }): Prom
69125
const stale = units.filter((u) => u !== wanted);
70126

71127
if (units.includes(wanted)) {
72-
ui.success(`${wanted} is installed`);
73-
// Refresh the resurrection dump so the current process list is what boots.
74-
if (await confirm(`Refresh ${wanted}'s saved process list (pm2 save)?`)) {
128+
if (choices.pm2Save) {
129+
// Refresh the resurrection dump so the current process list is what boots.
75130
await executor.exec(
76131
`bash -lc 'export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" && pm2 save --force' || true`,
77132
);
78-
ui.success('pm2 save done');
79133
changes.push(`PM2: refreshed dump for ${currentUser}`);
80134
}
81135
} else {
82136
ui.warn(`No ${wanted} found. If you switched ssh.user recently, re-run 'shipnode setup' as the new user or install pm2 startup manually.`);
83137
}
84138

85-
if (stale.length > 0) {
86-
ui.warn(`Stale PM2 units detected: ${stale.join(', ')}`);
87-
if (await confirm(`Disable stale unit(s) so only ${wanted} resurrects at boot?`)) {
88-
for (const unit of stale) {
89-
await executor.exec(
90-
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO systemctl disable --now "${unit}" 2>/dev/null || true`,
91-
);
92-
changes.push(`PM2: disabled ${unit}`);
93-
}
94-
ui.success('Stale units disabled');
139+
if (stale.length > 0 && choices.disableStaleUnits) {
140+
for (const unit of stale) {
141+
await executor.exec(
142+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO systemctl disable --now "${unit}" 2>/dev/null || true`,
143+
);
144+
changes.push(`PM2: disabled ${unit}`);
95145
}
146+
} else if (stale.length > 0) {
147+
ui.warn(`Stale PM2 units left in place: ${stale.join(', ')}`);
96148
}
97149

98-
ui.heading('Fail2ban');
99-
const f2b = (await executor.exec(sec.fail2banCheckActiveCommand())).stdout;
100-
if (!sec.isFail2banActive(f2b)) {
101-
ui.warn('Fail2ban not active.');
102-
if (await confirm('Install and configure fail2ban?')) {
150+
if (choices.fail2ban) {
151+
const f2b = (await executor.exec(sec.fail2banCheckActiveCommand())).stdout;
152+
if (!sec.isFail2banActive(f2b)) {
103153
await executor.execOrThrow(sec.fail2banInstallCommand());
104154
await executor.execOrThrow(sec.fail2banApplyConfigCommand());
105155
await executor.execOrThrow(sec.fail2banEnableCommand());
106-
ui.success('Fail2ban installed');
107156
changes.push('Fail2ban: installed, sshd jail enabled');
157+
} else {
158+
ui.success('Fail2ban already active');
108159
}
109-
} else {
110-
ui.success('Fail2ban already active');
111160
}
112161

113162
if (changes.length === 0) {
114-
ui.info('No changes made.');
163+
ui.info(`${serverName}: no changes made.`);
115164
} else {
116165
changes.forEach((c) => ui.success(c));
117-
ui.info(`${changes.length} change(s) applied.`);
166+
ui.info(`${serverName}: ${changes.length} change(s) applied.`);
118167
}
119168
},
120-
{ configPath: options.config },
169+
{ configPath: options.config, serverName: options.on },
121170
);
122171
}

src/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ program
181181
program
182182
.command('harden')
183183
.description('Apply server security hardening (SSH, firewall, fail2ban)')
184+
.option('--on <server>', 'Harden one server instead of every server in the workspace')
184185
.option('--config <path>', 'Use a specific config file')
185186
.action((opts) => cmdHarden(process.cwd(), opts));
186187

0 commit comments

Comments
 (0)