@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.
Files changed (46) hide show
  1. package/actions.js +802 -0
  2. package/actions.ts +1053 -0
  3. package/auth.js +35 -0
  4. package/auth.ts +37 -0
  5. package/bootstrap/FIREWALL.md +326 -0
  6. package/bootstrap/KERNEL-HARDENING.md +258 -0
  7. package/bootstrap/SECURITY-INTEGRATION.md +281 -0
  8. package/bootstrap/TESTING.md +301 -0
  9. package/bootstrap/cloud-init.js +279 -0
  10. package/bootstrap/cloud-init.ts +394 -0
  11. package/bootstrap/firewall.js +279 -0
  12. package/bootstrap/firewall.ts +342 -0
  13. package/bootstrap/genesis.js +406 -0
  14. package/bootstrap/genesis.ts +518 -0
  15. package/bootstrap/index.js +35 -0
  16. package/bootstrap/index.ts +71 -0
  17. package/bootstrap/kernel-hardening.js +266 -0
  18. package/bootstrap/kernel-hardening.test.ts +230 -0
  19. package/bootstrap/kernel-hardening.ts +272 -0
  20. package/bootstrap/security-audit.js +118 -0
  21. package/bootstrap/security-audit.ts +124 -0
  22. package/bootstrap/ssh-hardening.js +182 -0
  23. package/bootstrap/ssh-hardening.ts +192 -0
  24. package/client.js +137 -0
  25. package/client.ts +177 -0
  26. package/config.js +5 -0
  27. package/config.ts +5 -0
  28. package/errors.js +270 -0
  29. package/errors.ts +371 -0
  30. package/index.js +28 -0
  31. package/index.ts +55 -0
  32. package/package.json +56 -0
  33. package/pricing.js +284 -0
  34. package/pricing.ts +422 -0
  35. package/schemas.js +660 -0
  36. package/schemas.ts +765 -0
  37. package/server-status.ts +81 -0
  38. package/servers.js +424 -0
  39. package/servers.ts +568 -0
  40. package/ssh-keys.js +90 -0
  41. package/ssh-keys.ts +122 -0
  42. package/ssh-setup.ts +218 -0
  43. package/types.js +96 -0
  44. package/types.ts +389 -0
  45. package/volumes.js +172 -0
  46. 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";