Skip to content

Commit c184fd8

Browse files
committed
feat(setup/harden): install pm2 for the deploy user, verify boot unit
When setup bootstraps a deploy user, install mise/node/pm2 into that user's home (via sudo -u <user> bash -lc) so pm2 processes are owned by them and \`pm2-<user>.service\` matches. \`pm2 startup\` runs as root but targets the deploy user; \`pm2 save\` runs as the deploy user so their dump.pm2 is the one systemd resurrects at boot. Add a "PM2 boot resurrection" section to harden: verifies \`pm2-\${ssh.user}.service\` is installed, offers to refresh its dump, and offers to disable any stale \`pm2-*.service\` units left over from a prior root-scoped setup after switching ssh.user.
1 parent db50f78 commit c184fd8

2 files changed

Lines changed: 81 additions & 13 deletions

File tree

src/cli/commands/harden.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import * as sec from '../../infrastructure/provisioning/security.js';
66
export async function cmdHarden(cwd: string, options: { config?: string }): Promise<void> {
77
await runRemoteCommand(
88
cwd,
9-
async ({ executor }) => {
9+
async ({ config, executor }) => {
1010
const changes: string[] = [];
11+
const currentUser = config.ssh.user;
1112

1213
ui.heading('SSH Hardening');
1314
const sshActive = (await executor.exec(sec.sshCheckActiveCommand())).stdout.includes('active');
@@ -57,6 +58,43 @@ export async function cmdHarden(cwd: string, options: { config?: string }): Prom
5758
changes.push('UFW: configured (SSH, 80, 443 allowed)');
5859
}
5960

61+
ui.heading('PM2 boot resurrection');
62+
// List installed pm2 systemd units so we can spot stale ones from a previous
63+
// root-scoped setup after switching to a deploy user.
64+
const unitsResult = await executor.exec(
65+
`systemctl list-unit-files 'pm2-*.service' --no-legend 2>/dev/null | awk '{print $1}' || true`,
66+
);
67+
const units = unitsResult.stdout.trim().split('\n').filter(Boolean);
68+
const wanted = `pm2-${currentUser}.service`;
69+
const stale = units.filter((u) => u !== wanted);
70+
71+
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)?`)) {
75+
await executor.exec(
76+
`bash -lc 'export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH" && pm2 save --force' || true`,
77+
);
78+
ui.success('pm2 save done');
79+
changes.push(`PM2: refreshed dump for ${currentUser}`);
80+
}
81+
} else {
82+
ui.warn(`No ${wanted} found. If you switched ssh.user recently, re-run 'shipnode setup' as the new user or install pm2 startup manually.`);
83+
}
84+
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');
95+
}
96+
}
97+
6098
ui.heading('Fail2ban');
6199
const f2b = (await executor.exec(sec.fail2banCheckActiveCommand())).stdout;
62100
if (!f2b.includes('ACTIVE')) {

src/cli/commands/setup.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,23 @@ async function bootstrapDeployUser(
7171
return true;
7272
}
7373

74+
/**
75+
* Wrap a command so it runs as `user` when set, otherwise as the current SSH user.
76+
* `bash -lc` gives the target user a login shell so $HOME/$PATH resolve to theirs.
77+
*/
78+
function asUser(user: string | null, cmd: string): string {
79+
if (!user) return cmd;
80+
const escaped = cmd.replace(/'/g, "'\\''");
81+
return `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; $SUDO -u "${user}" bash -lc '${escaped}'`;
82+
}
83+
7484
function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser: string | null) {
7585
const nodeVersion = config.nodeVersion === 'lts' ? '24' : config.nodeVersion;
7686
const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"`;
87+
// When a deploy user was bootstrapped, install mise/node/pm2 into their home
88+
// so PM2 processes run as that user and `pm2-<user>.service` matches. Without
89+
// this, everything lands in root's home and deploy has no pm2 on their PATH.
90+
const targetHome = ownerUser ? `/home/${ownerUser}` : '$HOME';
7791

7892
return new Listr(
7993
[
@@ -96,22 +110,25 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
96110
], { concurrent: false }),
97111
},
98112
{
99-
title: 'Mise (version manager)',
113+
title: `Mise (version manager)${ownerUser ? ` for ${ownerUser}` : ''}`,
100114
task: () =>
101115
executor.execOrThrow(
102-
`if ! command -v mise &>/dev/null; then curl -fsSL https://mise.run | sh; fi`,
116+
asUser(
117+
ownerUser,
118+
`if ! command -v mise &>/dev/null && [ ! -x "$HOME/.local/bin/mise" ]; then curl -fsSL https://mise.run | sh; fi`,
119+
),
103120
),
104121
},
105122
{
106123
title: `Node.js ${config.nodeVersion}`,
107124
task: (_ctx: object, task: any) => task.newListr([
108125
{
109126
title: `Install node@${nodeVersion}`,
110-
task: () => executor.execOrThrow(`${mise}; mise install -y "node@${nodeVersion}"`),
127+
task: () => executor.execOrThrow(asUser(ownerUser, `${mise}; mise install -y "node@${nodeVersion}"`)),
111128
},
112129
{
113130
title: `Set node@${nodeVersion} as global default`,
114-
task: () => executor.execOrThrow(`${mise}; mise use -g -y "node@${nodeVersion}"`),
131+
task: () => executor.execOrThrow(asUser(ownerUser, `${mise}; mise use -g -y "node@${nodeVersion}"`)),
115132
},
116133
], { concurrent: false }),
117134
},
@@ -121,18 +138,31 @@ function buildTasks(executor: RemoteExecutor, config: ShipnodeConfig, ownerUser:
121138
{
122139
title: 'Install pm2',
123140
task: () => executor.execOrThrow(
124-
`${mise}; ` +
125-
`if ! mise exec "node@${nodeVersion}" -- pm2 --version &>/dev/null; then ` +
126-
` mise exec "node@${nodeVersion}" -- npm install -g pm2; ` +
127-
`fi`,
141+
asUser(
142+
ownerUser,
143+
`${mise}; ` +
144+
`if ! mise exec "node@${nodeVersion}" -- pm2 --version &>/dev/null; then ` +
145+
` mise exec "node@${nodeVersion}" -- npm install -g pm2; ` +
146+
`fi`,
147+
),
128148
),
129149
},
130150
{
131-
title: 'Configure systemd startup',
151+
// pm2 startup writes /etc/systemd/system/pm2-<user>.service — needs root
152+
// to write the unit + enable it, but the unit targets the deploy user.
153+
// pm2 save writes ~/.pm2/dump.pm2 for that user and must run as them.
154+
title: `Configure systemd startup (${ownerUser ?? '$USER'})`,
132155
task: () => executor.execOrThrow(
133-
`${mise}; ` +
134-
`mise exec "node@${nodeVersion}" -- pm2 startup systemd -u $USER --hp $HOME || true; ` +
135-
`mise exec "node@${nodeVersion}" -- pm2 save --force || true`,
156+
(ownerUser
157+
? `SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
158+
`PM2_BIN="${targetHome}/.local/share/mise/shims/pm2"; ` +
159+
`$SUDO env PATH="${targetHome}/.local/share/mise/shims:$PATH" "$PM2_BIN" startup systemd -u "${ownerUser}" --hp "${targetHome}" || true`
160+
: `${mise}; mise exec "node@${nodeVersion}" -- pm2 startup systemd -u $USER --hp $HOME || true`) +
161+
` && ` +
162+
asUser(
163+
ownerUser,
164+
`${mise}; mise exec "node@${nodeVersion}" -- pm2 save --force || true`,
165+
),
136166
),
137167
},
138168
], { concurrent: false }),

0 commit comments

Comments
 (0)