tailportal
Version:
Create a Tailscale exit node with simple commands
847 lines (831 loc) • 22.1 kB
JavaScript
;
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);