vps-harden 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/cli.js +656 -0
  4. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DukeDeSouth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # vps-harden
2
+
3
+ Secure your VPS in 2 minutes. One command, zero knowledge required.
4
+
5
+ ```
6
+ npx vps-harden 194.68.0.12
7
+ ```
8
+
9
+ ```
10
+ [vps-harden]
11
+ Target: 194.68.0.12
12
+
13
+ ✓ 1. Generate SSH key ~/.ssh/vps_194_68_0_12_ed25519
14
+ ✓ 2. Copy key to server root@194.68.0.12
15
+ ✓ 3. Create user deploy (sudo)
16
+ ✓ 4. Change SSH port 22 → 2222
17
+ ✓ 5. Verify new port deploy@194.68.0.12:2222 OK
18
+ ✓ 6. Close port 22 Port 22 closed
19
+ ✓ 7. Disable password auth Password auth disabled
20
+ ✓ 8. Disable root login Root login disabled
21
+ ✓ 9. Setup firewall ufw on (2222, 80, 443)
22
+ ✓ 10. Install fail2ban fail2ban on port 2222
23
+
24
+ ════════════════════════════════════════════════════
25
+ ✓ Server hardened successfully!
26
+
27
+ Connect with:
28
+ ssh -i ~/.ssh/vps_194_68_0_12_ed25519 -p 2222 deploy@194.68.0.12
29
+
30
+ Save this command. Port 22 is closed.
31
+ ════════════════════════════════════════════════════
32
+ ```
33
+
34
+ ## What it does
35
+
36
+ 1. **Generates SSH key** locally (ed25519, named after your server)
37
+ 2. **Copies key** to server via password auth
38
+ 3. **Creates non-root user** with passwordless sudo
39
+ 4. **Changes SSH port** (default: 2222)
40
+ 5. **Verifies new port works** before closing old one
41
+ 6. **Closes port 22** — only after verification
42
+ 7. **Disables password auth** — key-only access
43
+ 8. **Disables root login** — use your new user
44
+ 9. **Sets up UFW firewall** — allows SSH, HTTP, HTTPS
45
+ 10. **Installs fail2ban** — brute-force protection
46
+
47
+ ## Safety first
48
+
49
+ The tool never locks you out:
50
+
51
+ - Port 22 stays open until the new port is verified
52
+ - Password auth stays on until key auth is confirmed
53
+ - Every step has automatic rollback on failure
54
+ - If anything fails, previous steps are undone in reverse order
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ # Run instantly (no install)
60
+ npx vps-harden 194.68.0.12
61
+
62
+ # Or install globally
63
+ npm i -g vps-harden
64
+ ```
65
+
66
+ ## Options
67
+
68
+ ```bash
69
+ # Custom username and port
70
+ vps-harden 194.68.0.12 --username admin --port 3322
71
+
72
+ # Use existing SSH key
73
+ vps-harden 194.68.0.12 --key ~/.ssh/id_ed25519
74
+
75
+ # Preview without making changes
76
+ vps-harden 194.68.0.12 --dry-run
77
+
78
+ # Non-interactive (CI/scripts)
79
+ vps-harden 194.68.0.12 --password "rootpass" --username deploy
80
+ ```
81
+
82
+ | Flag | Default | Description |
83
+ |------|---------|-------------|
84
+ | `--password` | prompt | Root password |
85
+ | `--username` | `deploy` | New username |
86
+ | `--port` | `2222` | New SSH port |
87
+ | `--key` | auto-generate | Existing SSH key path |
88
+ | `--dry-run` | `false` | Preview without changes |
89
+
90
+ ## Requirements
91
+
92
+ - Node.js 18+
93
+ - macOS or Linux (local machine)
94
+ - Ubuntu/Debian VPS with root SSH access
95
+
96
+ ## After hardening
97
+
98
+ Your server is now protected with:
99
+
100
+ - Non-standard SSH port (bots scan port 22)
101
+ - Key-only authentication (no password brute-force)
102
+ - Non-root user (limits damage if compromised)
103
+ - UFW firewall (only SSH, HTTP, HTTPS open)
104
+ - fail2ban (auto-bans repeated failed logins)
105
+
106
+ ## Why not a bash script?
107
+
108
+ | | vps-harden | Bash scripts |
109
+ |---|---|---|
110
+ | Install | `npx` (instant) | `curl \| bash` (scary) |
111
+ | Runs from | Your machine | On the server |
112
+ | SSH key gen | Automatic | Manual |
113
+ | Safety | Verify before close | Hope for the best |
114
+ | Rollback | Automatic | None |
115
+ | UI | Interactive TUI | Raw output |
116
+
117
+ ## License
118
+
119
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,656 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.tsx
4
+ import { render } from "ink";
5
+ import meow from "meow";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import { createInterface } from "readline";
9
+
10
+ // src/app.tsx
11
+ import { useState, useEffect } from "react";
12
+ import { Box as Box3, Text as Text3 } from "ink";
13
+
14
+ // src/components/Banner.tsx
15
+ import { Box, Text } from "ink";
16
+ import { jsx, jsxs } from "react/jsx-runtime";
17
+ function Banner({ host: host2, dryRun }) {
18
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
19
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "[vps-harden]" }),
20
+ /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
21
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Target:" }),
22
+ /* @__PURE__ */ jsx(Text, { bold: true, children: host2 }),
23
+ dryRun && /* @__PURE__ */ jsx(Text, { color: "yellow", bold: true, children: " [DRY RUN]" })
24
+ ] })
25
+ ] });
26
+ }
27
+ function Result({ host: host2, port, username, keyPath, success }) {
28
+ if (!success) {
29
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
30
+ /* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: " \u2717 Hardening failed. Check errors above." }),
31
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " Port 22 should still be open \u2014 your server is accessible." })
32
+ ] });
33
+ }
34
+ const sshCmd = `ssh -i ${keyPath} -p ${port} ${username}@${host2}`;
35
+ const configBlock = [
36
+ `Host ${host2.replace(/\./g, "-")}`,
37
+ ` HostName ${host2}`,
38
+ ` User ${username}`,
39
+ ` Port ${port}`,
40
+ ` IdentityFile ${keyPath}`
41
+ ].join("\n");
42
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
43
+ /* @__PURE__ */ jsxs(Text, { children: [
44
+ " ",
45
+ "\u2550".repeat(52)
46
+ ] }),
47
+ /* @__PURE__ */ jsxs(Text, { color: "green", bold: true, children: [
48
+ " ",
49
+ "\u2713 Server hardened successfully!"
50
+ ] }),
51
+ /* @__PURE__ */ jsx(Text, { children: "" }),
52
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
53
+ " ",
54
+ "Connect with:"
55
+ ] }),
56
+ /* @__PURE__ */ jsxs(Text, { bold: true, color: "cyan", children: [
57
+ " ",
58
+ sshCmd
59
+ ] }),
60
+ /* @__PURE__ */ jsx(Text, { children: "" }),
61
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
62
+ " ",
63
+ "Save this command. Port 22 is closed."
64
+ ] }),
65
+ /* @__PURE__ */ jsxs(Text, { children: [
66
+ " ",
67
+ "\u2550".repeat(52)
68
+ ] }),
69
+ /* @__PURE__ */ jsx(Text, { children: "" }),
70
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
71
+ " ",
72
+ "Add to ~/.ssh/config:"
73
+ ] }),
74
+ configBlock.split("\n").map((line, i) => /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
75
+ " ",
76
+ line
77
+ ] }, i))
78
+ ] });
79
+ }
80
+
81
+ // src/components/StepList.tsx
82
+ import { Box as Box2, Text as Text2 } from "ink";
83
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
84
+ var STATUS_ICON = {
85
+ pending: { icon: "\u25CB", color: "gray" },
86
+ running: { icon: "\u25CF", color: "cyan" },
87
+ done: { icon: "\u2713", color: "green" },
88
+ failed: { icon: "\u2717", color: "red" },
89
+ skipped: { icon: "~", color: "yellow" }
90
+ };
91
+ function StepList({ steps }) {
92
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: steps.map((step, i) => {
93
+ const { icon, color } = STATUS_ICON[step.status] ?? STATUS_ICON.pending;
94
+ return /* @__PURE__ */ jsxs2(Box2, { gap: 1, children: [
95
+ /* @__PURE__ */ jsxs2(Text2, { color, bold: step.status === "running", children: [
96
+ " ",
97
+ icon,
98
+ " ",
99
+ String(i + 1).padStart(2),
100
+ ". ",
101
+ step.name
102
+ ] }),
103
+ step.message && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
104
+ " ",
105
+ step.message
106
+ ] })
107
+ ] }, step.id);
108
+ }) });
109
+ }
110
+
111
+ // src/ssh.ts
112
+ import { NodeSSH } from "node-ssh";
113
+ var RemoteSSH = class {
114
+ ssh = new NodeSSH();
115
+ _host = "";
116
+ _port = 22;
117
+ async connect(host2, port, username, auth) {
118
+ this._host = host2;
119
+ this._port = port;
120
+ await this.ssh.connect({
121
+ host: host2,
122
+ port,
123
+ username,
124
+ ...auth.password ? { password: auth.password } : {},
125
+ ...auth.privateKeyPath ? { privateKeyPath: auth.privateKeyPath } : {},
126
+ readyTimeout: 15e3,
127
+ keepaliveInterval: 5e3
128
+ });
129
+ }
130
+ async exec(command, timeout = 3e4) {
131
+ const result = await this.ssh.execCommand(command, {
132
+ execOptions: { ...timeout ? {} : {} }
133
+ });
134
+ return {
135
+ stdout: result.stdout,
136
+ stderr: result.stderr,
137
+ code: result.code ?? 0
138
+ };
139
+ }
140
+ async uploadContent(content, remotePath) {
141
+ const dir = remotePath.substring(0, remotePath.lastIndexOf("/"));
142
+ await this.ssh.execCommand(`mkdir -p ${dir} && chmod 700 ${dir}`);
143
+ const escaped = content.replace(/'/g, "'\\''");
144
+ await this.ssh.execCommand(`echo '${escaped}' > ${remotePath} && chmod 600 ${remotePath}`);
145
+ }
146
+ get host() {
147
+ return this._host;
148
+ }
149
+ get port() {
150
+ return this._port;
151
+ }
152
+ isConnected() {
153
+ return this.ssh.isConnected();
154
+ }
155
+ dispose() {
156
+ this.ssh.dispose();
157
+ }
158
+ };
159
+
160
+ // src/engine.ts
161
+ async function runSteps(steps, config, ssh, onUpdate) {
162
+ const completed = [];
163
+ for (const step of steps) {
164
+ onUpdate(step.id, "running");
165
+ if (config.dryRun) {
166
+ onUpdate(step.id, "done", `[dry-run] would ${step.name.toLowerCase()}`);
167
+ continue;
168
+ }
169
+ try {
170
+ const result = await step.run(config, ssh);
171
+ if (!result.success) {
172
+ onUpdate(step.id, "failed", result.message);
173
+ await rollbackCompleted(completed, config, ssh, onUpdate);
174
+ return false;
175
+ }
176
+ onUpdate(step.id, "done", result.message);
177
+ completed.push(step);
178
+ } catch (err) {
179
+ const msg = err instanceof Error ? err.message : String(err);
180
+ onUpdate(step.id, "failed", msg);
181
+ await rollbackCompleted(completed, config, ssh, onUpdate);
182
+ return false;
183
+ }
184
+ }
185
+ return true;
186
+ }
187
+ async function rollbackCompleted(completed, config, ssh, onUpdate) {
188
+ for (const step of [...completed].reverse()) {
189
+ if (step.rollback) {
190
+ try {
191
+ await step.rollback(config, ssh);
192
+ onUpdate(step.id, "skipped", "rolled back");
193
+ } catch {
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ // src/steps/01-ssh-key.ts
200
+ import { execSync } from "child_process";
201
+ import { existsSync } from "fs";
202
+ var sshKeyStep = {
203
+ id: "ssh-key",
204
+ name: "Generate SSH key",
205
+ async run(config, _ssh) {
206
+ if (existsSync(config.keyPath)) {
207
+ return { success: true, message: `Key exists: ${config.keyPath}` };
208
+ }
209
+ try {
210
+ execSync(
211
+ `ssh-keygen -t ed25519 -f "${config.keyPath}" -N "" -C "vps-harden@${config.host}"`,
212
+ { stdio: "pipe" }
213
+ );
214
+ return { success: true, message: config.keyPath };
215
+ } catch (err) {
216
+ return { success: false, message: `ssh-keygen failed: ${err}` };
217
+ }
218
+ },
219
+ async rollback(config) {
220
+ try {
221
+ const { unlinkSync } = await import("fs");
222
+ if (existsSync(config.keyPath)) unlinkSync(config.keyPath);
223
+ if (existsSync(config.keyPubPath)) unlinkSync(config.keyPubPath);
224
+ } catch {
225
+ }
226
+ }
227
+ };
228
+
229
+ // src/steps/02-copy-key.ts
230
+ import { readFileSync } from "fs";
231
+ var copyKeyStep = {
232
+ id: "copy-key",
233
+ name: "Copy key to server",
234
+ async run(config, ssh) {
235
+ try {
236
+ const pubKey = readFileSync(config.keyPubPath, "utf-8").trim();
237
+ await ssh.exec("mkdir -p /root/.ssh && chmod 700 /root/.ssh");
238
+ const { stdout: existing } = await ssh.exec('cat /root/.ssh/authorized_keys 2>/dev/null || echo ""');
239
+ if (existing.includes(pubKey)) {
240
+ return { success: true, message: "Key already on server" };
241
+ }
242
+ await ssh.exec(`echo "${pubKey}" >> /root/.ssh/authorized_keys`);
243
+ await ssh.exec("chmod 600 /root/.ssh/authorized_keys");
244
+ return { success: true, message: `Key copied to root@${config.host}` };
245
+ } catch (err) {
246
+ return { success: false, message: `Copy failed: ${err}` };
247
+ }
248
+ }
249
+ };
250
+
251
+ // src/steps/03-create-user.ts
252
+ import { readFileSync as readFileSync2 } from "fs";
253
+ var createUserStep = {
254
+ id: "create-user",
255
+ name: "Create user",
256
+ async run(config, ssh) {
257
+ const { username } = config;
258
+ try {
259
+ const { code } = await ssh.exec(`id ${username} 2>/dev/null`);
260
+ if (code === 0) {
261
+ } else {
262
+ await ssh.exec(`useradd -m -s /bin/bash ${username}`);
263
+ }
264
+ await ssh.exec(`usermod -aG sudo ${username}`);
265
+ const sudoLine = `${username} ALL=(ALL) NOPASSWD:ALL`;
266
+ await ssh.exec(`echo '${sudoLine}' > /etc/sudoers.d/${username}`);
267
+ await ssh.exec(`chmod 440 /etc/sudoers.d/${username}`);
268
+ const { code: visudoCode } = await ssh.exec(`visudo -cf /etc/sudoers.d/${username}`);
269
+ if (visudoCode !== 0) {
270
+ await ssh.exec(`rm -f /etc/sudoers.d/${username}`);
271
+ return { success: false, message: "sudoers syntax error \u2014 removed" };
272
+ }
273
+ const pubKey = readFileSync2(config.keyPubPath, "utf-8").trim();
274
+ await ssh.exec(`mkdir -p /home/${username}/.ssh && chmod 700 /home/${username}/.ssh`);
275
+ await ssh.exec(`echo "${pubKey}" > /home/${username}/.ssh/authorized_keys`);
276
+ await ssh.exec(`chmod 600 /home/${username}/.ssh/authorized_keys`);
277
+ await ssh.exec(`chown -R ${username}:${username} /home/${username}/.ssh`);
278
+ return { success: true, message: `${username} (sudo)` };
279
+ } catch (err) {
280
+ return { success: false, message: `User creation failed: ${err}` };
281
+ }
282
+ },
283
+ async rollback(config, ssh) {
284
+ await ssh.exec(`userdel -r ${config.username} 2>/dev/null || true`);
285
+ await ssh.exec(`rm -f /etc/sudoers.d/${config.username}`);
286
+ }
287
+ };
288
+
289
+ // src/steps/04-change-port.ts
290
+ var changePortStep = {
291
+ id: "change-port",
292
+ name: "Change SSH port",
293
+ async run(config, ssh) {
294
+ const { sshPort } = config;
295
+ try {
296
+ await ssh.exec(`sed -i '/^Port /d' /etc/ssh/sshd_config`);
297
+ await ssh.exec(`sed -i '/^#Port /d' /etc/ssh/sshd_config`);
298
+ await ssh.exec(`echo "Port ${sshPort}" >> /etc/ssh/sshd_config`);
299
+ await ssh.exec(`echo "Port 22" >> /etc/ssh/sshd_config`);
300
+ await ssh.exec("systemctl restart sshd");
301
+ return { success: true, message: `Listening on 22 + ${sshPort}` };
302
+ } catch (err) {
303
+ return { success: false, message: `Port change failed: ${err}` };
304
+ }
305
+ },
306
+ async rollback(_config, ssh) {
307
+ await ssh.exec(`sed -i '/^Port /d' /etc/ssh/sshd_config`);
308
+ await ssh.exec(`echo "Port 22" >> /etc/ssh/sshd_config`);
309
+ await ssh.exec("systemctl restart sshd");
310
+ }
311
+ };
312
+
313
+ // src/steps/05-verify-port.ts
314
+ var verifyPortStep = {
315
+ id: "verify-port",
316
+ name: "Verify new port",
317
+ async run(config, _ssh) {
318
+ const test = new RemoteSSH();
319
+ try {
320
+ await test.connect(config.host, config.sshPort, config.username, {
321
+ privateKeyPath: config.keyPath
322
+ });
323
+ const { stdout: whoami } = await test.exec("whoami");
324
+ if (whoami.trim() !== config.username) {
325
+ return { success: false, message: `Expected ${config.username}, got ${whoami.trim()}` };
326
+ }
327
+ const { stdout: sudoTest } = await test.exec("sudo whoami");
328
+ if (sudoTest.trim() !== "root") {
329
+ return { success: false, message: "sudo not working for new user" };
330
+ }
331
+ return {
332
+ success: true,
333
+ message: `${config.username}@${config.host}:${config.sshPort} OK`
334
+ };
335
+ } catch (err) {
336
+ return {
337
+ success: false,
338
+ message: `CANNOT connect on port ${config.sshPort} \u2014 aborting (port 22 still open). Error: ${err}`
339
+ };
340
+ } finally {
341
+ test.dispose();
342
+ }
343
+ }
344
+ };
345
+
346
+ // src/steps/06-close-old.ts
347
+ var closeOldPortStep = {
348
+ id: "close-old",
349
+ name: "Close port 22",
350
+ async run(config, ssh) {
351
+ try {
352
+ await ssh.exec(`sed -i '/^Port 22$/d' /etc/ssh/sshd_config`);
353
+ await ssh.exec("systemctl restart sshd");
354
+ return { success: true, message: "Port 22 closed" };
355
+ } catch (err) {
356
+ return { success: false, message: `Failed to close port 22: ${err}` };
357
+ }
358
+ },
359
+ async rollback(_config, ssh) {
360
+ await ssh.exec(`echo "Port 22" >> /etc/ssh/sshd_config`);
361
+ await ssh.exec("systemctl restart sshd");
362
+ }
363
+ };
364
+
365
+ // src/steps/07-disable-pw.ts
366
+ var disablePasswordStep = {
367
+ id: "disable-pw",
368
+ name: "Disable password auth",
369
+ async run(_config, ssh) {
370
+ try {
371
+ await ssh.exec(`sed -i 's/^#\\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config`);
372
+ await ssh.exec(`sed -i 's/^#\\?ChallengeResponseAuthentication .*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config`);
373
+ await ssh.exec(`sed -i 's/^#\\?KbdInteractiveAuthentication .*/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config`);
374
+ const { stdout } = await ssh.exec('grep -c "^PasswordAuthentication" /etc/ssh/sshd_config');
375
+ if (stdout.trim() === "0") {
376
+ await ssh.exec('echo "PasswordAuthentication no" >> /etc/ssh/sshd_config');
377
+ }
378
+ await ssh.exec("systemctl restart sshd");
379
+ return { success: true, message: "Password auth disabled" };
380
+ } catch (err) {
381
+ return { success: false, message: `Failed: ${err}` };
382
+ }
383
+ },
384
+ async rollback(_config, ssh) {
385
+ await ssh.exec(`sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config`);
386
+ await ssh.exec("systemctl restart sshd");
387
+ }
388
+ };
389
+
390
+ // src/steps/08-disable-root.ts
391
+ var disableRootStep = {
392
+ id: "disable-root",
393
+ name: "Disable root login",
394
+ async run(_config, ssh) {
395
+ try {
396
+ await ssh.exec(`sed -i 's/^#\\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config`);
397
+ const { stdout } = await ssh.exec('grep -c "^PermitRootLogin" /etc/ssh/sshd_config');
398
+ if (stdout.trim() === "0") {
399
+ await ssh.exec('echo "PermitRootLogin no" >> /etc/ssh/sshd_config');
400
+ }
401
+ await ssh.exec("systemctl restart sshd");
402
+ return { success: true, message: "Root login disabled" };
403
+ } catch (err) {
404
+ return { success: false, message: `Failed: ${err}` };
405
+ }
406
+ },
407
+ async rollback(_config, ssh) {
408
+ await ssh.exec(`sed -i 's/^PermitRootLogin no/PermitRootLogin yes/' /etc/ssh/sshd_config`);
409
+ await ssh.exec("systemctl restart sshd");
410
+ }
411
+ };
412
+
413
+ // src/steps/09-ufw.ts
414
+ var ufwStep = {
415
+ id: "ufw",
416
+ name: "Setup firewall",
417
+ async run(config, ssh) {
418
+ try {
419
+ await ssh.exec("apt-get update -qq && apt-get install -y -qq ufw", 12e4);
420
+ await ssh.exec("ufw default deny incoming");
421
+ await ssh.exec("ufw default allow outgoing");
422
+ await ssh.exec(`ufw allow ${config.sshPort}/tcp`);
423
+ await ssh.exec("ufw allow 80/tcp");
424
+ await ssh.exec("ufw allow 443/tcp");
425
+ const { stdout } = await ssh.exec("ufw status");
426
+ if (!stdout.includes(String(config.sshPort))) {
427
+ return { success: false, message: "SSH port not in ufw rules \u2014 aborting" };
428
+ }
429
+ await ssh.exec("ufw --force enable");
430
+ return { success: true, message: `ufw on (${config.sshPort}, 80, 443)` };
431
+ } catch (err) {
432
+ return { success: false, message: `Firewall setup failed: ${err}` };
433
+ }
434
+ },
435
+ async rollback(_config, ssh) {
436
+ await ssh.exec("ufw disable 2>/dev/null || true");
437
+ }
438
+ };
439
+
440
+ // src/steps/10-fail2ban.ts
441
+ var fail2banStep = {
442
+ id: "fail2ban",
443
+ name: "Install fail2ban",
444
+ async run(config, ssh) {
445
+ try {
446
+ await ssh.exec("apt-get install -y -qq fail2ban", 12e4);
447
+ const jailConfig = [
448
+ "[sshd]",
449
+ "enabled = true",
450
+ `port = ${config.sshPort}`,
451
+ "filter = sshd",
452
+ "logpath = /var/log/auth.log",
453
+ "maxretry = 5",
454
+ "bantime = 3600",
455
+ "findtime = 600"
456
+ ].join("\n");
457
+ const escaped = jailConfig.replace(/'/g, "'\\''");
458
+ await ssh.exec(`echo '${escaped}' > /etc/fail2ban/jail.local`);
459
+ await ssh.exec("systemctl enable fail2ban && systemctl restart fail2ban");
460
+ return { success: true, message: `fail2ban on port ${config.sshPort}` };
461
+ } catch (err) {
462
+ return { success: false, message: `fail2ban failed: ${err}` };
463
+ }
464
+ },
465
+ async rollback(_config, ssh) {
466
+ await ssh.exec("apt-get remove -y fail2ban 2>/dev/null || true");
467
+ }
468
+ };
469
+
470
+ // src/steps/index.ts
471
+ var allSteps = [
472
+ sshKeyStep,
473
+ copyKeyStep,
474
+ createUserStep,
475
+ changePortStep,
476
+ verifyPortStep,
477
+ closeOldPortStep,
478
+ disablePasswordStep,
479
+ disableRootStep,
480
+ ufwStep,
481
+ fail2banStep
482
+ ];
483
+
484
+ // src/app.tsx
485
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
486
+ function App({ config }) {
487
+ const [stepStates, setStepStates] = useState(
488
+ allSteps.map((s) => ({ id: s.id, name: s.name, status: "pending" }))
489
+ );
490
+ const [done, setDone] = useState(false);
491
+ const [success, setSuccess] = useState(false);
492
+ const [error, setError] = useState(null);
493
+ useEffect(() => {
494
+ let cancelled = false;
495
+ async function run() {
496
+ const ssh = new RemoteSSH();
497
+ try {
498
+ if (!config.dryRun) {
499
+ await ssh.connect(config.host, 22, "root", {
500
+ password: config.rootPassword
501
+ });
502
+ }
503
+ const ok = await runSteps(allSteps, config, ssh, (stepId, status, message) => {
504
+ if (cancelled) return;
505
+ setStepStates(
506
+ (prev) => prev.map((s) => s.id === stepId ? { ...s, status, message } : s)
507
+ );
508
+ });
509
+ if (!cancelled) {
510
+ setSuccess(ok);
511
+ setDone(true);
512
+ }
513
+ } catch (err) {
514
+ if (!cancelled) {
515
+ setError(err instanceof Error ? err.message : String(err));
516
+ setDone(true);
517
+ }
518
+ } finally {
519
+ ssh.dispose();
520
+ }
521
+ }
522
+ run();
523
+ return () => {
524
+ cancelled = true;
525
+ };
526
+ }, []);
527
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 1, children: [
528
+ /* @__PURE__ */ jsx3(Banner, { host: config.host, dryRun: config.dryRun }),
529
+ /* @__PURE__ */ jsx3(StepList, { steps: stepStates }),
530
+ !done && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "yellow", children: [
531
+ " ",
532
+ "Keep this terminal open!"
533
+ ] }) }),
534
+ error && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
535
+ " ",
536
+ "Error: ",
537
+ error
538
+ ] }) }),
539
+ done && /* @__PURE__ */ jsx3(
540
+ Result,
541
+ {
542
+ host: config.host,
543
+ port: config.sshPort,
544
+ username: config.username,
545
+ keyPath: config.keyPath,
546
+ success
547
+ }
548
+ )
549
+ ] });
550
+ }
551
+
552
+ // src/cli.tsx
553
+ import { jsx as jsx4 } from "react/jsx-runtime";
554
+ var cli = meow(
555
+ `
556
+ Usage
557
+ $ vps-harden <server-ip> [options]
558
+
559
+ Options
560
+ --password Root password (or will prompt)
561
+ --username New username (default: deploy)
562
+ --port New SSH port (default: 2222)
563
+ --key Use existing SSH key (skip generation)
564
+ --dry-run Preview steps without changes
565
+ --help Show help
566
+ --version Show version
567
+
568
+ Examples
569
+ $ vps-harden 194.68.0.12
570
+ $ vps-harden 194.68.0.12 --username admin --port 3322
571
+ $ vps-harden 194.68.0.12 --dry-run
572
+ `,
573
+ {
574
+ importMeta: import.meta,
575
+ flags: {
576
+ password: { type: "string" },
577
+ username: { type: "string", default: "deploy" },
578
+ port: { type: "number", default: 2222 },
579
+ key: { type: "string" },
580
+ dryRun: { type: "boolean", default: false }
581
+ }
582
+ }
583
+ );
584
+ var host = cli.input[0];
585
+ if (!host) {
586
+ console.error("Error: server IP is required\n");
587
+ console.error("Usage: vps-harden <server-ip>");
588
+ console.error("Example: npx vps-harden 194.68.0.12");
589
+ process.exit(1);
590
+ }
591
+ async function prompt(question, hidden = false) {
592
+ const rl = createInterface({
593
+ input: process.stdin,
594
+ output: process.stdout
595
+ });
596
+ if (hidden && process.stdout.isTTY) {
597
+ process.stdout.write(question);
598
+ return new Promise((resolve) => {
599
+ let input = "";
600
+ process.stdin.setRawMode(true);
601
+ process.stdin.resume();
602
+ process.stdin.setEncoding("utf8");
603
+ const onData = (char) => {
604
+ if (char === "\n" || char === "\r") {
605
+ process.stdin.setRawMode(false);
606
+ process.stdin.pause();
607
+ process.stdin.removeListener("data", onData);
608
+ rl.close();
609
+ process.stdout.write("\n");
610
+ resolve(input);
611
+ } else if (char === "") {
612
+ process.exit(0);
613
+ } else if (char === "\x7F") {
614
+ if (input.length > 0) {
615
+ input = input.slice(0, -1);
616
+ process.stdout.write("\b \b");
617
+ }
618
+ } else {
619
+ input += char;
620
+ process.stdout.write("*");
621
+ }
622
+ };
623
+ process.stdin.on("data", onData);
624
+ });
625
+ }
626
+ return new Promise((resolve) => {
627
+ rl.question(question, (answer) => {
628
+ rl.close();
629
+ resolve(answer);
630
+ });
631
+ });
632
+ }
633
+ async function main() {
634
+ let password = cli.flags.password;
635
+ if (!password && !cli.flags.dryRun) {
636
+ password = await prompt(`Root password for ${host}: `, true);
637
+ if (!password) {
638
+ console.error("Password is required");
639
+ process.exit(1);
640
+ }
641
+ }
642
+ const sanitizedHost = host.replace(/[.:]/g, "_");
643
+ const keyName = `vps_${sanitizedHost}_ed25519`;
644
+ const keyPath = cli.flags.key ?? join(homedir(), ".ssh", keyName);
645
+ const config = {
646
+ host,
647
+ rootPassword: password ?? "",
648
+ username: cli.flags.username,
649
+ sshPort: cli.flags.port,
650
+ keyPath,
651
+ keyPubPath: `${keyPath}.pub`,
652
+ dryRun: cli.flags.dryRun
653
+ };
654
+ render(/* @__PURE__ */ jsx4(App, { config }));
655
+ }
656
+ main();
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "vps-harden",
3
+ "version": "1.0.0",
4
+ "description": "Secure your VPS in 2 minutes. Interactive CLI wizard for SSH hardening.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vps-harden": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "dev": "tsup --watch",
15
+ "start": "node dist/cli.js"
16
+ },
17
+ "keywords": [
18
+ "vps",
19
+ "hardening",
20
+ "ssh",
21
+ "security",
22
+ "server",
23
+ "firewall",
24
+ "ufw",
25
+ "fail2ban",
26
+ "cli",
27
+ "tui",
28
+ "devops"
29
+ ],
30
+ "author": "DukeDeSouth",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/DukeDeSouth/vps-harden.git"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "dependencies": {
40
+ "ink": "^6.8.0",
41
+ "meow": "^14.1.0",
42
+ "node-ssh": "^13.2.1",
43
+ "react": "^19.2.4"
44
+ },
45
+ "devDependencies": {
46
+ "@types/react": "^19.2.14",
47
+ "tsup": "^8.5.1",
48
+ "typescript": "^5.9.3"
49
+ }
50
+ }