import config from "../config.ts"; import axios from "axios"; import { VMManager, type VM } from "./base.ts"; import fs from "node:fs"; import { redis } from "../utils/redis.ts"; const HETZNER_TOKEN = config.HETZNER_TOKEN; const sshKeys = config.HETZNER_SSH_KEYS.split(",").map(Number); export class Hetzner extends VMManager { size = "cpx11"; // cpx11, cx22 (not available in US yet) largeSize = "cpx31"; // cpx21, cpx31, ccx13, ccx23, cx32 minRetries = 5; reuseVMs = true; id = "Hetzner"; gateway = config.HETZNER_GATEWAY; imageId = config.HETZNER_IMAGE; private getRandomDatacenter() { // US let datacenters = ["ash"]; if (this.region === "USW") { datacenters = ["hil"]; } else if (this.region === "EU") { datacenters = ["nbg1", "fsn1", "hel1"]; } return datacenters[Math.floor(Math.random() * datacenters.length)]; } startVM = async (name: string) => { const data = { name: name, server_type: this.isLarge ? this.largeSize : this.size, start_after_create: true, image: Number(this.imageId), ssh_keys: sshKeys, public_net: { enable_ipv4: true, enable_ipv6: false, }, // networks: [ // this.networks[Math.floor(Math.random() * this.networks.length)], // ], // user_data: `replace with vbrowser.sh startup script if we want to boot vbrowser on instance creation (won't trigger on rebuild/restart)` labels: { [this.getTag()]: "1", }, location: this.getRandomDatacenter(), }; const response = await axios({ method: "POST", url: `https://api.hetzner.cloud/v1/servers`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "Content-Type": "application/json", "User-Agent": "test", }, data, }); const id = response.data.server.id; return id; }; terminateVM = async (id: string) => { await axios({ method: "DELETE", url: `https://api.hetzner.cloud/v1/servers/${id}`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "User-Agent": "test", }, }); }; rebootVM = async (id: string) => { // Reboot the VM await axios({ method: "POST", url: `https://api.hetzner.cloud/v1/servers/${id}/actions/reboot`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "User-Agent": "test", }, }); return; }; reimageVM = async (id: string) => { // Rebuild the VM await axios({ method: "POST", url: `https://api.hetzner.cloud/v1/servers/${id}/actions/rebuild`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "User-Agent": "test", }, data: { image: Number(this.imageId), }, }); return; }; getVM = async (id: string) => { const response: any = await axios({ method: "GET", url: `https://api.hetzner.cloud/v1/servers/${id}`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "User-Agent": "test", }, }); console.log( "[GETVM] %s: %s rate limit remaining", id, response?.headers["ratelimit-remaining"], ); await redis?.set( "hetznerApiRemaining", response?.headers["ratelimit-remaining"], ); const server = this.mapServerObject(response.data.server); return server; }; listVMs = async (filter: string) => { const limit = this.getLimitSize(); const pageCount = Math.ceil((limit || 1) / 50); const pages = Array.from(Array(pageCount).keys()).map((i) => i + 1); const responses: any[] = await Promise.all( pages.map((page) => axios({ method: "GET", url: `https://api.hetzner.cloud/v1/servers`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "User-Agent": "test", }, params: { sort: "id:asc", page, per_page: 50, label_selector: filter, }, }), ), ); const responsesMapped = responses.map((response) => response.data.servers.map(this.mapServerObject), ); return responsesMapped.flat(); }; powerOn = async (id: string) => { // Poweron the server (usually not needed) try { await axios({ method: "POST", url: `https://api.hetzner.cloud/v1/servers/${id}/actions/poweron`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "Content-Type": "application/json", "User-Agent": "test", }, }); } catch (e) { console.log("%s failed to poweron", id); } }; attachToNetwork = async (id: string) => { // // Attach server to network (usually not needed) // try { // const response: any = await axios({ // method: 'GET', // url: `https://api.hetzner.cloud/v1/servers/${id}`, // headers: { // Authorization: 'Bearer ' + HETZNER_TOKEN, // }, // }); // if (response.data.server.private_net?.[0] == null) { // await axios({ // method: 'POST', // url: `https://api.hetzner.cloud/v1/servers/${id}/actions/attach_to_network`, // headers: { // Authorization: 'Bearer ' + HETZNER_TOKEN, // 'Content-Type': 'application/json', // }, // data: { // network: // this.networks[Math.floor(Math.random() * this.networks.length)], // }, // }); // } // } catch (e: any) { // console.log('%s failed to attach to network', id); // console.log(e.response?.data); // } }; updateSnapshot = async () => { const response = await axios({ method: "POST", url: `https://api.hetzner.cloud/v1/servers`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "Content-Type": "application/json", "User-Agent": "test", }, data: { name: "vBrowserSnapshot", server_type: "cpx11", start_after_create: true, image: "docker-ce", // 15512617 for Ubuntu 20.04 ssh_keys: sshKeys, user_data: fs .readFileSync(import.meta.dirname + "/../../dev/vbrowser.sh") .toString() .replace("{VBROWSER_ADMIN_KEY}", config.VBROWSER_ADMIN_KEY), location: this.getRandomDatacenter(), }, }); const id = response.data.server.id; await new Promise((resolve) => setTimeout(resolve, 4 * 60 * 1000)); // Validate snapshot server was created successfully // const response3 = await axios( // 'http://' + response.data.server.public_net?.ipv4?.ip + ':5000' // ); const response2 = await axios({ method: "POST", url: `https://api.hetzner.cloud/v1/servers/${id}/actions/create_image`, headers: { Authorization: "Bearer " + HETZNER_TOKEN, "Content-Type": "application/json", }, }); const imageId = response2.data.image.id; await this.terminateVM(id); return imageId; }; mapServerObject = (server: any): VM => { const public_ip = server.public_net?.ipv4?.ip; // const private_ip = server.private_net?.[0]?.ip; const ip = public_ip; // We can use either the public or private IP for communicating between gateway and VM // Only signaling traffic goes through here since the video is transmitted over WebRTC // The private IP requires the server and gateway to be on the same network and there is a limit to the number of servers allowed return { id: server.id?.toString(), // The gateway handles SSL termination and proxies to the private IP host: ip ? `${this.gateway}/?ip=${ip}` : "", provider: this.id, large: this.isLarge, region: this.region, }; }; }