11import { confirm } from '../prompt.js' ;
2- import { runRemoteCommand } from '../runner.js' ;
2+ import { runRemoteCommandForTargets } from '../runner.js' ;
33import { ui } from '../ui.js' ;
44import * 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}
0 commit comments