@peerbit/server 5.9.6 → 5.10.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/dist/src/aws.d.ts.map +1 -1
- package/dist/src/aws.js +2 -1
- package/dist/src/aws.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +142 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +7 -1
- package/dist/src/client.js.map +1 -1
- package/dist/src/docker.d.ts.map +1 -1
- package/dist/src/docker.js +122 -32
- package/dist/src/docker.js.map +1 -1
- package/dist/src/hetzner.browser.d.ts +2 -0
- package/dist/src/hetzner.browser.d.ts.map +1 -0
- package/dist/src/hetzner.browser.js +3 -0
- package/dist/src/hetzner.browser.js.map +1 -0
- package/dist/src/hetzner.d.ts +26 -0
- package/dist/src/hetzner.d.ts.map +1 -0
- package/dist/src/hetzner.js +161 -0
- package/dist/src/hetzner.js.map +1 -0
- package/dist/src/remotes.d.ts +6 -1
- package/dist/src/remotes.d.ts.map +1 -1
- package/dist/src/remotes.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/ui/assets/{index-BEgs1gyX.js → index-Dst3R_bZ.js} +6 -6
- package/dist/ui/index.html +1 -1
- package/package.json +3 -2
- package/src/aws.ts +2 -1
- package/src/cli.ts +164 -1
- package/src/client.ts +6 -1
- package/src/docker.ts +139 -31
- package/src/hetzner.browser.ts +1 -0
- package/src/hetzner.ts +242 -0
- package/src/remotes.ts +7 -1
package/dist/ui/index.html
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
Learn how to configure a non-root public URL by running `npm run build`.
|
|
24
24
|
-->
|
|
25
25
|
<title>Peerbit</title>
|
|
26
|
-
<script type="module" crossorigin src="/assets/index-
|
|
26
|
+
<script type="module" crossorigin src="/assets/index-Dst3R_bZ.js"></script>
|
|
27
27
|
<link rel="stylesheet" crossorigin href="/assets/index-CIfVvUo9.css">
|
|
28
28
|
</head>
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peerbit/server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.0",
|
|
4
4
|
"author": "dao.xyz",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"./remotes.js": "./dist/src/remotes.browser.js",
|
|
27
27
|
"./dist/src/docker.js": "./dist/src/docker.browser.js",
|
|
28
28
|
"./docker.js": "./dist/src/docker.browser.js",
|
|
29
|
-
"./dist/src/aws.js": "./dist/src/aws.browser.js"
|
|
29
|
+
"./dist/src/aws.js": "./dist/src/aws.browser.js",
|
|
30
|
+
"./dist/src/hetzner.js": "./dist/src/hetzner.browser.js"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"dist",
|
package/src/aws.ts
CHANGED
|
@@ -57,6 +57,7 @@ const setupUserData = (
|
|
|
57
57
|
serverVersion?: string,
|
|
58
58
|
) => {
|
|
59
59
|
const peerIdStrings = grantAccess.map((x) => x.toString());
|
|
60
|
+
const grantArgs = peerIdStrings.map((key) => `--ga ${key}`).join(" ");
|
|
60
61
|
|
|
61
62
|
// better-sqlite3 force use to install build-essentials for `make` command, TOOD dont bundle better-sqlite3 by default?
|
|
62
63
|
const versionSpec = serverVersion ? `@${serverVersion}` : "";
|
|
@@ -67,7 +68,7 @@ sudo apt-get install -y nodejs
|
|
|
67
68
|
sudo apt-get install -y build-essential
|
|
68
69
|
npm install -g @peerbit/server${versionSpec}
|
|
69
70
|
sudo peerbit domain test --email ${email}
|
|
70
|
-
peerbit start ${
|
|
71
|
+
peerbit start ${grantArgs} > log.txt 2>&1 &
|
|
71
72
|
`;
|
|
72
73
|
};
|
|
73
74
|
const PURPOSE_TAG_NAME = "Purpose";
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
loadConfig,
|
|
31
31
|
startCertbot,
|
|
32
32
|
} from "./domain.js";
|
|
33
|
+
import {
|
|
34
|
+
HETZNER_SERVER_TYPES,
|
|
35
|
+
launchNodes as launchHetznerNodes,
|
|
36
|
+
terminateNode as terminateHetznerNode,
|
|
37
|
+
} from "./hetzner.js";
|
|
33
38
|
import { DEFAULT_REMOTE_GROUP, type RemoteObject, Remotes } from "./remotes.js";
|
|
34
39
|
import { LOCAL_API_PORT } from "./routes.js";
|
|
35
40
|
import { startServerWithNode } from "./server.js";
|
|
@@ -457,6 +462,152 @@ export const cli = async (args?: string[]) => {
|
|
|
457
462
|
}
|
|
458
463
|
},
|
|
459
464
|
})
|
|
465
|
+
.command({
|
|
466
|
+
command: "hetzner",
|
|
467
|
+
describe: "Spawn remote nodes on Hetzner Cloud",
|
|
468
|
+
builder: (hetznerArgs: Argv) => {
|
|
469
|
+
hetznerArgs.option("count", {
|
|
470
|
+
describe: "Amount of nodes to spawn",
|
|
471
|
+
defaultDescription: "One node",
|
|
472
|
+
type: "number",
|
|
473
|
+
alias: "c",
|
|
474
|
+
default: 1,
|
|
475
|
+
});
|
|
476
|
+
hetznerArgs.option("location", {
|
|
477
|
+
describe: "Location (e.g. fsn1, nbg1, hel1, ash, hil)",
|
|
478
|
+
type: "string",
|
|
479
|
+
alias: "l",
|
|
480
|
+
default: "fsn1",
|
|
481
|
+
});
|
|
482
|
+
hetznerArgs.option("group", {
|
|
483
|
+
describe: "Remote group to launch nodes in",
|
|
484
|
+
type: "string",
|
|
485
|
+
alias: "g",
|
|
486
|
+
default: DEFAULT_REMOTE_GROUP,
|
|
487
|
+
});
|
|
488
|
+
hetznerArgs.option("server-type", {
|
|
489
|
+
describe: "Server type",
|
|
490
|
+
type: "string",
|
|
491
|
+
alias: "t",
|
|
492
|
+
choices: [...HETZNER_SERVER_TYPES],
|
|
493
|
+
default: "cx11",
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
hetznerArgs.option("name", {
|
|
497
|
+
describe: "Name prefix for spawned nodes",
|
|
498
|
+
type: "string",
|
|
499
|
+
alias: "n",
|
|
500
|
+
default: "peerbit-node",
|
|
501
|
+
});
|
|
502
|
+
hetznerArgs.option("grant-access", {
|
|
503
|
+
describe: "Grant access to public keys on start",
|
|
504
|
+
defaultDescription:
|
|
505
|
+
"The publickey of this device located in 'directory'",
|
|
506
|
+
type: "string",
|
|
507
|
+
array: true,
|
|
508
|
+
alias: "ga",
|
|
509
|
+
});
|
|
510
|
+
hetznerArgs.option("directory", {
|
|
511
|
+
describe: "Peerbit directory",
|
|
512
|
+
defaultDescription: "~.peerbit",
|
|
513
|
+
type: "string",
|
|
514
|
+
alias: "d",
|
|
515
|
+
default: getHomeConfigDir(),
|
|
516
|
+
});
|
|
517
|
+
hetznerArgs.option("email", {
|
|
518
|
+
describe: "Email for Let's security messages",
|
|
519
|
+
type: "string",
|
|
520
|
+
alias: "e",
|
|
521
|
+
demandOption: true,
|
|
522
|
+
});
|
|
523
|
+
hetznerArgs.option("token", {
|
|
524
|
+
describe: "Hetzner Cloud API token (or set HCLOUD_TOKEN)",
|
|
525
|
+
type: "string",
|
|
526
|
+
alias: ["tok"],
|
|
527
|
+
});
|
|
528
|
+
hetznerArgs.option("image", {
|
|
529
|
+
describe: "Image to use (e.g. ubuntu-22.04)",
|
|
530
|
+
type: "string",
|
|
531
|
+
default: "ubuntu-22.04",
|
|
532
|
+
});
|
|
533
|
+
hetznerArgs.option("server-version", {
|
|
534
|
+
describe:
|
|
535
|
+
"@peerbit/server version or tag to install on the instance (e.g. 5.7.0-58d3d09)",
|
|
536
|
+
type: "string",
|
|
537
|
+
alias: ["sv"],
|
|
538
|
+
});
|
|
539
|
+
return hetznerArgs;
|
|
540
|
+
},
|
|
541
|
+
handler: async (args) => {
|
|
542
|
+
const self = (
|
|
543
|
+
await getKeypair(args.directory)
|
|
544
|
+
).publicKey.toPeerId();
|
|
545
|
+
const accessGrant: PeerId[] =
|
|
546
|
+
args["grant-access"]?.length > 0
|
|
547
|
+
? (args["grant-access"] as string[]).map((x) =>
|
|
548
|
+
peerIdFromString(x),
|
|
549
|
+
)
|
|
550
|
+
: [];
|
|
551
|
+
accessGrant.push(self);
|
|
552
|
+
const nodes = await launchHetznerNodes({
|
|
553
|
+
token: (args.token as string) || undefined,
|
|
554
|
+
email: args.email as string,
|
|
555
|
+
count: args.count,
|
|
556
|
+
namePrefix: args.name,
|
|
557
|
+
location: args.location,
|
|
558
|
+
serverType: args["server-type"],
|
|
559
|
+
grantAccess: accessGrant,
|
|
560
|
+
image: args.image,
|
|
561
|
+
serverVersion:
|
|
562
|
+
(args["server-version"] as string) || undefined,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
console.log(
|
|
566
|
+
`Waiting for ${args.count} ${
|
|
567
|
+
args.count > 1 ? "nodes" : "node"
|
|
568
|
+
} to spawn. This might take a few minutes. You can watch the progress in your Hetzner Cloud console.`,
|
|
569
|
+
);
|
|
570
|
+
const twirlTimer = (function () {
|
|
571
|
+
const P = ["\\", "|", "/", "-"];
|
|
572
|
+
let x = 0;
|
|
573
|
+
return setInterval(function () {
|
|
574
|
+
process.stdout.write(
|
|
575
|
+
"\r" + "Loading: " + chalk.hex(colors[x])(P[x++]),
|
|
576
|
+
);
|
|
577
|
+
x &= 3;
|
|
578
|
+
}, 250);
|
|
579
|
+
})();
|
|
580
|
+
for (const node of nodes) {
|
|
581
|
+
try {
|
|
582
|
+
const domain = await waitForDomain(node.publicIp);
|
|
583
|
+
const remotes = new Remotes(getRemotesPath(args.directory));
|
|
584
|
+
remotes.add({
|
|
585
|
+
name: node.name,
|
|
586
|
+
address: domain,
|
|
587
|
+
group: args.group,
|
|
588
|
+
origin: {
|
|
589
|
+
type: "hetzner",
|
|
590
|
+
serverId: node.serverId,
|
|
591
|
+
location: node.location,
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
} catch (error: any) {
|
|
595
|
+
process.stdout.write("\r");
|
|
596
|
+
console.error(
|
|
597
|
+
`Error waiting for domain for ip: ${
|
|
598
|
+
node.publicIp
|
|
599
|
+
} to be available: ${error?.toString()}`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
process.stdout.write("\r");
|
|
604
|
+
clearInterval(twirlTimer);
|
|
605
|
+
console.log(`New nodes available (${nodes.length}):`);
|
|
606
|
+
for (const node of nodes) {
|
|
607
|
+
console.log(chalk.green(node.name));
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
})
|
|
460
611
|
.strict()
|
|
461
612
|
.demandCommand();
|
|
462
613
|
})
|
|
@@ -469,6 +620,11 @@ export const cli = async (args?: string[]) => {
|
|
|
469
620
|
type: "boolean",
|
|
470
621
|
default: false,
|
|
471
622
|
});
|
|
623
|
+
killArgs.option("token", {
|
|
624
|
+
describe: "Used for Hetzner Cloud API (or set HCLOUD_TOKEN)",
|
|
625
|
+
type: "string",
|
|
626
|
+
alias: ["tok"],
|
|
627
|
+
});
|
|
472
628
|
killArgs.positional("name", {
|
|
473
629
|
type: "string",
|
|
474
630
|
describe: "Remote name",
|
|
@@ -495,6 +651,11 @@ export const cli = async (args?: string[]) => {
|
|
|
495
651
|
instanceId: remote.origin.instanceId,
|
|
496
652
|
region: remote.origin.region,
|
|
497
653
|
});
|
|
654
|
+
} else if (remote.origin?.type === "hetzner") {
|
|
655
|
+
await terminateHetznerNode({
|
|
656
|
+
serverId: remote.origin.serverId,
|
|
657
|
+
token: (args.token as string) || undefined,
|
|
658
|
+
});
|
|
498
659
|
}
|
|
499
660
|
}
|
|
500
661
|
}
|
|
@@ -537,7 +698,9 @@ export const cli = async (args?: string[]) => {
|
|
|
537
698
|
remote.group || "",
|
|
538
699
|
remote.origin?.type === "aws"
|
|
539
700
|
? `aws\n${remote.origin.region}\n${remote.origin.instanceId}`
|
|
540
|
-
: ""
|
|
701
|
+
: remote.origin?.type === "hetzner"
|
|
702
|
+
? `hetzner\n${remote.origin.location}\n${remote.origin.serverId}`
|
|
703
|
+
: "",
|
|
541
704
|
resolvedOrRejected[ix].status === "fulfilled"
|
|
542
705
|
? chalk.green("Y")
|
|
543
706
|
: chalk.red("N"),
|
package/src/client.ts
CHANGED
|
@@ -358,12 +358,17 @@ export const createClient = async (
|
|
|
358
358
|
},
|
|
359
359
|
|
|
360
360
|
terminate: async () => {
|
|
361
|
-
const { terminateNode } = await import("./aws.js");
|
|
362
361
|
if (remote.origin?.type === "aws") {
|
|
362
|
+
const { terminateNode } = await import("./aws.js");
|
|
363
363
|
await terminateNode({
|
|
364
364
|
instanceId: remote.origin.instanceId,
|
|
365
365
|
region: remote.origin.region,
|
|
366
366
|
});
|
|
367
|
+
} else if (remote.origin?.type === "hetzner") {
|
|
368
|
+
const { terminateNode } = await import("./hetzner.js");
|
|
369
|
+
await terminateNode({
|
|
370
|
+
serverId: remote.origin.serverId,
|
|
371
|
+
});
|
|
367
372
|
}
|
|
368
373
|
},
|
|
369
374
|
};
|
package/src/docker.ts
CHANGED
|
@@ -1,44 +1,152 @@
|
|
|
1
1
|
import { delay, waitFor } from "@peerbit/time";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
type ExecResult = { stdout: string; stderr: string };
|
|
4
|
+
|
|
5
|
+
const execCommand = async (cmd: string): Promise<ExecResult> => {
|
|
4
6
|
const { exec } = await import("child_process");
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
exec(cmd, (error, stdout, stderr) => {
|
|
9
|
+
if (error) {
|
|
10
|
+
(error as any).stdout = stdout;
|
|
11
|
+
(error as any).stderr = stderr;
|
|
12
|
+
reject(error);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
resolve({ stdout, stderr });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
};
|
|
5
19
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const commandExists = async (command: string): Promise<boolean> => {
|
|
21
|
+
try {
|
|
22
|
+
await execCommand(`command -v ${command}`);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getSudoPrefix = async (): Promise<string> => {
|
|
30
|
+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
31
|
+
if (isRoot) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
if (await commandExists("sudo")) {
|
|
35
|
+
return "sudo ";
|
|
36
|
+
}
|
|
37
|
+
throw new Error("Docker installation requires elevated privileges (sudo not found)");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const dockerCliExists = async (): Promise<boolean> => {
|
|
41
|
+
try {
|
|
42
|
+
await execCommand("docker --version");
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const dockerDaemonAccessible = async (): Promise<boolean> => {
|
|
50
|
+
try {
|
|
51
|
+
await execCommand("docker info");
|
|
52
|
+
return true;
|
|
53
|
+
} catch (error: any) {
|
|
54
|
+
const stderr: string = error?.stderr || "";
|
|
55
|
+
if (
|
|
56
|
+
stderr.includes("Got permission denied") ||
|
|
57
|
+
stderr.toLowerCase().includes("permission denied")
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Docker is installed but the current user cannot access the Docker daemon. Add the user to the 'docker' group or run with elevated privileges.",
|
|
61
|
+
);
|
|
20
62
|
}
|
|
21
|
-
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
22
66
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
resolve(stdout);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
67
|
+
const startDockerDaemon = async (sudoPrefix: string) => {
|
|
68
|
+
if (await commandExists("snap")) {
|
|
69
|
+
try {
|
|
70
|
+
await execCommand(`${sudoPrefix}snap start docker`);
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
32
73
|
|
|
74
|
+
if (await commandExists("systemctl")) {
|
|
33
75
|
try {
|
|
34
|
-
await
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
76
|
+
await execCommand(`${sudoPrefix}systemctl enable --now docker`);
|
|
77
|
+
return;
|
|
78
|
+
} catch {}
|
|
79
|
+
try {
|
|
80
|
+
await execCommand(`${sudoPrefix}systemctl start docker`);
|
|
81
|
+
return;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (await commandExists("service")) {
|
|
86
|
+
try {
|
|
87
|
+
await execCommand(`${sudoPrefix}service docker start`);
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const installDockerWithSnap = async (sudoPrefix: string) => {
|
|
93
|
+
await execCommand(`${sudoPrefix}snap install docker`);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const installDockerWithApt = async (sudoPrefix: string) => {
|
|
97
|
+
await execCommand(`${sudoPrefix}apt-get update`);
|
|
98
|
+
await execCommand(
|
|
99
|
+
`${sudoPrefix}DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io`,
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const installDocker = async () => {
|
|
104
|
+
const sudoPrefix = await getSudoPrefix();
|
|
105
|
+
|
|
106
|
+
if (!(await dockerCliExists())) {
|
|
107
|
+
let lastError: unknown;
|
|
108
|
+
|
|
109
|
+
if (await commandExists("snap")) {
|
|
110
|
+
try {
|
|
111
|
+
await installDockerWithSnap(sudoPrefix);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
lastError = error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!(await dockerCliExists()) && (await commandExists("apt-get"))) {
|
|
118
|
+
try {
|
|
119
|
+
await installDockerWithApt(sudoPrefix);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
lastError = error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!(await dockerCliExists())) {
|
|
126
|
+
const suffix =
|
|
127
|
+
lastError instanceof Error
|
|
128
|
+
? `: ${lastError.message}`
|
|
129
|
+
: lastError
|
|
130
|
+
? `: ${String(lastError)}`
|
|
131
|
+
: "";
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Failed to install docker (no supported installer succeeded)${suffix}`,
|
|
134
|
+
);
|
|
40
135
|
}
|
|
41
136
|
}
|
|
137
|
+
|
|
138
|
+
await startDockerDaemon(sudoPrefix);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await waitFor(async () => dockerDaemonAccessible(), {
|
|
142
|
+
timeout: 2 * 60 * 1000,
|
|
143
|
+
delayInterval: 2000,
|
|
144
|
+
});
|
|
145
|
+
} catch (error: any) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Docker is installed but not available: ${error?.message || "unknown error"}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
42
150
|
};
|
|
43
151
|
|
|
44
152
|
export const startContainer = async (cmd: string, errorMessage?: string) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Unsupported
|
package/src/hetzner.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
/* eslint-disable @typescript-eslint/naming-convention */
|
|
3
|
+
import { type PeerId } from "@libp2p/interface";
|
|
4
|
+
import { delay } from "@peerbit/time";
|
|
5
|
+
|
|
6
|
+
export const HETZNER_LOCATIONS = [
|
|
7
|
+
"fsn1",
|
|
8
|
+
"nbg1",
|
|
9
|
+
"hel1",
|
|
10
|
+
"ash",
|
|
11
|
+
"hil",
|
|
12
|
+
] as const;
|
|
13
|
+
export type HetznerLocation = (typeof HETZNER_LOCATIONS)[number];
|
|
14
|
+
|
|
15
|
+
export const HETZNER_SERVER_TYPES = [
|
|
16
|
+
"cx11",
|
|
17
|
+
"cx21",
|
|
18
|
+
"cx31",
|
|
19
|
+
"cx41",
|
|
20
|
+
"cx51",
|
|
21
|
+
"cax11",
|
|
22
|
+
"cax21",
|
|
23
|
+
"cax31",
|
|
24
|
+
"cax41",
|
|
25
|
+
] as const;
|
|
26
|
+
export type HetznerServerType = (typeof HETZNER_SERVER_TYPES)[number];
|
|
27
|
+
|
|
28
|
+
const HCLOUD_API_BASE = "https://api.hetzner.cloud/v1";
|
|
29
|
+
|
|
30
|
+
const getToken = (token?: string): string => {
|
|
31
|
+
const resolved =
|
|
32
|
+
token ||
|
|
33
|
+
process.env.HCLOUD_TOKEN ||
|
|
34
|
+
process.env.HETZNER_TOKEN ||
|
|
35
|
+
process.env.HETZNER_CLOUD_TOKEN;
|
|
36
|
+
if (!resolved) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Missing Hetzner Cloud API token. Provide --token or set HCLOUD_TOKEN.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return resolved;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const setupUserData = (
|
|
45
|
+
email: string,
|
|
46
|
+
grantAccess: PeerId[] = [],
|
|
47
|
+
serverVersion?: string,
|
|
48
|
+
) => {
|
|
49
|
+
const peerIdStrings = grantAccess.map((x) => x.toString());
|
|
50
|
+
const grantArgs = peerIdStrings.map((key) => `--ga ${key}`).join(" ");
|
|
51
|
+
const versionSpec = serverVersion ? `@${serverVersion}` : "";
|
|
52
|
+
|
|
53
|
+
// better-sqlite3 forces users to install build-essentials for `make` command
|
|
54
|
+
return `#!/bin/bash
|
|
55
|
+
set -e
|
|
56
|
+
cd /root
|
|
57
|
+
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - &&\
|
|
58
|
+
apt-get install -y nodejs
|
|
59
|
+
apt-get install -y build-essential
|
|
60
|
+
npm install -g @peerbit/server${versionSpec}
|
|
61
|
+
peerbit domain test --email ${email}
|
|
62
|
+
peerbit start ${grantArgs} > log.txt 2>&1 &
|
|
63
|
+
`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type HcloudServer = {
|
|
67
|
+
id: number;
|
|
68
|
+
name: string;
|
|
69
|
+
public_net?: { ipv4?: { ip?: string } };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const createHcloudClient = async (token: string) => {
|
|
73
|
+
const { default: axios } = await import("axios");
|
|
74
|
+
return axios.create({
|
|
75
|
+
baseURL: HCLOUD_API_BASE,
|
|
76
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
77
|
+
timeout: 60_000,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const parseAxiosError = (error: any): string => {
|
|
82
|
+
const status = error?.response?.status;
|
|
83
|
+
const data = error?.response?.data;
|
|
84
|
+
const message = error?.message || String(error);
|
|
85
|
+
if (!status) {
|
|
86
|
+
return message;
|
|
87
|
+
}
|
|
88
|
+
const details =
|
|
89
|
+
typeof data === "string"
|
|
90
|
+
? data
|
|
91
|
+
: data?.error?.message || JSON.stringify(data);
|
|
92
|
+
return `${message} (HTTP ${status})${details ? `: ${details}` : ""}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const escapeRegExp = (value: string): string =>
|
|
96
|
+
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
97
|
+
|
|
98
|
+
const getHighestNameCounter = (
|
|
99
|
+
servers: HcloudServer[],
|
|
100
|
+
prefix: string,
|
|
101
|
+
): number => {
|
|
102
|
+
const pattern = new RegExp(`^${escapeRegExp(prefix)}-(\\d+)$`);
|
|
103
|
+
let max = 0;
|
|
104
|
+
for (const server of servers) {
|
|
105
|
+
const match = server.name.match(pattern);
|
|
106
|
+
if (!match) continue;
|
|
107
|
+
const n = Number(match[1]);
|
|
108
|
+
if (Number.isFinite(n)) {
|
|
109
|
+
max = Math.max(max, n);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return max;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const waitForServerPublicIp = async (
|
|
116
|
+
client: Awaited<ReturnType<typeof createHcloudClient>>,
|
|
117
|
+
serverId: number,
|
|
118
|
+
timeoutMs = 3 * 60 * 1000,
|
|
119
|
+
pollIntervalMs = 5000,
|
|
120
|
+
): Promise<string> => {
|
|
121
|
+
const start = Date.now();
|
|
122
|
+
while (Date.now() - start < timeoutMs) {
|
|
123
|
+
try {
|
|
124
|
+
const info = await client.get(`/servers/${serverId}`);
|
|
125
|
+
const server: HcloudServer | undefined = info.data?.server;
|
|
126
|
+
const ip = server?.public_net?.ipv4?.ip;
|
|
127
|
+
if (ip) {
|
|
128
|
+
return ip;
|
|
129
|
+
}
|
|
130
|
+
} catch (error: any) {
|
|
131
|
+
if (error?.response?.status !== 404 && error?.response?.status !== 429) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Failed while waiting for Hetzner server ${serverId} IP: ${parseAxiosError(
|
|
134
|
+
error,
|
|
135
|
+
)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await delay(pollIntervalMs);
|
|
140
|
+
}
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Timed out waiting for Hetzner server ${serverId} to get a public IPv4`,
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const launchNodes = async (properties: {
|
|
147
|
+
token?: string;
|
|
148
|
+
location?: string;
|
|
149
|
+
email: string;
|
|
150
|
+
count?: number;
|
|
151
|
+
serverType?: string;
|
|
152
|
+
namePrefix?: string;
|
|
153
|
+
grantAccess?: PeerId[];
|
|
154
|
+
serverVersion?: string;
|
|
155
|
+
image?: string;
|
|
156
|
+
}): Promise<
|
|
157
|
+
{ serverId: number; publicIp: string; name: string; location: string }[]
|
|
158
|
+
> => {
|
|
159
|
+
if (properties.count && properties.count > 10) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
"Unexpected node launch count: " +
|
|
162
|
+
properties.count +
|
|
163
|
+
". To prevent unwanted behaviour you can also launch 10 nodes at once",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const count = properties.count || 1;
|
|
167
|
+
|
|
168
|
+
const token = getToken(properties.token);
|
|
169
|
+
const client = await createHcloudClient(token);
|
|
170
|
+
|
|
171
|
+
const location = properties.location || "fsn1";
|
|
172
|
+
const serverType = properties.serverType || "cx11";
|
|
173
|
+
const image = properties.image || "ubuntu-22.04";
|
|
174
|
+
const namePrefix = properties.namePrefix || "peerbit-node";
|
|
175
|
+
|
|
176
|
+
const existingServers = (await client.get("/servers")).data?.servers as
|
|
177
|
+
| HcloudServer[]
|
|
178
|
+
| undefined;
|
|
179
|
+
const existingCounter = getHighestNameCounter(
|
|
180
|
+
existingServers || [],
|
|
181
|
+
namePrefix,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const created: Array<{ serverId: number; name: string }> = [];
|
|
185
|
+
for (let ix = 1; ix <= count; ix++) {
|
|
186
|
+
const name = `${namePrefix}-${existingCounter + ix}`;
|
|
187
|
+
try {
|
|
188
|
+
const resp = await client.post("/servers", {
|
|
189
|
+
name,
|
|
190
|
+
server_type: serverType,
|
|
191
|
+
image,
|
|
192
|
+
location,
|
|
193
|
+
user_data: setupUserData(
|
|
194
|
+
properties.email,
|
|
195
|
+
properties.grantAccess,
|
|
196
|
+
properties.serverVersion,
|
|
197
|
+
),
|
|
198
|
+
});
|
|
199
|
+
const serverId: number | undefined = resp.data?.server?.id;
|
|
200
|
+
if (!serverId) {
|
|
201
|
+
throw new Error("Missing server id in Hetzner response");
|
|
202
|
+
}
|
|
203
|
+
created.push({ serverId, name });
|
|
204
|
+
} catch (error: any) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Failed to create Hetzner server '${name}': ${parseAxiosError(error)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const nodes: Array<{
|
|
212
|
+
serverId: number;
|
|
213
|
+
publicIp: string;
|
|
214
|
+
name: string;
|
|
215
|
+
location: string;
|
|
216
|
+
}> = [];
|
|
217
|
+
for (const { serverId, name } of created) {
|
|
218
|
+
const publicIp = await waitForServerPublicIp(client, serverId);
|
|
219
|
+
nodes.push({ serverId, publicIp, name, location });
|
|
220
|
+
}
|
|
221
|
+
return nodes;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const terminateNode = async (properties: {
|
|
225
|
+
token?: string;
|
|
226
|
+
serverId: number | string;
|
|
227
|
+
}) => {
|
|
228
|
+
const token = getToken(properties.token);
|
|
229
|
+
const client = await createHcloudClient(token);
|
|
230
|
+
try {
|
|
231
|
+
await client.delete(`/servers/${properties.serverId}`);
|
|
232
|
+
} catch (error: any) {
|
|
233
|
+
if (error?.response?.status === 404) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Failed to terminate Hetzner server ${properties.serverId}: ${parseAxiosError(
|
|
238
|
+
error,
|
|
239
|
+
)}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
};
|