@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
package/ssh-keys.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Hetzner SSH key operations
3
+ */
4
+
5
+ import { z } from "zod";
6
+ import type {
7
+ HetznerSSHKey,
8
+ CreateSSHKeyOptions,
9
+ } from "./types.js";
10
+ import type { HetznerClient } from "./client.js";
11
+ import {
12
+ HetznerListSSHKeysResponseSchema,
13
+ HetznerGetSSHKeyResponseSchema,
14
+ HetznerCreateSSHKeyRequestSchema,
15
+ HetznerCreateSSHKeyResponseSchema,
16
+ } from "./schemas.js";
17
+
18
+ export class SSHKeyOperations {
19
+ constructor(private client: HetznerClient) {}
20
+
21
+ /**
22
+ * List all SSH keys
23
+ */
24
+ async list(): Promise<HetznerSSHKey[]> {
25
+ const response = await this.client.request<{ ssh_keys: HetznerSSHKey[] }>(
26
+ "/ssh_keys",
27
+ );
28
+
29
+ // Validate response with Zod
30
+ const validated = HetznerListSSHKeysResponseSchema.safeParse(response);
31
+ if (!validated.success) {
32
+ console.warn('Hetzner list SSH keys validation warning:', validated.error.issues);
33
+ return response.ssh_keys; // Return unvalidated data for backward compatibility
34
+ }
35
+
36
+ return validated.data.ssh_keys;
37
+ }
38
+
39
+ /**
40
+ * Get a specific SSH key by ID or name
41
+ */
42
+ async get(idOrName: number | string): Promise<HetznerSSHKey> {
43
+ const endpoint = typeof idOrName === 'number'
44
+ ? `/ssh_keys/${idOrName}`
45
+ : `/ssh_keys?name=${encodeURIComponent(idOrName)}`;
46
+
47
+ const response = await this.client.request<{ ssh_key: HetznerSSHKey }>(
48
+ endpoint,
49
+ );
50
+
51
+ // Validate response with Zod
52
+ const validated = HetznerGetSSHKeyResponseSchema.safeParse(response);
53
+ if (!validated.success) {
54
+ console.warn('Hetzner get SSH key validation warning:', validated.error.issues);
55
+ return response.ssh_key; // Return unvalidated data for backward compatibility
56
+ }
57
+
58
+ return validated.data.ssh_key;
59
+ }
60
+
61
+ /**
62
+ * Create a new SSH key
63
+ *
64
+ * @param options - SSH key creation options
65
+ * @returns Created SSH key
66
+ */
67
+ async create(options: CreateSSHKeyOptions): Promise<HetznerSSHKey> {
68
+ // Validate input with Zod
69
+ const validatedOptions = HetznerCreateSSHKeyRequestSchema.safeParse(options);
70
+ if (!validatedOptions.success) {
71
+ throw new Error(`Invalid SSH key options: ${validatedOptions.error.issues.map(i => i.message).join(', ')}`);
72
+ }
73
+
74
+ const body = {
75
+ name: validatedOptions.data.name,
76
+ public_key: validatedOptions.data.public_key,
77
+ ...(validatedOptions.data.labels && { labels: validatedOptions.data.labels }),
78
+ };
79
+
80
+ const response = await this.client.request<{ ssh_key: HetznerSSHKey }>(
81
+ "/ssh_keys",
82
+ {
83
+ method: "POST",
84
+ body: JSON.stringify(body),
85
+ }
86
+ );
87
+
88
+ // Validate response with Zod
89
+ const validated = HetznerCreateSSHKeyResponseSchema.safeParse(response);
90
+ if (!validated.success) {
91
+ console.warn('Hetzner create SSH key validation warning:', validated.error.issues);
92
+ return response.ssh_key; // Return unvalidated data for backward compatibility
93
+ }
94
+
95
+ return validated.data.ssh_key;
96
+ }
97
+
98
+ /**
99
+ * Delete an SSH key
100
+ *
101
+ * @param id - SSH key ID
102
+ */
103
+ async delete(id: number): Promise<void> {
104
+ await this.client.request(
105
+ `/ssh_keys/${id}`,
106
+ { method: "DELETE" }
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Find an SSH key by name
112
+ * Returns undefined if not found
113
+ */
114
+ async findByName(name: string): Promise<HetznerSSHKey | undefined> {
115
+ try {
116
+ const keys = await this.list();
117
+ return keys.find(key => key.name === name);
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ }
package/ssh-setup.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * SSH Key Management for Hetzner
3
+ *
4
+ * This module ensures SSH keys are properly configured between:
5
+ * 1. Local machine (~/.ssh/)
6
+ * 2. Hetzner Cloud API
7
+ *
8
+ * PROBLEM:
9
+ * - Creating random keys in Hetzner doesn't work because we need the matching private key locally
10
+ * - Password auth is unreliable and often disabled
11
+ * - IP reuse causes known_hosts conflicts
12
+ *
13
+ * SOLUTION:
14
+ * - Always use existing local keys or create new key pairs
15
+ * - Upload public key to Hetzner, keep private key local
16
+ */
17
+
18
+ import { existsSync, readFileSync, writeFileSync } from "fs";
19
+ import { join } from "path";
20
+ import crypto from "crypto";
21
+ import { SSHPresets, SSHFlags, buildSSHArgs, sshConfig } from "@codespaces/ssh";
22
+
23
+ interface SSHKey {
24
+ id: number;
25
+ name: string;
26
+ fingerprint: string;
27
+ public_key: string;
28
+ }
29
+
30
+ interface LocalKeyPair {
31
+ name: string;
32
+ publicKey: string;
33
+ privateKeyPath: string;
34
+ }
35
+
36
+ const HETZNER_SSH_DIR = join(process.env.HOME || "", ".ssh");
37
+ const HETZNER_KEY_PREFIX = "hetzner-codespaces";
38
+
39
+ /**
40
+ * Get or create a local SSH key pair for Hetzner
41
+ * Returns the key name to use with Hetzner API
42
+ */
43
+ export async function getOrCreateHetznerSSHKey(): Promise<LocalKeyPair> {
44
+ // Check for existing Hetzner keys
45
+ const existingKey = findExistingHetznerKey();
46
+ if (existingKey) {
47
+ console.log(`✓ Using existing SSH key: ${existingKey.name}`);
48
+ return existingKey;
49
+ }
50
+
51
+ // Create new key pair
52
+ console.log("Creating new SSH key pair for Hetzner...");
53
+ return createNewKeyPair();
54
+ }
55
+
56
+ /**
57
+ * Find existing Hetzner SSH key in local ~/.ssh/
58
+ */
59
+ function findExistingHetznerKey(): LocalKeyPair | null {
60
+ const privateKeyPath = join(HETZNER_SSH_DIR, `${HETZNER_KEY_PREFIX}`);
61
+ const publicKeyPath = `${privateKeyPath}.pub`;
62
+
63
+ if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) {
64
+ return null;
65
+ }
66
+
67
+ const publicKey = readFileSync(publicKeyPath, "utf-8").trim();
68
+
69
+ return {
70
+ name: HETZNER_KEY_PREFIX,
71
+ publicKey,
72
+ privateKeyPath,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Create a new SSH key pair for Hetzner
78
+ */
79
+ function createNewKeyPair(): LocalKeyPair {
80
+ const keyName = `${HETZNER_KEY_PREFIX}-${Date.now()}`;
81
+ const privateKeyPath = join(HETZNER_SSH_DIR, keyName);
82
+ const publicKeyPath = `${privateKeyPath}.pub`;
83
+
84
+ // Generate new ed25519 key pair
85
+ try {
86
+ // Bun.spawn automatically escapes arguments to prevent shell injection
87
+ Bun.spawnSync(["ssh-keygen", "-t", "ed25519", "-f", privateKeyPath, "-N", "", "-C", keyName], {
88
+ stdout: "ignore",
89
+ stderr: "ignore",
90
+ });
91
+
92
+ // Set proper permissions
93
+ Bun.spawnSync(["chmod", "600", privateKeyPath], { stdout: "ignore", stderr: "ignore" });
94
+ Bun.spawnSync(["chmod", "644", publicKeyPath], { stdout: "ignore", stderr: "ignore" });
95
+
96
+ const publicKey = readFileSync(publicKeyPath, "utf-8").trim();
97
+
98
+ console.log(`✓ Created new SSH key: ${keyName}`);
99
+ console.log(` Private: ${privateKeyPath}`);
100
+ console.log(` Public: ${publicKeyPath}`);
101
+
102
+ return {
103
+ name: keyName,
104
+ publicKey,
105
+ privateKeyPath,
106
+ };
107
+ } catch (error) {
108
+ throw new Error(`Failed to create SSH key: ${error}`);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Ensure SSH key exists in Hetzner
114
+ * Returns the SSH key name to use when creating servers
115
+ */
116
+ export async function ensureHetznerSSHKey(
117
+ hetznerAPI: (endpoint: string, method: string, body?: unknown) => Promise<Response>
118
+ ): Promise<string> {
119
+ // 1. Get or create local key pair
120
+ const localKey = await getOrCreateHetznerSSHKey();
121
+
122
+ // 2. Check if key exists in Hetzner
123
+ const existingKeys = await hetznerAPI("/ssh_keys", "GET", null)
124
+ .then((r) => r.json())
125
+ .then((data) => data.ssh_keys as SSHKey[]);
126
+
127
+ // 3. Find matching key by public key
128
+ const matchingKey = existingKeys.find((k) => k.public_key.trim() === localKey.publicKey.trim());
129
+
130
+ if (matchingKey) {
131
+ console.log(`✓ SSH key already exists in Hetzner: ${matchingKey.name} (${matchingKey.id})`);
132
+ return matchingKey.name; // Use existing key name
133
+ }
134
+
135
+ // 4. Upload new key to Hetzner
136
+ console.log(`Uploading SSH key to Hetzner: ${localKey.name}...`);
137
+ const createResponse = await hetznerAPI("/ssh_keys", "POST", {
138
+ name: localKey.name,
139
+ public_key: localKey.publicKey,
140
+ });
141
+
142
+ if (!createResponse.ok) {
143
+ const error = await createResponse.text();
144
+ throw new Error(`Failed to upload SSH key to Hetzner: ${error}`);
145
+ }
146
+
147
+ const createdKey = (await createResponse.json()) as SSHKey;
148
+ console.log(`✓ SSH key uploaded: ${createdKey.name} (${createdKey.id})`);
149
+
150
+ return createdKey.name;
151
+ }
152
+
153
+ /**
154
+ * Clear known_hosts entry for an IP (to fix IP reuse issues)
155
+ */
156
+ export function clearKnownHosts(ip: string): void {
157
+ try {
158
+ // Bun.spawn automatically escapes arguments to prevent shell injection
159
+ Bun.spawnSync(["ssh-keygen", "-R", ip], { stdout: "ignore", stderr: "ignore" });
160
+ console.log(`✓ Cleared known_hosts entry for ${ip}`);
161
+ } catch {
162
+ // Ignore if entry doesn't exist
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Test SSH connection to a server using typed flags
168
+ */
169
+ export async function testSSHConnection(
170
+ ip: string,
171
+ privateKeyPath: string,
172
+ username: string = "root"
173
+ ): Promise<boolean> {
174
+ try {
175
+ // Build SSH args using typed flags
176
+ const flags = [
177
+ ...SSHPresets.default,
178
+ SSHFlags.identity(privateKeyPath),
179
+ sshConfig("ConnectTimeout", "10"),
180
+ ];
181
+
182
+ const sshArgs = buildSSHArgs(flags, ip, username);
183
+ sshArgs.push("echo", "connected");
184
+
185
+ // Bun.spawn automatically escapes arguments to prevent shell injection
186
+ const proc = Bun.spawn(sshArgs, {
187
+ stdout: "ignore",
188
+ stderr: "ignore",
189
+ });
190
+
191
+ // Wait for process to complete with timeout
192
+ const timeout = setTimeout(() => proc.kill(), 15000);
193
+ await proc.exited;
194
+ clearTimeout(timeout);
195
+
196
+ return proc.exitCode === 0;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Full workflow: Prepare SSH keys before creating servers
204
+ */
205
+ export async function prepareSSHKeys(
206
+ hetznerAPI: (endpoint: string, method: string, body?: unknown) => Promise<Response>
207
+ ): Promise<{
208
+ sshKeyName: string;
209
+ privateKeyPath: string;
210
+ }> {
211
+ const sshKeyName = await ensureHetznerSSHKey(hetznerAPI);
212
+ const localKey = await getOrCreateHetznerSSHKey();
213
+
214
+ return {
215
+ sshKeyName,
216
+ privateKeyPath: localKey.privateKeyPath,
217
+ };
218
+ }
package/types.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Hetzner Cloud API types
3
+ */
4
+ // ============================================================================
5
+ // Import shared status enums
6
+ // ============================================================================
7
+ import { EnvironmentStatus, ActionStatus, VolumeStatus, } from "@ebowwa/codespaces-types/compile";
8
+ // Re-export for convenience
9
+ export { EnvironmentStatus, ActionStatus, VolumeStatus };
10
+ // ============================================================================
11
+ // Action Types
12
+ // ============================================================================
13
+ /**
14
+ * Action command types from Hetzner Cloud API
15
+ */
16
+ export var ActionCommand;
17
+ (function (ActionCommand) {
18
+ // Server actions
19
+ ActionCommand["CreateServer"] = "create_server";
20
+ ActionCommand["DeleteServer"] = "delete_server";
21
+ ActionCommand["StartServer"] = "start_server";
22
+ ActionCommand["StopServer"] = "stop_server";
23
+ ActionCommand["RebootServer"] = "reboot_server";
24
+ ActionCommand["ResetServer"] = "reset_server";
25
+ ActionCommand["ShutdownServer"] = "shutdown_server";
26
+ ActionCommand["Poweroff"] = "poweroff";
27
+ ActionCommand["ChangeServerType"] = "change_server_type";
28
+ ActionCommand["RebuildServer"] = "rebuild_server";
29
+ ActionCommand["EnableBackup"] = "enable_backup";
30
+ ActionCommand["DisableBackup"] = "disable_backup";
31
+ ActionCommand["CreateImage"] = "create_image";
32
+ ActionCommand["ChangeDnsPtr"] = "change_dns_ptr";
33
+ ActionCommand["AttachToNetwork"] = "attach_to_network";
34
+ ActionCommand["DetachFromNetwork"] = "detach_from_network";
35
+ ActionCommand["ChangeAliasIps"] = "change_alias_ips";
36
+ ActionCommand["EnableRescue"] = "enable_rescue";
37
+ ActionCommand["DisableRescue"] = "disable_rescue";
38
+ ActionCommand["ChangeProtection"] = "change_protection";
39
+ // Volume actions
40
+ ActionCommand["CreateVolume"] = "create_volume";
41
+ ActionCommand["DeleteVolume"] = "delete_volume";
42
+ ActionCommand["AttachVolume"] = "attach_volume";
43
+ ActionCommand["DetachVolume"] = "detach_volume";
44
+ ActionCommand["ResizeVolume"] = "resize_volume";
45
+ ActionCommand["VolumeChangeProtection"] = "volume_change_protection";
46
+ // Network actions
47
+ ActionCommand["AddSubnet"] = "add_subnet";
48
+ ActionCommand["DeleteSubnet"] = "delete_subnet";
49
+ ActionCommand["AddRoute"] = "add_route";
50
+ ActionCommand["DeleteRoute"] = "delete_route";
51
+ ActionCommand["ChangeIpRange"] = "change_ip_range";
52
+ ActionCommand["NetworkChangeProtection"] = "network_change_protection";
53
+ // Floating IP actions
54
+ ActionCommand["AssignFloatingIp"] = "assign_floating_ip";
55
+ ActionCommand["UnassignFloatingIp"] = "unassign_floating_ip";
56
+ ActionCommand["FloatingIpChangeDnsPtr"] = "floating_ip_change_dns_ptr";
57
+ ActionCommand["FloatingIpChangeProtection"] = "floating_ip_change_protection";
58
+ // Load Balancer actions
59
+ ActionCommand["CreateLoadBalancer"] = "create_load_balancer";
60
+ ActionCommand["DeleteLoadBalancer"] = "delete_load_balancer";
61
+ ActionCommand["AddTarget"] = "add_target";
62
+ ActionCommand["RemoveTarget"] = "remove_target";
63
+ ActionCommand["AddService"] = "add_service";
64
+ ActionCommand["UpdateService"] = "update_service";
65
+ ActionCommand["DeleteService"] = "delete_service";
66
+ ActionCommand["LoadBalancerAttachToNetwork"] = "load_balancer_attach_to_network";
67
+ ActionCommand["LoadBalancerDetachFromNetwork"] = "load_balancer_detach_from_network";
68
+ ActionCommand["ChangeAlgorithm"] = "change_algorithm";
69
+ ActionCommand["ChangeType"] = "change_type";
70
+ ActionCommand["LoadBalancerChangeProtection"] = "load_balancer_change_protection";
71
+ // Certificate actions
72
+ ActionCommand["IssueCertificate"] = "issue_certificate";
73
+ ActionCommand["RetryCertificate"] = "retry_certificate";
74
+ // Firewall actions
75
+ ActionCommand["SetFirewallRules"] = "set_firewall_rules";
76
+ ActionCommand["ApplyFirewall"] = "apply_firewall";
77
+ ActionCommand["RemoveFirewall"] = "remove_firewall";
78
+ ActionCommand["FirewallChangeProtection"] = "firewall_change_protection";
79
+ // Image actions
80
+ ActionCommand["ImageChangeProtection"] = "image_change_protection";
81
+ })(ActionCommand || (ActionCommand = {}));
82
+ /**
83
+ * Resource types that can be affected by actions
84
+ */
85
+ export var ResourceType;
86
+ (function (ResourceType) {
87
+ ResourceType["Server"] = "server";
88
+ ResourceType["Volume"] = "volume";
89
+ ResourceType["Network"] = "network";
90
+ ResourceType["FloatingIp"] = "floating_ip";
91
+ ResourceType["LoadBalancer"] = "load_balancer";
92
+ ResourceType["Certificate"] = "certificate";
93
+ ResourceType["Firewall"] = "firewall";
94
+ ResourceType["Image"] = "image";
95
+ })(ResourceType || (ResourceType = {}));
96
+ //# sourceMappingURL=types.js.map