UNPKG

tailportal

Version:

Create a Tailscale exit node with simple commands

847 lines (831 loc) 22.1 kB
#!/usr/bin/env node "use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); // index.ts var import_dotenv = __toESM(require("dotenv")); // src/instance-manager.ts var import_automation = require("@pulumi/pulumi/automation"); // src/instance-creator.ts var vultr = __toESM(require("@ediri/vultr")); var gcp = __toESM(require("@pulumi/gcp")); // ../../node_modules/.pnpm/nanoid@5.1.5/node_modules/nanoid/index.js var import_node_crypto = require("node:crypto"); var POOL_SIZE_MULTIPLIER = 128; var pool; var poolOffset; function fillPool(bytes) { if (!pool || pool.length < bytes) { pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER); import_node_crypto.webcrypto.getRandomValues(pool); poolOffset = 0; } else if (poolOffset + bytes > pool.length) { import_node_crypto.webcrypto.getRandomValues(pool); poolOffset = 0; } poolOffset += bytes; } function random(bytes) { fillPool(bytes |= 0); return pool.subarray(poolOffset - bytes, poolOffset); } function customRandom(alphabet, defaultSize, getRandom) { let mask = (2 << 31 - Math.clz32(alphabet.length - 1 | 1)) - 1; let step = Math.ceil(1.6 * mask * defaultSize / alphabet.length); return (size = defaultSize) => { let id = ""; while (true) { let bytes = getRandom(step); let i = step; while (i--) { id += alphabet[bytes[i] & mask] || ""; if (id.length >= size) return id; } } }; } function customAlphabet(alphabet, size = 21) { return customRandom(alphabet, size, random); } // ../vultr-types/dist/index.js var regions = [ { id: "ams", city: "Amsterdam", country: "NL", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "block_storage_high_perf", "load_balancers", "kubernetes" ] }, { id: "atl", city: "Atlanta", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "blr", city: "Bangalore", country: "IN", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "block_storage_high_perf", "load_balancers", "kubernetes" ] }, { id: "bom", city: "Mumbai", country: "IN", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "cdg", city: "Paris", country: "FR", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "del", city: "Delhi NCR", country: "IN", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "dfw", city: "Dallas", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "ewr", city: "New Jersey", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_high_perf", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "fra", city: "Frankfurt", country: "DE", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "hnl", city: "Honolulu", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "icn", city: "Seoul", country: "KR", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "itm", city: "Osaka", country: "JP", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "jnb", city: "Johannesburg", country: "ZA", continent: "Africa", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "lax", city: "Los Angeles", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "block_storage_high_perf", "load_balancers", "kubernetes" ] }, { id: "lhr", city: "London", country: "GB", continent: "Europe", options: [ "ddos_protection", "block_storage_high_perf", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "mad", city: "Madrid", country: "ES", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "man", city: "Manchester", country: "GB", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "mel", city: "Melbourne", country: "AU", continent: "Australia", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "mex", city: "Mexico City", country: "MX", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "mia", city: "Miami", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "nrt", city: "Tokyo", country: "JP", continent: "Asia", options: [ "ddos_protection", "block_storage_high_perf", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "ord", city: "Chicago", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "block_storage_high_perf", "load_balancers", "kubernetes" ] }, { id: "sao", city: "S\xE3o Paulo", country: "BR", continent: "South America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "scl", city: "Santiago", country: "CL", continent: "South America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "sea", city: "Seattle", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "sgp", city: "Singapore", country: "SG", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "block_storage_high_perf", "load_balancers", "kubernetes" ] }, { id: "sjc", city: "Silicon Valley", country: "US", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "sto", city: "Stockholm", country: "SE", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "syd", city: "Sydney", country: "AU", continent: "Australia", options: [ "ddos_protection", "block_storage_high_perf", "load_balancers", "kubernetes" ] }, { id: "tlv", city: "Tel Aviv", country: "IL", continent: "Asia", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "waw", city: "Warsaw", country: "PL", continent: "Europe", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] }, { id: "yto", city: "Toronto", country: "CA", continent: "North America", options: [ "ddos_protection", "block_storage_storage_opt", "load_balancers", "kubernetes" ] } ]; // src/instance-mapper.ts var import_vultr = require("@ediri/vultr"); var import_gcp = require("@pulumi/gcp"); var pulumi = __toESM(require("@pulumi/pulumi")); function mapInstanceToOutput(name, provider, instance) { return pulumi.all([instance.id, instance.hostname]).apply(([id, hostname]) => ({ name, id, hostname, provider })); } // src/cloud-config.ts var cloudConfigString = `#cloud-config runcmd: - ['sh', '-c', 'curl -fsSL https://tailscale.com/install.sh | sh'] - ['sh', '-c', "echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf && echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf && sudo sysctl -p /etc/sysctl.d/99-tailscale.conf" ] - ['tailscale', 'up', '--authkey=$TS_AUTH_KEY', '--advertise-exit-node', '--ssh'] `; var startupScriptString = `#!/bin/bash # Install Tailscale curl -fsSL https://tailscale.com/install.sh | sh # Configure sysctl settings echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.d/99-tailscale.conf echo "net.ipv6.conf.all.forwarding = 1" | sudo tee -a /etc/sysctl.d/99-tailscale.conf sudo sysctl -p /etc/sysctl.d/99-tailscale.conf # Start Tailscale tailscale up --authkey=$TS_AUTH_KEY --advertise-exit-node --ssh `; // src/instance-creator.ts var InstanceCreator = class { config; constructor(config) { this.config = config; } async createInstance(provider, region) { const name = this.generateInstanceName(provider); switch (provider) { case "vultr": { if (!regions.map((reg) => reg.id).includes(region)) { throw new Error("Region not valid"); } const instance = new vultr.Instance(name, { hostname: name, osId: 2136, plan: "vc2-1c-1gb", region, backups: "disabled", userData: this.getUserData(), activationEmail: false, label: "tailportal", tags: ["tailportal"] }); return mapInstanceToOutput(name, provider, instance); } case "gcp": { const instance = new gcp.compute.Instance(name, { networkInterfaces: [ { accessConfigs: [{}], network: "default" } ], name, machineType: "e2-micro", zone: "us-central1-a", tags: ["tailportal"], bootDisk: { initializeParams: { image: "debian-cloud/debian-11" } }, metadataStartupScript: this.getStartupScript(), // TODO: user given metadata metadata: { sshKeys: "" } }); return mapInstanceToOutput(name, provider, instance); } case "aws-lightsail": case "aws-ec2": case "digitalocean": case "hetzner": case "linode": default: { throw new Error("Not implemented"); } } } getExistingInstance(info) { switch (info.provider) { case "vultr": { const instance = vultr.Instance.get(info.name, info.id); return mapInstanceToOutput(info.name, info.provider, instance); } case "gcp": { const instance = gcp.compute.Instance.get(info.name, info.id); return mapInstanceToOutput(info.name, info.provider, instance); } case "aws-lightsail": case "aws-ec2": case "digitalocean": case "hetzner": case "linode": default: { throw new Error("Not implemented"); } } } getUserData() { return cloudConfigString.replace(/\$TS_AUTH_KEY/, this.config.tsAuthKey); } getStartupScript() { return startupScriptString.replace(/\$TS_AUTH_KEY/, this.config.tsAuthKey); } generateInstanceName(provider) { const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 6); return `${provider}-instance-${nanoid()}`; } }; // src/stack-output.ts function mapStackOutputToArray(output) { return Object.values(output).map((v) => v.value); } // src/instance-manager.ts var InstanceManager = class { config; stackName; projectName; stack; instancesInfo = []; constructor(config, stackName2, projectName2) { this.config = config; this.stackName = stackName2; this.projectName = projectName2; } async initializeStack() { this.stack = await import_automation.LocalWorkspace.createOrSelectStack( { program: this.getProgram(), projectName: this.projectName, stackName: this.stackName }, { secretsProvider: "passphrase", envVars: { PULUMI_CONFIG_PASSPHRASE: this.config.pulumiPassphrase }, projectSettings: { name: this.projectName, runtime: "nodejs" }, stackSettings: { [this.stackName]: { config: { "vultr:apiKey": this.config.vultrApiKey, "gcp:project": this.config.googleProject, "gcp:credentials": this.config.googleCredentials } } } } ); this.instancesInfo = mapStackOutputToArray(await this.stack.outputs()); } async createInstance(provider, region) { this.instancesInfo.push({ provider, region }); return this.upStack(); } async createInstances(data) { data.forEach( ({ provider, region }) => this.instancesInfo.push({ provider, region }) ); return this.upStack(); } async removeInstance(name) { this.instancesInfo = this.instancesInfo.filter((_info) => { if (!!_info.name) { const info = _info; return info.name !== name; } else { return true; } }); return this.upStack(); } async destroyStack() { this.instancesInfo = []; await this.upStack(); await this.stack?.destroy({ onOutput: console.info }); } async upStack() { await this.stack?.up({ onOutput: console.info }); } async refreshStack() { await this.stack?.refresh({ onOutput: console.info }); } getProgram() { return async () => { const existing = this.getExistingInstanceInfo().map( (info) => this.getOutputFromInfo(info) ); const newInstances = this.getNewInstanceInfo().map( (info) => this.getOutputFromCreateInfo(info) ); return [...existing, ...newInstances]; }; } getOutputFromInfo(info) { const instanceCreator = new InstanceCreator(this.config); return instanceCreator.getExistingInstance(info); } getOutputFromCreateInfo(info) { const instanceCreator = new InstanceCreator(this.config); return instanceCreator.createInstance(info.provider, info.region); } get currentInstances() { return this.getExistingInstanceInfo(); } getExistingInstanceInfo() { return this.instancesInfo.filter((info) => { if (!!info.id) { return info; } }).filter(Boolean); } getNewInstanceInfo() { return this.instancesInfo.filter((info) => { if (!!info.region) { return info; } }).filter(Boolean); } }; // src/types.ts var cloudProviders = [ "vultr", "aws-lightsail", "aws-ec2", "gcp", "digitalocean", "hetzner", "linode" ]; // index.ts var import_commander = require("commander"); var import_zod = require("zod"); import_dotenv.default.config(); function validateEnvironment() { const tsAuthKey = process.env.TS_AUTH_KEY; if (!tsAuthKey) { throw new Error("TS_AUTH_KEY environment variable is required"); } const pulumiPassphrase = process.env.PULUMI_CONFIG_PASSPHRASE; if (!pulumiPassphrase) { throw new Error("PULUMI_CONFIG_PASSPHRASE environment variable is required"); } return { tsAuthKey, pulumiPassphrase }; } var stackName = "dev"; var projectName = "tailportal"; import_commander.program.name("tailportal").description("Create a Tailscale exit node with simple commands").version("0.1.0"); import_commander.program.command("create <provider> [region]").description("Create a new instance").action(async (provider, region) => { const { tsAuthKey, pulumiPassphrase } = validateEnvironment(); const config = { tsAuthKey, pulumiPassphrase, vultrApiKey: process.env.VULTR_API_KEY, googleProject: process.env.GOOGLE_PROJECT, googleCredentials: process.env.GOOGLE_CREDENTIALS }; const createSchema = import_zod.z.object({ provider: import_zod.z.string(), region: import_zod.z.string().optional() }); const parseResult = createSchema.safeParse({ provider, region }); if (!parseResult.success) { console.error("Invalid input:", parseResult.error.format()); console.error(`Available providers: ${cloudProviders.join(", ")}`); console.error( `Available regions: ${regions.map((reg) => reg.id).join(", ")}` ); process.exit(1); } const parsed = parseResult.data; if (!cloudProviders.includes(parsed.provider)) { console.error(`Invalid provider: ${parsed.provider}`); console.error(`Available providers: ${cloudProviders.join(", ")}`); process.exit(1); } const validProvider = parsed.provider; if (!parsed.region || !regions.some((reg) => reg.id === parsed.region)) { console.error(`Invalid or missing region: ${parsed.region}`); console.error( `Available regions: ${regions.map((reg) => reg.id).join(", ")}` ); process.exit(1); } const validRegion = parsed.region; console.debug( `creating instance through ${validProvider} in ${validRegion}` ); const instanceManager = new InstanceManager(config, stackName, projectName); await instanceManager.initializeStack(); await instanceManager.createInstance(validProvider, validRegion); process.exit(0); }); import_commander.program.command("destroy").description("Destroy the stack").action(async () => { const { tsAuthKey, pulumiPassphrase } = validateEnvironment(); const config = { tsAuthKey, pulumiPassphrase, vultrApiKey: process.env.VULTR_API_KEY, googleProject: process.env.GOOGLE_PROJECT, googleCredentials: process.env.GOOGLE_CREDENTIALS }; const instanceManager = new InstanceManager(config, stackName, projectName); await instanceManager.initializeStack(); await instanceManager.destroyStack(); process.exit(0); }); import_commander.program.command("list").description("List current instances").action(async () => { const { tsAuthKey, pulumiPassphrase } = validateEnvironment(); const config = { tsAuthKey, pulumiPassphrase, vultrApiKey: process.env.VULTR_API_KEY, googleProject: process.env.GOOGLE_PROJECT, googleCredentials: process.env.GOOGLE_CREDENTIALS }; const instanceManager = new InstanceManager(config, stackName, projectName); await instanceManager.initializeStack(); console.log(instanceManager.currentInstances); }); import_commander.program.command("remove <name>").description("Remove an instance by name").action(async (name) => { const { tsAuthKey, pulumiPassphrase } = validateEnvironment(); const config = { tsAuthKey, pulumiPassphrase, vultrApiKey: process.env.VULTR_API_KEY, googleProject: process.env.GOOGLE_PROJECT, googleCredentials: process.env.GOOGLE_CREDENTIALS }; const instanceManager = new InstanceManager(config, stackName, projectName); await instanceManager.initializeStack(); await instanceManager.removeInstance(name); }); import_commander.program.command("region").description("List available regions").action(() => { const regionStrings = regions.map( (region) => `${region.id} : ${region.city}, ${region.country} (${region.continent})` ); console.log(regionStrings.join("\n")); }); import_commander.program.command("sync").description("Synchronize the stack with the current state").action(async () => { const { tsAuthKey, pulumiPassphrase } = validateEnvironment(); const config = { tsAuthKey, pulumiPassphrase, vultrApiKey: process.env.VULTR_API_KEY, googleProject: process.env.GOOGLE_PROJECT, googleCredentials: process.env.GOOGLE_CREDENTIALS }; const instanceManager = new InstanceManager(config, stackName, projectName); await instanceManager.initializeStack(); await instanceManager.refreshStack(); await instanceManager.upStack(); }); import_commander.program.parse(process.argv);