homeio
PublikFork dari doctor-io/homeio
tailscale-status.ts
import "server-only";
import { execFile } from "node:child_process";
import { access, unlink, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type {
TailscaleInstallResult,
TailscaleStatusPublic,
} from "@/lib/shared/contracts/tailscale";
const execFileAsync = promisify(execFile);
async function hasTunDevice() {
try {
await access("/dev/net/tun");
return true;
} catch {
return false;
}
}
type TailscaleStatusJson = {
BackendState?: string;
Self?: {
HostName?: string;
DNSName?: string;
TailscaleIPs?: string[];
Online?: boolean;
};
};
function isMissingCommand(error: unknown) {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
async function toStatus(data: TailscaleStatusJson): Promise<TailscaleStatusPublic> {
const backendState = data.BackendState ?? null;
const connected = backendState === "Running" || data.Self?.Online === true;
const tunAvailable = await hasTunDevice();
return {
installed: true,
tunAvailable,
running: backendState !== null && backendState !== "NoState",
connected,
backendState,
hostname: data.Self?.HostName ?? null,
dnsName: data.Self?.DNSName ?? null,
tailscaleIps: data.Self?.TailscaleIPs ?? [],
issue: tunAvailable ? null : "missing_tun",
error: null,
};
}
export async function getLocalTailscaleStatus(): Promise<TailscaleStatusPublic> {
const tunAvailable = await hasTunDevice();
try {
const { stdout } = await execFileAsync("tailscale", ["status", "--json"], {
timeout: 5000,
maxBuffer: 1024 * 1024,
});
return toStatus(JSON.parse(stdout) as TailscaleStatusJson);
} catch (error) {
if (isMissingCommand(error)) {
return {
installed: false,
tunAvailable,
running: false,
connected: false,
backendState: null,
hostname: null,
dnsName: null,
tailscaleIps: [],
issue: tunAvailable ? null : "missing_tun",
error: "Tailscale CLI is not installed.",
};
}
const message = error instanceof Error ? error.message : "Unable to read Tailscale status.";
const missingTun = !tunAvailable || message.includes("/dev/net/tun") || message.includes("CreateTUN");
return {
installed: true,
tunAvailable,
running: false,
connected: false,
backendState: null,
hostname: null,
dnsName: null,
tailscaleIps: [],
issue: missingTun ? "missing_tun" : "service_unavailable",
error: message,
};
}
}
let installLock = false;
export async function installTailscale(authKey?: string): Promise<TailscaleInstallResult> {
if (installLock) {
throw new Error("Tailscale installation already in progress. Please wait.");
}
installLock = true;
try {
return await runInstall(authKey);
} finally {
installLock = false;
}
}
async function runInstall(authKey?: string): Promise<TailscaleInstallResult> {
const currentStatus = await getLocalTailscaleStatus();
let stdout = "";
let stderr = "";
if (!currentStatus.installed) {
try {
const installResult = await execFileAsync("sh", [
"-c",
"curl -fsSL https://tailscale.com/install.sh | sh",
], {
timeout: 120_000,
maxBuffer: 4 * 1024 * 1024,
});
stdout += installResult.stdout;
stderr += installResult.stderr;
} catch (err) {
const e = err as { stderr?: string; stdout?: string; message?: string };
const detail = (e.stderr || e.stdout || e.message || "Unknown error").slice(0, 3000);
throw new Error(`Tailscale install script failed:\n${detail}`);
}
}
const nextStatus = await getLocalTailscaleStatus();
if (!nextStatus.tunAvailable) {
throw new Error("Tailscale requires /dev/net/tun. In Proxmox LXC, pass through dev/net/tun before activating.");
}
if (!authKey) {
return {
installed: true,
activated: nextStatus.connected,
stdout,
stderr: stderr || "Tailscale is installed. Provide an auth key to activate this server.",
};
}
await execFileAsync("systemctl", ["reset-failed", "tailscaled"], { timeout: 10_000 });
await execFileAsync("systemctl", ["enable", "--now", "tailscaled"], { timeout: 30_000 });
const tmpKeyFile = join(tmpdir(), `ts-key-${randomBytes(8).toString("hex")}`);
await writeFile(tmpKeyFile, authKey, { mode: 0o600 });
try {
const upResult = await execFileAsync("tailscale", [
"up",
`--auth-key=file:${tmpKeyFile}`,
"--accept-dns=true",
], {
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
stdout += upResult.stdout;
stderr += upResult.stderr;
} finally {
await unlink(tmpKeyFile).catch(() => {});
}
return {
installed: true,
activated: true,
stdout,
stderr,
};
}