@ebowwa/hetzner 0.1.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/actions.js +802 -0
- package/actions.ts +1053 -0
- package/auth.js +35 -0
- package/auth.ts +37 -0
- package/bootstrap/FIREWALL.md +326 -0
- package/bootstrap/KERNEL-HARDENING.md +258 -0
- package/bootstrap/SECURITY-INTEGRATION.md +281 -0
- package/bootstrap/TESTING.md +301 -0
- package/bootstrap/cloud-init.js +279 -0
- package/bootstrap/cloud-init.ts +394 -0
- package/bootstrap/firewall.js +279 -0
- package/bootstrap/firewall.ts +342 -0
- package/bootstrap/genesis.js +406 -0
- package/bootstrap/genesis.ts +518 -0
- package/bootstrap/index.js +35 -0
- package/bootstrap/index.ts +71 -0
- package/bootstrap/kernel-hardening.js +266 -0
- package/bootstrap/kernel-hardening.test.ts +230 -0
- package/bootstrap/kernel-hardening.ts +272 -0
- package/bootstrap/security-audit.js +118 -0
- package/bootstrap/security-audit.ts +124 -0
- package/bootstrap/ssh-hardening.js +182 -0
- package/bootstrap/ssh-hardening.ts +192 -0
- package/client.js +137 -0
- package/client.ts +177 -0
- package/config.js +5 -0
- package/config.ts +5 -0
- package/errors.js +270 -0
- package/errors.ts +371 -0
- package/index.js +28 -0
- package/index.ts +55 -0
- package/package.json +56 -0
- package/pricing.js +284 -0
- package/pricing.ts +422 -0
- package/schemas.js +660 -0
- package/schemas.ts +765 -0
- package/server-status.ts +81 -0
- package/servers.js +424 -0
- package/servers.ts +568 -0
- package/ssh-keys.js +90 -0
- package/ssh-keys.ts +122 -0
- package/ssh-setup.ts +218 -0
- package/types.js +96 -0
- package/types.ts +389 -0
- package/volumes.js +172 -0
- package/volumes.ts +229 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Hardening Cloud-Init Components
|
|
3
|
+
*
|
|
4
|
+
* Composable cloud-init blocks for securing sshd on new servers.
|
|
5
|
+
* Includes: hardened sshd config, fail2ban, and health monitoring.
|
|
6
|
+
*
|
|
7
|
+
* Background: Hetzner public IPs get brute-forced constantly (~5k failed SSH
|
|
8
|
+
* logins per 24h observed on genesis). Without hardening, the default sshd
|
|
9
|
+
* MaxStartups (10:30:100) gets overwhelmed and legitimate connections time out
|
|
10
|
+
* with "Timed out while waiting for handshake".
|
|
11
|
+
*
|
|
12
|
+
* This module is imported by both cloud-init.ts (seed/worker nodes) and
|
|
13
|
+
* genesis.ts (control plane) so every new server gets hardened at first boot.
|
|
14
|
+
*
|
|
15
|
+
* Three composable functions return cloud-init line arrays for splicing into
|
|
16
|
+
* the appropriate YAML sections:
|
|
17
|
+
* - sshdHardeningPackages() → packages: section
|
|
18
|
+
* - sshdHardeningWriteFiles() → write_files: section
|
|
19
|
+
* - sshdHardeningRunCmd() → runcmd: section
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Packages required for SSH hardening.
|
|
23
|
+
* Returns cloud-init YAML lines for the `packages:` section.
|
|
24
|
+
*
|
|
25
|
+
* - fail2ban: bans IPs after repeated failed SSH attempts (3 tries → 1hr ban)
|
|
26
|
+
*/
|
|
27
|
+
export function sshdHardeningPackages() {
|
|
28
|
+
return [
|
|
29
|
+
" - fail2ban",
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Files to write at first boot for SSH hardening.
|
|
34
|
+
* Returns cloud-init YAML lines for the `write_files:` section.
|
|
35
|
+
*
|
|
36
|
+
* Drops 5 files onto the server:
|
|
37
|
+
*
|
|
38
|
+
* 1. /etc/ssh/sshd_config.d/hardened.conf
|
|
39
|
+
* - Disables password auth entirely (key-only)
|
|
40
|
+
* - Raises MaxStartups from default 10:30:100 → 20:50:60 so brute-force
|
|
41
|
+
* traffic doesn't starve legitimate connections
|
|
42
|
+
* - Reduces LoginGraceTime from 2min → 30s (attackers hold slots open)
|
|
43
|
+
* - Limits MaxAuthTries to 3 per connection
|
|
44
|
+
* - Enables keepalive (30s interval, 3 missed = disconnect)
|
|
45
|
+
*
|
|
46
|
+
* 2. /etc/fail2ban/jail.local
|
|
47
|
+
* - Monitors sshd via systemd journal
|
|
48
|
+
* - Bans IP for 1 hour after 3 failures within 10 minutes
|
|
49
|
+
* - Uses nftables (Ubuntu 24.04 default firewall backend)
|
|
50
|
+
*
|
|
51
|
+
* 3. /opt/monitoring/sshd-health.sh
|
|
52
|
+
* - Collects sshd + fail2ban + system metrics into JSON
|
|
53
|
+
* - Writes to /var/log/sshd-health.json (read by the app via SSH)
|
|
54
|
+
* - Auto-restarts sshd if it detects it's down
|
|
55
|
+
*
|
|
56
|
+
* 4. /etc/systemd/system/sshd-health.service (oneshot runner)
|
|
57
|
+
* 5. /etc/systemd/system/sshd-health.timer (runs every 60 seconds)
|
|
58
|
+
*/
|
|
59
|
+
export function sshdHardeningWriteFiles() {
|
|
60
|
+
const lines = [];
|
|
61
|
+
// 1. Hardened sshd config — dropped into sshd_config.d/ so it overrides defaults
|
|
62
|
+
// without touching the main sshd_config file
|
|
63
|
+
lines.push(" # Hardened SSH configuration");
|
|
64
|
+
lines.push(" - path: /etc/ssh/sshd_config.d/hardened.conf");
|
|
65
|
+
lines.push(" owner: root:root");
|
|
66
|
+
lines.push(" permissions: '0644'");
|
|
67
|
+
lines.push(" content: |");
|
|
68
|
+
lines.push(" # Hardened SSH config - applied via cloud-init");
|
|
69
|
+
lines.push(" PasswordAuthentication no");
|
|
70
|
+
lines.push(" PermitEmptyPasswords no");
|
|
71
|
+
lines.push(" KbdInteractiveAuthentication no");
|
|
72
|
+
lines.push(" PermitRootLogin prohibit-password");
|
|
73
|
+
lines.push(" LoginGraceTime 30");
|
|
74
|
+
lines.push(" MaxStartups 20:50:60");
|
|
75
|
+
lines.push(" ClientAliveInterval 30");
|
|
76
|
+
lines.push(" ClientAliveCountMax 3");
|
|
77
|
+
lines.push(" MaxAuthTries 3");
|
|
78
|
+
lines.push("");
|
|
79
|
+
// 2. fail2ban jail — 3 failed attempts in 10min = 1hr ban via nftables
|
|
80
|
+
lines.push(" # fail2ban SSH jail configuration");
|
|
81
|
+
lines.push(" - path: /etc/fail2ban/jail.local");
|
|
82
|
+
lines.push(" owner: root:root");
|
|
83
|
+
lines.push(" permissions: '0644'");
|
|
84
|
+
lines.push(" content: |");
|
|
85
|
+
lines.push(" [sshd]");
|
|
86
|
+
lines.push(" enabled = true");
|
|
87
|
+
lines.push(" port = ssh");
|
|
88
|
+
lines.push(" backend = systemd");
|
|
89
|
+
lines.push(" maxretry = 3");
|
|
90
|
+
lines.push(" findtime = 600");
|
|
91
|
+
lines.push(" bantime = 3600");
|
|
92
|
+
lines.push(" banaction = nftables-multiport");
|
|
93
|
+
lines.push("");
|
|
94
|
+
// 3. Health monitoring script — collects sshd/fail2ban/system stats into JSON
|
|
95
|
+
// Output at /var/log/sshd-health.json is read by the app's collectSSHDHealth()
|
|
96
|
+
// function (src/lib/metrics.ts) every 5 minutes via SSH
|
|
97
|
+
lines.push(" # sshd health monitoring script");
|
|
98
|
+
lines.push(" - path: /opt/monitoring/sshd-health.sh");
|
|
99
|
+
lines.push(" owner: root:root");
|
|
100
|
+
lines.push(" permissions: '0755'");
|
|
101
|
+
lines.push(" content: |");
|
|
102
|
+
lines.push(" #!/bin/bash");
|
|
103
|
+
lines.push(" set -euo pipefail");
|
|
104
|
+
lines.push(" LOGFILE=\"/var/log/sshd-health.json\"");
|
|
105
|
+
lines.push(" sshd_active=$(systemctl is-active ssh 2>/dev/null || echo \"inactive\")");
|
|
106
|
+
lines.push(" sshd_pid=$(pgrep -x sshd | head -1 || echo \"0\")");
|
|
107
|
+
lines.push(" f2b_active=$(systemctl is-active fail2ban 2>/dev/null || echo \"inactive\")");
|
|
108
|
+
lines.push(" f2b_banned=$(fail2ban-client status sshd 2>/dev/null | grep \"Currently banned\" | awk '{print $NF}' || echo \"0\")");
|
|
109
|
+
lines.push(" f2b_total=$(fail2ban-client status sshd 2>/dev/null | grep \"Total banned\" | awk '{print $NF}' || echo \"0\")");
|
|
110
|
+
lines.push(" failed_1h=$(journalctl -u ssh --since \"1 hour ago\" --no-pager 2>/dev/null | grep -c \"Failed\" || true)");
|
|
111
|
+
lines.push(" failed_24h=$(journalctl -u ssh --since \"24 hours ago\" --no-pager 2>/dev/null | grep -c \"Failed\" || true)");
|
|
112
|
+
lines.push(" active_connections=$(ss -tnp | grep -c \":22 \" || echo \"0\")");
|
|
113
|
+
lines.push(" uptime_seconds=$(awk '{print int($1)}' /proc/uptime)");
|
|
114
|
+
lines.push(" load=$(awk '{print $1}' /proc/loadavg)");
|
|
115
|
+
lines.push(" mem_used_pct=$(free | awk '/Mem:/{printf \"%.1f\", $3/$2*100}')");
|
|
116
|
+
lines.push(" timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)");
|
|
117
|
+
lines.push(" cat > \"$LOGFILE\" <<HEALTHEOF");
|
|
118
|
+
lines.push(" {");
|
|
119
|
+
lines.push(" \"timestamp\": \"$timestamp\",");
|
|
120
|
+
lines.push(" \"sshd\": { \"active\": \"$sshd_active\", \"pid\": $sshd_pid },");
|
|
121
|
+
lines.push(" \"fail2ban\": { \"active\": \"$f2b_active\", \"currently_banned\": $f2b_banned, \"total_banned\": $f2b_total },");
|
|
122
|
+
lines.push(" \"connections\": { \"active\": $active_connections, \"failed_1h\": $failed_1h, \"failed_24h\": $failed_24h },");
|
|
123
|
+
lines.push(" \"system\": { \"uptime_seconds\": $uptime_seconds, \"load\": $load, \"memory_used_pct\": $mem_used_pct }");
|
|
124
|
+
lines.push(" }");
|
|
125
|
+
lines.push(" HEALTHEOF");
|
|
126
|
+
lines.push(" if [ \"$sshd_active\" != \"active\" ]; then");
|
|
127
|
+
lines.push(" logger -t sshd-health -p auth.crit \"ALERT: sshd is $sshd_active\"");
|
|
128
|
+
lines.push(" systemctl start ssh 2>/dev/null || logger -t sshd-health -p auth.crit \"FAILED to restart sshd\"");
|
|
129
|
+
lines.push(" fi");
|
|
130
|
+
lines.push("");
|
|
131
|
+
// 4. Systemd oneshot service — wraps the health script for timer-based execution
|
|
132
|
+
lines.push(" # sshd health check systemd service");
|
|
133
|
+
lines.push(" - path: /etc/systemd/system/sshd-health.service");
|
|
134
|
+
lines.push(" owner: root:root");
|
|
135
|
+
lines.push(" permissions: '0644'");
|
|
136
|
+
lines.push(" content: |");
|
|
137
|
+
lines.push(" [Unit]");
|
|
138
|
+
lines.push(" Description=sshd health check");
|
|
139
|
+
lines.push(" After=ssh.service");
|
|
140
|
+
lines.push(" [Service]");
|
|
141
|
+
lines.push(" Type=oneshot");
|
|
142
|
+
lines.push(" ExecStart=/opt/monitoring/sshd-health.sh");
|
|
143
|
+
lines.push("");
|
|
144
|
+
// 5. Systemd timer — fires the health check every 60 seconds, starting 30s after boot
|
|
145
|
+
lines.push(" # sshd health check timer (every 60s)");
|
|
146
|
+
lines.push(" - path: /etc/systemd/system/sshd-health.timer");
|
|
147
|
+
lines.push(" owner: root:root");
|
|
148
|
+
lines.push(" permissions: '0644'");
|
|
149
|
+
lines.push(" content: |");
|
|
150
|
+
lines.push(" [Unit]");
|
|
151
|
+
lines.push(" Description=Run sshd health check every minute");
|
|
152
|
+
lines.push(" [Timer]");
|
|
153
|
+
lines.push(" OnBootSec=30");
|
|
154
|
+
lines.push(" OnUnitActiveSec=60");
|
|
155
|
+
lines.push(" [Install]");
|
|
156
|
+
lines.push(" WantedBy=timers.target");
|
|
157
|
+
lines.push("");
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Commands to activate all SSH hardening services at first boot.
|
|
162
|
+
* Returns cloud-init YAML lines for the `runcmd:` section.
|
|
163
|
+
*
|
|
164
|
+
* Order matters:
|
|
165
|
+
* 1. Create /opt/monitoring dir (health script target)
|
|
166
|
+
* 2. Reload sshd to pick up hardened.conf (fallback: full restart)
|
|
167
|
+
* 3. Enable + start fail2ban (reads jail.local immediately)
|
|
168
|
+
* 4. Reload systemd to pick up the health service/timer units
|
|
169
|
+
* 5. Enable + start the health timer (begins 60s monitoring loop)
|
|
170
|
+
*/
|
|
171
|
+
export function sshdHardeningRunCmd() {
|
|
172
|
+
const lines = [];
|
|
173
|
+
lines.push(" # SSH hardening: reload sshd, enable fail2ban and health monitoring");
|
|
174
|
+
lines.push(" - mkdir -p /opt/monitoring");
|
|
175
|
+
lines.push(" - systemctl reload ssh || systemctl restart ssh");
|
|
176
|
+
lines.push(" - systemctl enable --now fail2ban");
|
|
177
|
+
lines.push(" - systemctl daemon-reload");
|
|
178
|
+
lines.push(" - systemctl enable --now sshd-health.timer");
|
|
179
|
+
lines.push("");
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=ssh-hardening.js.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Hardening Cloud-Init Components
|
|
3
|
+
*
|
|
4
|
+
* Composable cloud-init blocks for securing sshd on new servers.
|
|
5
|
+
* Includes: hardened sshd config, fail2ban, and health monitoring.
|
|
6
|
+
*
|
|
7
|
+
* Background: Hetzner public IPs get brute-forced constantly (~5k failed SSH
|
|
8
|
+
* logins per 24h observed on genesis). Without hardening, the default sshd
|
|
9
|
+
* MaxStartups (10:30:100) gets overwhelmed and legitimate connections time out
|
|
10
|
+
* with "Timed out while waiting for handshake".
|
|
11
|
+
*
|
|
12
|
+
* This module is imported by both cloud-init.ts (seed/worker nodes) and
|
|
13
|
+
* genesis.ts (control plane) so every new server gets hardened at first boot.
|
|
14
|
+
*
|
|
15
|
+
* Three composable functions return cloud-init line arrays for splicing into
|
|
16
|
+
* the appropriate YAML sections:
|
|
17
|
+
* - sshdHardeningPackages() → packages: section
|
|
18
|
+
* - sshdHardeningWriteFiles() → write_files: section
|
|
19
|
+
* - sshdHardeningRunCmd() → runcmd: section
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Packages required for SSH hardening.
|
|
24
|
+
* Returns cloud-init YAML lines for the `packages:` section.
|
|
25
|
+
*
|
|
26
|
+
* - fail2ban: bans IPs after repeated failed SSH attempts (3 tries → 1hr ban)
|
|
27
|
+
*/
|
|
28
|
+
export function sshdHardeningPackages(): string[] {
|
|
29
|
+
return [
|
|
30
|
+
" - fail2ban",
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Files to write at first boot for SSH hardening.
|
|
36
|
+
* Returns cloud-init YAML lines for the `write_files:` section.
|
|
37
|
+
*
|
|
38
|
+
* Drops 5 files onto the server:
|
|
39
|
+
*
|
|
40
|
+
* 1. /etc/ssh/sshd_config.d/hardened.conf
|
|
41
|
+
* - Disables password auth entirely (key-only)
|
|
42
|
+
* - Raises MaxStartups from default 10:30:100 → 20:50:60 so brute-force
|
|
43
|
+
* traffic doesn't starve legitimate connections
|
|
44
|
+
* - Reduces LoginGraceTime from 2min → 30s (attackers hold slots open)
|
|
45
|
+
* - Limits MaxAuthTries to 3 per connection
|
|
46
|
+
* - Enables keepalive (30s interval, 3 missed = disconnect)
|
|
47
|
+
*
|
|
48
|
+
* 2. /etc/fail2ban/jail.local
|
|
49
|
+
* - Monitors sshd via systemd journal
|
|
50
|
+
* - Bans IP for 1 hour after 3 failures within 10 minutes
|
|
51
|
+
* - Uses nftables (Ubuntu 24.04 default firewall backend)
|
|
52
|
+
*
|
|
53
|
+
* 3. /opt/monitoring/sshd-health.sh
|
|
54
|
+
* - Collects sshd + fail2ban + system metrics into JSON
|
|
55
|
+
* - Writes to /var/log/sshd-health.json (read by the app via SSH)
|
|
56
|
+
* - Auto-restarts sshd if it detects it's down
|
|
57
|
+
*
|
|
58
|
+
* 4. /etc/systemd/system/sshd-health.service (oneshot runner)
|
|
59
|
+
* 5. /etc/systemd/system/sshd-health.timer (runs every 60 seconds)
|
|
60
|
+
*/
|
|
61
|
+
export function sshdHardeningWriteFiles(): string[] {
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
|
|
64
|
+
// 1. Hardened sshd config — dropped into sshd_config.d/ so it overrides defaults
|
|
65
|
+
// without touching the main sshd_config file
|
|
66
|
+
lines.push(" # Hardened SSH configuration");
|
|
67
|
+
lines.push(" - path: /etc/ssh/sshd_config.d/hardened.conf");
|
|
68
|
+
lines.push(" owner: root:root");
|
|
69
|
+
lines.push(" permissions: '0644'");
|
|
70
|
+
lines.push(" content: |");
|
|
71
|
+
lines.push(" # Hardened SSH config - applied via cloud-init");
|
|
72
|
+
lines.push(" PasswordAuthentication no");
|
|
73
|
+
lines.push(" PermitEmptyPasswords no");
|
|
74
|
+
lines.push(" KbdInteractiveAuthentication no");
|
|
75
|
+
lines.push(" PermitRootLogin prohibit-password");
|
|
76
|
+
lines.push(" LoginGraceTime 30");
|
|
77
|
+
lines.push(" MaxStartups 20:50:60");
|
|
78
|
+
lines.push(" ClientAliveInterval 30");
|
|
79
|
+
lines.push(" ClientAliveCountMax 3");
|
|
80
|
+
lines.push(" MaxAuthTries 3");
|
|
81
|
+
lines.push("");
|
|
82
|
+
|
|
83
|
+
// 2. fail2ban jail — 3 failed attempts in 10min = 1hr ban via nftables
|
|
84
|
+
lines.push(" # fail2ban SSH jail configuration");
|
|
85
|
+
lines.push(" - path: /etc/fail2ban/jail.local");
|
|
86
|
+
lines.push(" owner: root:root");
|
|
87
|
+
lines.push(" permissions: '0644'");
|
|
88
|
+
lines.push(" content: |");
|
|
89
|
+
lines.push(" [sshd]");
|
|
90
|
+
lines.push(" enabled = true");
|
|
91
|
+
lines.push(" port = ssh");
|
|
92
|
+
lines.push(" backend = systemd");
|
|
93
|
+
lines.push(" maxretry = 3");
|
|
94
|
+
lines.push(" findtime = 600");
|
|
95
|
+
lines.push(" bantime = 3600");
|
|
96
|
+
lines.push(" banaction = nftables-multiport");
|
|
97
|
+
lines.push("");
|
|
98
|
+
|
|
99
|
+
// 3. Health monitoring script — collects sshd/fail2ban/system stats into JSON
|
|
100
|
+
// Output at /var/log/sshd-health.json is read by the app's collectSSHDHealth()
|
|
101
|
+
// function (src/lib/metrics.ts) every 5 minutes via SSH
|
|
102
|
+
lines.push(" # sshd health monitoring script");
|
|
103
|
+
lines.push(" - path: /opt/monitoring/sshd-health.sh");
|
|
104
|
+
lines.push(" owner: root:root");
|
|
105
|
+
lines.push(" permissions: '0755'");
|
|
106
|
+
lines.push(" content: |");
|
|
107
|
+
lines.push(" #!/bin/bash");
|
|
108
|
+
lines.push(" set -euo pipefail");
|
|
109
|
+
lines.push(" LOGFILE=\"/var/log/sshd-health.json\"");
|
|
110
|
+
lines.push(" sshd_active=$(systemctl is-active ssh 2>/dev/null || echo \"inactive\")");
|
|
111
|
+
lines.push(" sshd_pid=$(pgrep -x sshd | head -1 || echo \"0\")");
|
|
112
|
+
lines.push(" f2b_active=$(systemctl is-active fail2ban 2>/dev/null || echo \"inactive\")");
|
|
113
|
+
lines.push(" f2b_banned=$(fail2ban-client status sshd 2>/dev/null | grep \"Currently banned\" | awk '{print $NF}' || echo \"0\")");
|
|
114
|
+
lines.push(" f2b_total=$(fail2ban-client status sshd 2>/dev/null | grep \"Total banned\" | awk '{print $NF}' || echo \"0\")");
|
|
115
|
+
lines.push(" failed_1h=$(journalctl -u ssh --since \"1 hour ago\" --no-pager 2>/dev/null | grep -c \"Failed\" || true)");
|
|
116
|
+
lines.push(" failed_24h=$(journalctl -u ssh --since \"24 hours ago\" --no-pager 2>/dev/null | grep -c \"Failed\" || true)");
|
|
117
|
+
lines.push(" active_connections=$(ss -tnp | grep -c \":22 \" || echo \"0\")");
|
|
118
|
+
lines.push(" uptime_seconds=$(awk '{print int($1)}' /proc/uptime)");
|
|
119
|
+
lines.push(" load=$(awk '{print $1}' /proc/loadavg)");
|
|
120
|
+
lines.push(" mem_used_pct=$(free | awk '/Mem:/{printf \"%.1f\", $3/$2*100}')");
|
|
121
|
+
lines.push(" timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)");
|
|
122
|
+
lines.push(" cat > \"$LOGFILE\" <<HEALTHEOF");
|
|
123
|
+
lines.push(" {");
|
|
124
|
+
lines.push(" \"timestamp\": \"$timestamp\",");
|
|
125
|
+
lines.push(" \"sshd\": { \"active\": \"$sshd_active\", \"pid\": $sshd_pid },");
|
|
126
|
+
lines.push(" \"fail2ban\": { \"active\": \"$f2b_active\", \"currently_banned\": $f2b_banned, \"total_banned\": $f2b_total },");
|
|
127
|
+
lines.push(" \"connections\": { \"active\": $active_connections, \"failed_1h\": $failed_1h, \"failed_24h\": $failed_24h },");
|
|
128
|
+
lines.push(" \"system\": { \"uptime_seconds\": $uptime_seconds, \"load\": $load, \"memory_used_pct\": $mem_used_pct }");
|
|
129
|
+
lines.push(" }");
|
|
130
|
+
lines.push(" HEALTHEOF");
|
|
131
|
+
lines.push(" if [ \"$sshd_active\" != \"active\" ]; then");
|
|
132
|
+
lines.push(" logger -t sshd-health -p auth.crit \"ALERT: sshd is $sshd_active\"");
|
|
133
|
+
lines.push(" systemctl start ssh 2>/dev/null || logger -t sshd-health -p auth.crit \"FAILED to restart sshd\"");
|
|
134
|
+
lines.push(" fi");
|
|
135
|
+
lines.push("");
|
|
136
|
+
|
|
137
|
+
// 4. Systemd oneshot service — wraps the health script for timer-based execution
|
|
138
|
+
lines.push(" # sshd health check systemd service");
|
|
139
|
+
lines.push(" - path: /etc/systemd/system/sshd-health.service");
|
|
140
|
+
lines.push(" owner: root:root");
|
|
141
|
+
lines.push(" permissions: '0644'");
|
|
142
|
+
lines.push(" content: |");
|
|
143
|
+
lines.push(" [Unit]");
|
|
144
|
+
lines.push(" Description=sshd health check");
|
|
145
|
+
lines.push(" After=ssh.service");
|
|
146
|
+
lines.push(" [Service]");
|
|
147
|
+
lines.push(" Type=oneshot");
|
|
148
|
+
lines.push(" ExecStart=/opt/monitoring/sshd-health.sh");
|
|
149
|
+
lines.push("");
|
|
150
|
+
|
|
151
|
+
// 5. Systemd timer — fires the health check every 60 seconds, starting 30s after boot
|
|
152
|
+
lines.push(" # sshd health check timer (every 60s)");
|
|
153
|
+
lines.push(" - path: /etc/systemd/system/sshd-health.timer");
|
|
154
|
+
lines.push(" owner: root:root");
|
|
155
|
+
lines.push(" permissions: '0644'");
|
|
156
|
+
lines.push(" content: |");
|
|
157
|
+
lines.push(" [Unit]");
|
|
158
|
+
lines.push(" Description=Run sshd health check every minute");
|
|
159
|
+
lines.push(" [Timer]");
|
|
160
|
+
lines.push(" OnBootSec=30");
|
|
161
|
+
lines.push(" OnUnitActiveSec=60");
|
|
162
|
+
lines.push(" [Install]");
|
|
163
|
+
lines.push(" WantedBy=timers.target");
|
|
164
|
+
lines.push("");
|
|
165
|
+
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Commands to activate all SSH hardening services at first boot.
|
|
171
|
+
* Returns cloud-init YAML lines for the `runcmd:` section.
|
|
172
|
+
*
|
|
173
|
+
* Order matters:
|
|
174
|
+
* 1. Create /opt/monitoring dir (health script target)
|
|
175
|
+
* 2. Reload sshd to pick up hardened.conf (fallback: full restart)
|
|
176
|
+
* 3. Enable + start fail2ban (reads jail.local immediately)
|
|
177
|
+
* 4. Reload systemd to pick up the health service/timer units
|
|
178
|
+
* 5. Enable + start the health timer (begins 60s monitoring loop)
|
|
179
|
+
*/
|
|
180
|
+
export function sshdHardeningRunCmd(): string[] {
|
|
181
|
+
const lines: string[] = [];
|
|
182
|
+
|
|
183
|
+
lines.push(" # SSH hardening: reload sshd, enable fail2ban and health monitoring");
|
|
184
|
+
lines.push(" - mkdir -p /opt/monitoring");
|
|
185
|
+
lines.push(" - systemctl reload ssh || systemctl restart ssh");
|
|
186
|
+
lines.push(" - systemctl enable --now fail2ban");
|
|
187
|
+
lines.push(" - systemctl daemon-reload");
|
|
188
|
+
lines.push(" - systemctl enable --now sshd-health.timer");
|
|
189
|
+
lines.push("");
|
|
190
|
+
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
package/client.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Cloud API client
|
|
3
|
+
* For server-side use only (requires API token)
|
|
4
|
+
*
|
|
5
|
+
* TODO: RE-REVIEW https://docs.hetzner.cloud/reference/cloud#authentication
|
|
6
|
+
* - https://tailscale.com/kb/1150/cloud-hetzner
|
|
7
|
+
*/
|
|
8
|
+
// Explicitly import fetch for Bun compatibility
|
|
9
|
+
import { fetch as bunFetch } from "bun";
|
|
10
|
+
import { HETZNER_API_BASE } from "./config.js";
|
|
11
|
+
// Use Bun's fetch if available, otherwise try global fetch
|
|
12
|
+
const fetch = bunFetch || globalThis.fetch;
|
|
13
|
+
import { resolveApiToken, getTokenFromCLI, isAuthenticated } from "./auth.js";
|
|
14
|
+
import { ServerOperations } from "./servers.js";
|
|
15
|
+
import { ActionOperations } from "./actions.js";
|
|
16
|
+
import { PricingOperations } from "./pricing.js";
|
|
17
|
+
import { SSHKeyOperations } from "./ssh-keys.js";
|
|
18
|
+
import { VolumeOperations } from "./volumes.js";
|
|
19
|
+
import { createHetznerError, } from "./errors.js";
|
|
20
|
+
import { parseRateLimitHeaders, } from "./actions.js";
|
|
21
|
+
export class HetznerClient {
|
|
22
|
+
apiToken;
|
|
23
|
+
constructor(apiToken) {
|
|
24
|
+
this.apiToken = resolveApiToken(apiToken);
|
|
25
|
+
// If no token from env or explicit, try CLI config
|
|
26
|
+
if (!this.apiToken) {
|
|
27
|
+
this.apiToken = getTokenFromCLI();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
get isAuthenticated() {
|
|
31
|
+
return isAuthenticated(this.apiToken);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Make a request to the Hetzner Cloud API
|
|
35
|
+
*
|
|
36
|
+
* @param endpoint - API endpoint (e.g., "/servers")
|
|
37
|
+
* @param options - RequestInit options
|
|
38
|
+
* @returns Parsed JSON response
|
|
39
|
+
* @throws {HetznerAPIError} On API errors
|
|
40
|
+
*/
|
|
41
|
+
async request(endpoint, options = {}) {
|
|
42
|
+
const response = await fetch(`${HETZNER_API_BASE}${endpoint}`, {
|
|
43
|
+
...options,
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
...options.headers,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
// Parse rate limit headers
|
|
51
|
+
const rateLimit = parseRateLimitHeaders(response.headers);
|
|
52
|
+
this.handleRateLimit(rateLimit);
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const body = await response.json().catch(() => ({}));
|
|
55
|
+
throw createHetznerError(response.status, body);
|
|
56
|
+
}
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validate Hetzner API response with Zod schema
|
|
62
|
+
*
|
|
63
|
+
* @param schema - Zod schema to validate against
|
|
64
|
+
* @param data - Data to validate
|
|
65
|
+
* @returns Validated data
|
|
66
|
+
*/
|
|
67
|
+
validateResponse(schema, data) {
|
|
68
|
+
const result = schema.safeParse(data);
|
|
69
|
+
if (result.success) {
|
|
70
|
+
return result.data;
|
|
71
|
+
}
|
|
72
|
+
// Log validation errors but don't throw to maintain backward compatibility
|
|
73
|
+
console.warn("Hetzner API response validation warning:", result.error.issues);
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Handle rate limit information from response headers
|
|
78
|
+
*
|
|
79
|
+
* @param rateLimit - Rate limit info from response
|
|
80
|
+
*/
|
|
81
|
+
handleRateLimit(rateLimit) {
|
|
82
|
+
if (!rateLimit)
|
|
83
|
+
return;
|
|
84
|
+
// Warn if rate limit is low
|
|
85
|
+
if (rateLimit.remaining < 100) {
|
|
86
|
+
console.warn(`[Hetzner API] Rate limit low: ${rateLimit.remaining}/${rateLimit.limit} remaining. Resets at ${new Date(rateLimit.reset * 1000).toISOString()}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Get current rate limit information
|
|
91
|
+
*
|
|
92
|
+
* Makes a lightweight request to check rate limit status
|
|
93
|
+
*
|
|
94
|
+
* @returns Rate limit info or null if not available
|
|
95
|
+
*/
|
|
96
|
+
async getRateLimit() {
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`${HETZNER_API_BASE}/servers`, {
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return parseRateLimitHeaders(response.headers);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
servers = new ServerOperations(this);
|
|
110
|
+
actions = new ActionOperations(this);
|
|
111
|
+
pricing = new PricingOperations(this);
|
|
112
|
+
ssh_keys = new SSHKeyOperations(this);
|
|
113
|
+
volumes = new VolumeOperations(this);
|
|
114
|
+
// Backward-compatible convenience methods (delegates to servers operations)
|
|
115
|
+
async listServers() {
|
|
116
|
+
return this.servers.list();
|
|
117
|
+
}
|
|
118
|
+
async getServer(id) {
|
|
119
|
+
return this.servers.get(id);
|
|
120
|
+
}
|
|
121
|
+
async createServer(options) {
|
|
122
|
+
return this.servers.create(options);
|
|
123
|
+
}
|
|
124
|
+
async deleteServer(id) {
|
|
125
|
+
return this.servers.delete(id);
|
|
126
|
+
}
|
|
127
|
+
async powerOn(id) {
|
|
128
|
+
return this.servers.powerOn(id);
|
|
129
|
+
}
|
|
130
|
+
async powerOff(id) {
|
|
131
|
+
return this.servers.powerOff(id);
|
|
132
|
+
}
|
|
133
|
+
async reboot(id) {
|
|
134
|
+
return this.servers.reboot(id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=client.js.map
|
package/client.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Cloud API client
|
|
3
|
+
* For server-side use only (requires API token)
|
|
4
|
+
*
|
|
5
|
+
* TODO: RE-REVIEW https://docs.hetzner.cloud/reference/cloud#authentication
|
|
6
|
+
* - https://tailscale.com/kb/1150/cloud-hetzner
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Explicitly import fetch for Bun compatibility
|
|
10
|
+
import { fetch as bunFetch } from "bun";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { HETZNER_API_BASE } from "./config.js";
|
|
13
|
+
|
|
14
|
+
// Use Bun's fetch if available, otherwise try global fetch
|
|
15
|
+
const fetch = bunFetch || (globalThis as any).fetch;
|
|
16
|
+
import { resolveApiToken, getTokenFromCLI, isAuthenticated } from "./auth.js";
|
|
17
|
+
import { ServerOperations } from "./servers.js";
|
|
18
|
+
import { ActionOperations } from "./actions.js";
|
|
19
|
+
import { PricingOperations } from "./pricing.js";
|
|
20
|
+
import { SSHKeyOperations } from "./ssh-keys.js";
|
|
21
|
+
import { VolumeOperations } from "./volumes.js";
|
|
22
|
+
import {
|
|
23
|
+
HetznerListServersResponseSchema,
|
|
24
|
+
HetznerGetServerResponseSchema,
|
|
25
|
+
HetznerCreateServerResponseSchema,
|
|
26
|
+
} from "./schemas.js";
|
|
27
|
+
import {
|
|
28
|
+
createHetznerError,
|
|
29
|
+
isRateLimitError,
|
|
30
|
+
} from "./errors.js";
|
|
31
|
+
import {
|
|
32
|
+
parseRateLimitHeaders,
|
|
33
|
+
waitForRateLimitReset,
|
|
34
|
+
} from "./actions.js";
|
|
35
|
+
import type { RateLimitInfo } from "./types.js";
|
|
36
|
+
|
|
37
|
+
export class HetznerClient {
|
|
38
|
+
private apiToken: string;
|
|
39
|
+
|
|
40
|
+
constructor(apiToken?: string) {
|
|
41
|
+
this.apiToken = resolveApiToken(apiToken);
|
|
42
|
+
|
|
43
|
+
// If no token from env or explicit, try CLI config
|
|
44
|
+
if (!this.apiToken) {
|
|
45
|
+
this.apiToken = getTokenFromCLI();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get isAuthenticated(): boolean {
|
|
50
|
+
return isAuthenticated(this.apiToken);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Make a request to the Hetzner Cloud API
|
|
55
|
+
*
|
|
56
|
+
* @param endpoint - API endpoint (e.g., "/servers")
|
|
57
|
+
* @param options - RequestInit options
|
|
58
|
+
* @returns Parsed JSON response
|
|
59
|
+
* @throws {HetznerAPIError} On API errors
|
|
60
|
+
*/
|
|
61
|
+
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
62
|
+
const response = await fetch(`${HETZNER_API_BASE}${endpoint}`, {
|
|
63
|
+
...options,
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
...options.headers,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Parse rate limit headers
|
|
72
|
+
const rateLimit = parseRateLimitHeaders(response.headers);
|
|
73
|
+
this.handleRateLimit(rateLimit);
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const body = await response.json().catch(() => ({}));
|
|
77
|
+
throw createHetznerError(response.status, body);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const data = await response.json();
|
|
81
|
+
return data as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate Hetzner API response with Zod schema
|
|
86
|
+
*
|
|
87
|
+
* @param schema - Zod schema to validate against
|
|
88
|
+
* @param data - Data to validate
|
|
89
|
+
* @returns Validated data
|
|
90
|
+
*/
|
|
91
|
+
private validateResponse<T>(schema: z.ZodType<T>, data: unknown): T {
|
|
92
|
+
const result = schema.safeParse(data);
|
|
93
|
+
if (result.success) {
|
|
94
|
+
return result.data;
|
|
95
|
+
}
|
|
96
|
+
// Log validation errors but don't throw to maintain backward compatibility
|
|
97
|
+
console.warn(
|
|
98
|
+
"Hetzner API response validation warning:",
|
|
99
|
+
result.error.issues
|
|
100
|
+
);
|
|
101
|
+
return data as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Handle rate limit information from response headers
|
|
106
|
+
*
|
|
107
|
+
* @param rateLimit - Rate limit info from response
|
|
108
|
+
*/
|
|
109
|
+
private handleRateLimit(rateLimit: RateLimitInfo | null): void {
|
|
110
|
+
if (!rateLimit) return;
|
|
111
|
+
|
|
112
|
+
// Warn if rate limit is low
|
|
113
|
+
if (rateLimit.remaining < 100) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`[Hetzner API] Rate limit low: ${rateLimit.remaining}/${rateLimit.limit} remaining. Resets at ${new Date(rateLimit.reset * 1000).toISOString()}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get current rate limit information
|
|
122
|
+
*
|
|
123
|
+
* Makes a lightweight request to check rate limit status
|
|
124
|
+
*
|
|
125
|
+
* @returns Rate limit info or null if not available
|
|
126
|
+
*/
|
|
127
|
+
async getRateLimit(): Promise<RateLimitInfo | null> {
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(`${HETZNER_API_BASE}/servers`, {
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return parseRateLimitHeaders(response.headers);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
readonly servers = new ServerOperations(this);
|
|
141
|
+
readonly actions = new ActionOperations(this);
|
|
142
|
+
readonly pricing = new PricingOperations(this);
|
|
143
|
+
readonly ssh_keys = new SSHKeyOperations(this);
|
|
144
|
+
readonly volumes = new VolumeOperations(this);
|
|
145
|
+
|
|
146
|
+
// Backward-compatible convenience methods (delegates to servers operations)
|
|
147
|
+
async listServers() {
|
|
148
|
+
return this.servers.list();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getServer(id: number) {
|
|
152
|
+
return this.servers.get(id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async createServer(options: import("./types.js").CreateServerOptions) {
|
|
156
|
+
return this.servers.create(options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async deleteServer(id: number) {
|
|
160
|
+
return this.servers.delete(id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async powerOn(id: number) {
|
|
164
|
+
return this.servers.powerOn(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async powerOff(id: number) {
|
|
168
|
+
return this.servers.powerOff(id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async reboot(id: number) {
|
|
172
|
+
return this.servers.reboot(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Re-export types for convenience
|
|
177
|
+
export type * from "./types.js";
|