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.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/cli.js +656 -0
- 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
|
+
}
|