426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
547 lines
20 KiB
JavaScript
547 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Arm Headscale on a Hetzner control-plane host.
|
|
*
|
|
* This is the repeatable counterpart to the launch runbook hand edits:
|
|
* - converge /etc/headscale/config.yaml to the public URL + loopback listener
|
|
* - install the committed ACL policy
|
|
* - ensure the `agent` and `tunnel` users exist
|
|
* - provision the nginx vhost + Let's Encrypt cert that fronts the public
|
|
* Headscale URL (TS2021/noise needs a no-http2 vhost with Upgrade/Connection
|
|
* passthrough + long timeouts — a CF-proxied or h2 origin breaks it)
|
|
* - enroll the CP itself as a tailscale node (cp-<env>-router, tag:eliza-proxy)
|
|
* against its local Headscale, so the daemon can reach agent 100.64.x IPs
|
|
* - upsert the daemon env that makes sandbox provisioning require Headscale
|
|
* - restart Headscale and the provisioning worker, then health-check both
|
|
*
|
|
* These last-mile bits (nginx vhost, LE cert, cp-router enrollment) were
|
|
* previously hand-run on every CP and lost on a rebuild — that DR gap is what
|
|
* this script + the control-plane Terraform headscale DNS record now close.
|
|
* Every step here is idempotent: a re-arm is a no-op if the box is converged.
|
|
*
|
|
* The API key is treated as pre-existing secret material. Generate or rotate it
|
|
* on the box with `headscale apikeys create --expiration=8760h`, then pass it
|
|
* through --headscale-api-key or HEADSCALE_API_KEY. This script never creates or
|
|
* prints a fresh key because GitHub Actions logs are the wrong place for that.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ENV_PATH = "/opt/eliza/cloud/.env.local";
|
|
const HEADSCALE_CONFIG = "/etc/headscale/config.yaml";
|
|
const HEADSCALE_ACL = "/etc/headscale/acl.hujson";
|
|
const HEADSCALE_STATE_DIR = "/var/lib/headscale";
|
|
const SYSTEMD_UNIT = "eliza-provisioning-worker.service";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(scriptDir, "../../../..");
|
|
const aclPath = resolve(
|
|
repoRoot,
|
|
"packages/cloud/services/headscale/acl.hujson",
|
|
);
|
|
|
|
// Only these flags take no value. Every other flag consumes the next token as
|
|
// its value — even one that starts with "--" (a PEM begins with "-----BEGIN"),
|
|
// which a naive next.startsWith("--") check would silently drop.
|
|
const BOOL_FLAGS = new Set([
|
|
"dry-run",
|
|
"help",
|
|
"h",
|
|
"skip-nginx-cert",
|
|
"skip-cp-router",
|
|
]);
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const a = argv[i];
|
|
if (!a.startsWith("--") && a !== "-h") continue;
|
|
const key = a.replace(/^--?/, "");
|
|
if (BOOL_FLAGS.has(key)) {
|
|
out[key] = true;
|
|
continue;
|
|
}
|
|
const next = argv[i + 1];
|
|
if (next === undefined) {
|
|
out[key] = true;
|
|
} else {
|
|
out[key] = next;
|
|
i += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readArg(args, key, envKey) {
|
|
// A flag parsed without a value yields boolean true; ignore it so the env
|
|
// fallback is still reachable (never treat true as a real string value).
|
|
const fromArg = typeof args[key] === "string" ? args[key] : undefined;
|
|
const value =
|
|
fromArg ?? process.env[envKey ?? key.toUpperCase().replaceAll("-", "_")];
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function die(message) {
|
|
console.error(`error: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
function envValueQuote(value) {
|
|
// systemd EnvironmentFile values must stay single-line. Agent-token PEM
|
|
// parsing intentionally expands literal "\\n" sequences back to newlines.
|
|
return `"${String(value)
|
|
.replaceAll("\r\n", "\\n")
|
|
.replaceAll("\n", "\\n")
|
|
.replaceAll('"', '\\"')}"`;
|
|
}
|
|
|
|
function validateHttpsUrl(name, value) {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "https:") throw new Error("must be https");
|
|
} catch {
|
|
die(`${name} must be an https URL (received ${value})`);
|
|
}
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help || args.h) {
|
|
console.log(`
|
|
Arm Headscale on a control-plane host.
|
|
|
|
Required:
|
|
--host <ip-or-host> Control-plane SSH host.
|
|
--ssh-key <path> Deploy-user SSH private key.
|
|
--headscale-public-url <https-url> Public Headscale URL.
|
|
--headscale-api-key <key> Existing Headscale API key.
|
|
|
|
Optional:
|
|
--headscale-api-url <url> Daemon API URL (default http://127.0.0.1:8081).
|
|
--listen-addr <addr:port> Headscale listen_addr (default 127.0.0.1:8081).
|
|
--headscale-user <user> User for agent preauth keys (default agent).
|
|
--cp-router-hostname <name> Tailscale hostname the CP enrolls itself as
|
|
(default derived from the public URL, e.g.
|
|
cp-staging-router). tag:eliza-proxy, owned
|
|
by the 'tunnel' headscale user.
|
|
--certbot-email <email> Email for the Let's Encrypt account / expiry
|
|
notices (default ops@elizalabs.ai).
|
|
--skip-nginx-cert Skip the nginx vhost + LE cert step.
|
|
--skip-cp-router Skip the CP self-enrollment step.
|
|
--agent-token-private-key-pem <pem> Upsert daemon env when already generated.
|
|
--eliza-local-root-key <key> Upsert daemon env when already generated.
|
|
--dry-run Print remote script, do not SSH.
|
|
|
|
Environment fallbacks use uppercase option names, e.g. HEADSCALE_API_KEY.
|
|
|
|
WARNING: --dry-run prints the assembled remote script INCLUDING secret values
|
|
(HEADSCALE_API_KEY / AGENT_TOKEN_PRIVATE_KEY_PEM / ELIZA_LOCAL_ROOT_KEY) in
|
|
plaintext. Do not run it in a shared or logged terminal. Prefer env vars over
|
|
CLI flags for secret material.
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const host = readArg(args, "host", "DEPLOY_HOST");
|
|
const sshKey = readArg(args, "ssh-key", "DEPLOY_SSH_KEY");
|
|
const publicUrl = readArg(args, "headscale-public-url", "HEADSCALE_PUBLIC_URL");
|
|
const apiUrl =
|
|
readArg(args, "headscale-api-url", "HEADSCALE_API_URL") ??
|
|
"http://127.0.0.1:8081";
|
|
const apiKey = readArg(args, "headscale-api-key", "HEADSCALE_API_KEY");
|
|
const listenAddr =
|
|
readArg(args, "listen-addr", "HEADSCALE_LISTEN_ADDR") ?? "127.0.0.1:8081";
|
|
const headscaleUser =
|
|
readArg(args, "headscale-user", "HEADSCALE_USER") ?? "agent";
|
|
const agentTokenPrivateKey = readArg(
|
|
args,
|
|
"agent-token-private-key-pem",
|
|
"AGENT_TOKEN_PRIVATE_KEY_PEM",
|
|
);
|
|
const localRootKey = readArg(
|
|
args,
|
|
"eliza-local-root-key",
|
|
"ELIZA_LOCAL_ROOT_KEY",
|
|
);
|
|
const certbotEmail =
|
|
readArg(args, "certbot-email", "CERTBOT_EMAIL") ?? "ops@elizalabs.ai";
|
|
const cpRouterHostnameArg = readArg(
|
|
args,
|
|
"cp-router-hostname",
|
|
"CP_ROUTER_HOSTNAME",
|
|
);
|
|
const skipNginxCert = args["skip-nginx-cert"] === true;
|
|
const skipCpRouter = args["skip-cp-router"] === true;
|
|
|
|
if (!host) die("--host or DEPLOY_HOST is required");
|
|
if (!sshKey) die("--ssh-key or DEPLOY_SSH_KEY is required");
|
|
if (!existsSync(sshKey)) die(`SSH key not found: ${sshKey}`);
|
|
if (!publicUrl)
|
|
die("--headscale-public-url or HEADSCALE_PUBLIC_URL is required");
|
|
if (!apiKey) die("--headscale-api-key or HEADSCALE_API_KEY is required");
|
|
if (!existsSync(aclPath)) die(`ACL file not found: ${aclPath}`);
|
|
validateHttpsUrl("HEADSCALE_PUBLIC_URL", publicUrl);
|
|
|
|
// The nginx vhost fronts the public hostname (host part of the URL) and proxies
|
|
// to the loopback port headscale listens on (port part of listen_addr). Both
|
|
// are already env-correct on the workflow inputs — staging is :8080, prod :8081
|
|
// — so the vhost upstream tracks listen_addr automatically with no extra flag.
|
|
const headscaleHostname = new URL(publicUrl).hostname;
|
|
// .pop() on the colon-split yields the port for "addr:port" and the whole
|
|
// string for a bare "port" — no need to branch on includes(":").
|
|
const headscalePort = listenAddr.split(":").pop();
|
|
if (!/^\d+$/.test(headscalePort ?? ""))
|
|
die(`could not derive headscale port from listen_addr '${listenAddr}'`);
|
|
|
|
// CP router hostname: cp-<env>-router. Derive <env> from the public hostname
|
|
// when not given explicitly: headscale-staging.elizacloud.ai → staging,
|
|
// headscale.elizacloud.ai → production. Falls back to the literal first DNS
|
|
// label otherwise, so an unexpected hostname still yields a deterministic name
|
|
// rather than throwing.
|
|
function deriveCpRouterHostname(fqdn) {
|
|
const firstLabel = fqdn.split(".")[0]; // "headscale-staging" | "headscale"
|
|
const suffix = firstLabel.startsWith("headscale-")
|
|
? firstLabel.slice("headscale-".length)
|
|
: firstLabel === "headscale"
|
|
? "production"
|
|
: firstLabel;
|
|
return `cp-${suffix}-router`;
|
|
}
|
|
const cpRouterHostname =
|
|
cpRouterHostnameArg ?? deriveCpRouterHostname(headscaleHostname);
|
|
|
|
const aclBase64 = Buffer.from(readFileSync(aclPath, "utf8"), "utf8").toString(
|
|
"base64",
|
|
);
|
|
|
|
const daemonEnv = {
|
|
HEADSCALE_PUBLIC_URL: publicUrl,
|
|
HEADSCALE_API_URL: apiUrl,
|
|
HEADSCALE_API_KEY: apiKey,
|
|
HEADSCALE_USER: headscaleUser,
|
|
...(agentTokenPrivateKey
|
|
? { AGENT_TOKEN_PRIVATE_KEY_PEM: agentTokenPrivateKey }
|
|
: {}),
|
|
...(localRootKey ? { ELIZA_LOCAL_ROOT_KEY: localRootKey } : {}),
|
|
};
|
|
|
|
const upserts = Object.entries(daemonEnv)
|
|
.map(([key, value]) => {
|
|
const line = `${key}=${envValueQuote(value)}`;
|
|
return [
|
|
`sudo sed -i ${shellQuote(`/^${key}=/d`)} "$F"`,
|
|
`printf '%s\\n' ${shellQuote(line)} | sudo tee -a "$F" >/dev/null`,
|
|
].join("\n");
|
|
})
|
|
.join("\n");
|
|
|
|
// ── nginx vhost + Let's Encrypt cert for the public Headscale URL ────────────
|
|
// Reproduces the proven-good /etc/nginx/conf.d/headscale.conf that was hand-
|
|
// written on each CP (the DR gap). The vhost MUST be no-http2 with Upgrade/
|
|
// Connection passthrough + 86400s timeouts — the headscale TS2021/noise control
|
|
// protocol rides a long-lived HTTP/1.1 Upgrade that an h2 origin (RFC 7540) or
|
|
// a short proxy timeout drops. Upstream port tracks the headscale listen_addr.
|
|
//
|
|
// Cert flow: write an HTTP-only bootstrap vhost so certbot's nginx authenticator
|
|
// can solve HTTP-01, run `certbot certonly` (idempotent — no-op if a valid cert
|
|
// already exists), THEN drop the final TLS vhost referencing the LE cert paths.
|
|
// `certonly` (not `--nginx` install) keeps the final vhost fully deterministic
|
|
// here instead of letting certbot rewrite it. Renewal is certbot's own
|
|
// systemd certbot.timer (installed by the certbot package) — no cron added.
|
|
const nginxCertSteps = skipNginxCert
|
|
? `echo "skip-nginx-cert set: leaving nginx vhost + LE cert untouched"`
|
|
: `
|
|
echo "--- nginx vhost + Let's Encrypt cert for ${headscaleHostname} ---"
|
|
HS_HOST=${shellQuote(headscaleHostname)}
|
|
HS_PORT=${shellQuote(headscalePort)}
|
|
CERTBOT_EMAIL=${shellQuote(certbotEmail)}
|
|
HS_VHOST=/etc/nginx/conf.d/headscale.conf
|
|
LE_LIVE=/etc/letsencrypt/live/$HS_HOST
|
|
|
|
command -v certbot >/dev/null 2>&1 || sudo apt-get install -y certbot python3-certbot-nginx
|
|
|
|
# 1. Bootstrap HTTP-only vhost so certbot --nginx can answer the HTTP-01
|
|
# challenge on :80. Overwritten by the final vhost below once the cert
|
|
# exists. Idempotent: re-running just rewrites the same bytes.
|
|
sudo tee "$HS_VHOST" >/dev/null <<NGINX
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name $HS_HOST;
|
|
location / { return 404; }
|
|
}
|
|
NGINX
|
|
sudo nginx -t
|
|
sudo systemctl reload nginx
|
|
|
|
# 2. Obtain the cert if absent / not yet valid. certonly is idempotent and
|
|
# exits 0 when a live cert is already present and not near expiry, so a
|
|
# re-arm never re-issues (and never trips LE rate limits). --nginx
|
|
# authenticator reuses the running nginx for the HTTP-01 challenge.
|
|
if sudo test -d "$LE_LIVE"; then
|
|
echo "LE cert already present for $HS_HOST; skipping issuance"
|
|
else
|
|
sudo certbot certonly --nginx --non-interactive --agree-tos \\
|
|
-m "$CERTBOT_EMAIL" -d "$HS_HOST"
|
|
fi
|
|
|
|
# 3. Final no-http2 TLS vhost: 80→443 redirect + 443 proxy to the headscale
|
|
# loopback listener, with the Upgrade/Connection map + long timeouts the
|
|
# noise protocol needs. This is the byte-for-byte proven-good prod/staging
|
|
# vhost, templated for env hostname + port.
|
|
sudo tee "$HS_VHOST" >/dev/null <<NGINX
|
|
# headscale control-protocol (TS2021/noise) needs the Upgrade header passed
|
|
# through on HTTP/1.1. NO http2 on this vhost: an h2 client connection would
|
|
# drop the Upgrade header (RFC 7540), which is exactly what broke headscale
|
|
# on Railway.
|
|
map \\$http_upgrade \\$hs_connection_upgrade {
|
|
default upgrade;
|
|
'' close;
|
|
}
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name $HS_HOST;
|
|
return 301 https://\\$host\\$request_uri;
|
|
}
|
|
server {
|
|
listen 443 ssl;
|
|
listen [::]:443 ssl;
|
|
server_name $HS_HOST;
|
|
ssl_certificate /etc/letsencrypt/live/$HS_HOST/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/$HS_HOST/privkey.pem;
|
|
location / {
|
|
proxy_pass http://127.0.0.1:$HS_PORT;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade \\$http_upgrade;
|
|
proxy_set_header Connection \\$hs_connection_upgrade;
|
|
proxy_set_header Host \\$host;
|
|
proxy_set_header X-Real-IP \\$remote_addr;
|
|
proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \\$scheme;
|
|
proxy_buffering off;
|
|
proxy_read_timeout 86400s;
|
|
proxy_send_timeout 86400s;
|
|
}
|
|
}
|
|
NGINX
|
|
sudo nginx -t
|
|
sudo systemctl reload nginx
|
|
|
|
# Confirm the cert renewal timer is active (renewal is certbot's own systemd
|
|
# timer, not a cron entry we manage). Non-fatal: surfaces a warning if the
|
|
# distro shipped certbot without the timer.
|
|
sudo systemctl is-active certbot.timer >/dev/null 2>&1 \\
|
|
&& echo "certbot.timer active (auto-renewal wired)" \\
|
|
|| echo "WARN: certbot.timer not active — check auto-renewal on this host"
|
|
`;
|
|
|
|
// ── CP self-enrollment as a tailscale node (cp-<env>-router) ─────────────────
|
|
// The CP enrolls ITSELF against its local headscale as cp-<env>-router with
|
|
// tag:eliza-proxy (owned by the 'tunnel' user in acl.hujson). This is what lets
|
|
// the daemon on the CP reach agent tag:agent 100.64.x IPs. Previously a manual
|
|
// `tailscale up` per CP (the DR gap). Idempotent: skips if a node with this
|
|
// hostname is already enrolled. headscale v0.28's `preauthkeys create -u` takes
|
|
// a numeric USER ID, not a username, so we resolve tunnel→id from users list.
|
|
const cpRouterSteps = skipCpRouter
|
|
? `echo "skip-cp-router set: leaving CP tailscale enrollment untouched"`
|
|
: `
|
|
echo "--- CP self-enrollment: ${cpRouterHostname} (tag:eliza-proxy) ---"
|
|
CP_ROUTER_HOST=${shellQuote(cpRouterHostname)}
|
|
LOGIN_SERVER=${shellQuote(publicUrl)}
|
|
|
|
command -v tailscale >/dev/null 2>&1 || curl -fsSL https://tailscale.com/install.sh | sh
|
|
sudo systemctl enable --now tailscaled
|
|
|
|
# Already enrolled under this hostname? (matches the live cp-router node by
|
|
# headscale node 'name'). If so, this whole step is a no-op.
|
|
if sudo headscale nodes list -o json 2>/dev/null \\
|
|
| jq -e --arg h "$CP_ROUTER_HOST" 'any(.[]; .name == $h)' >/dev/null 2>&1; then
|
|
echo "$CP_ROUTER_HOST already enrolled in headscale; skipping tailscale up"
|
|
else
|
|
# Resolve the 'tunnel' user id (preauthkeys create -u wants a uint in v0.28).
|
|
TUNNEL_UID=$(sudo headscale users list -o json 2>/dev/null \\
|
|
| jq -r '.[] | select(.name == "tunnel") | .id')
|
|
[ -n "$TUNNEL_UID" ] || { echo "tunnel user not found; cannot mint preauth key"; exit 1; }
|
|
|
|
# Short-lived, single-use, pre-tagged preauth key. Tagged tag:eliza-proxy so
|
|
# the node lands tagged at join (ownership enforced by acl.hujson tagOwners).
|
|
PREAUTH_KEY=$(sudo headscale preauthkeys create -u "$TUNNEL_UID" \\
|
|
--tags tag:eliza-proxy --expiration 1h -o json 2>/dev/null | jq -r '.key')
|
|
[ -n "$PREAUTH_KEY" ] || { echo "failed to mint preauth key for cp-router"; exit 1; }
|
|
|
|
sudo tailscale up \\
|
|
--login-server="$LOGIN_SERVER" \\
|
|
--authkey="$PREAUTH_KEY" \\
|
|
--hostname="$CP_ROUTER_HOST" \\
|
|
--advertise-tags=tag:eliza-proxy \\
|
|
--accept-routes
|
|
echo "$CP_ROUTER_HOST enrolled"
|
|
fi
|
|
|
|
sudo tailscale status 2>/dev/null | grep -F "$CP_ROUTER_HOST" \\
|
|
|| echo "WARN: $CP_ROUTER_HOST not visible in tailscale status yet"
|
|
`;
|
|
|
|
const remote = `
|
|
set -euo pipefail
|
|
PUBLIC_URL=${shellQuote(publicUrl)}
|
|
API_URL=${shellQuote(apiUrl)}
|
|
LISTEN_ADDR=${shellQuote(listenAddr)}
|
|
F=${ENV_PATH}
|
|
|
|
command -v headscale >/dev/null 2>&1 || {
|
|
echo "headscale binary not found; install the headscale package before arming this host"
|
|
exit 1
|
|
}
|
|
|
|
sudo install -d -m 0755 /etc/headscale
|
|
sudo install -d -o headscale -g headscale -m 0750 ${HEADSCALE_STATE_DIR}
|
|
|
|
printf '%s' ${shellQuote(aclBase64)} | base64 -d | sudo tee ${HEADSCALE_ACL} >/dev/null
|
|
sudo chown root:root ${HEADSCALE_ACL}
|
|
sudo chmod 0644 ${HEADSCALE_ACL}
|
|
|
|
if [ ! -f ${HEADSCALE_CONFIG} ]; then
|
|
sudo tee ${HEADSCALE_CONFIG} >/dev/null <<'YAML'
|
|
noise:
|
|
private_key_path: /var/lib/headscale/noise_private.key
|
|
prefixes:
|
|
v4: 100.64.0.0/10
|
|
v6: fd7a:115c:a1e0::/48
|
|
derp:
|
|
urls:
|
|
- https://controlplane.tailscale.com/derpmap/default
|
|
auto_update_enabled: true
|
|
update_frequency: 24h
|
|
disable_check_updates: true
|
|
ephemeral_node_inactivity_timeout: 15m
|
|
node_update_check_interval: 10s
|
|
database:
|
|
type: sqlite
|
|
sqlite:
|
|
path: /var/lib/headscale/db.sqlite
|
|
write_ahead_log: true
|
|
log:
|
|
level: info
|
|
format: json
|
|
dns:
|
|
magic_dns: true
|
|
base_domain: tunnel.eliza.local
|
|
nameservers:
|
|
global:
|
|
- 1.1.1.1
|
|
- 9.9.9.9
|
|
policy:
|
|
mode: file
|
|
path: /etc/headscale/acl.hujson
|
|
unix_socket: /var/lib/headscale/headscale.sock
|
|
unix_socket_permission: "0770"
|
|
YAML
|
|
fi
|
|
|
|
set_config() {
|
|
local key="$1"
|
|
local value="$2"
|
|
if sudo grep -qE "^$key:" ${HEADSCALE_CONFIG}; then
|
|
sudo sed -i -E "s|^$key:.*|$key: $value|" ${HEADSCALE_CONFIG}
|
|
else
|
|
printf '%s: %s\\n' "$key" "$value" | sudo tee -a ${HEADSCALE_CONFIG} >/dev/null
|
|
fi
|
|
}
|
|
|
|
set_config server_url "$PUBLIC_URL"
|
|
set_config listen_addr "$LISTEN_ADDR"
|
|
set_config metrics_listen_addr "127.0.0.1:9090"
|
|
set_config grpc_listen_addr "127.0.0.1:50443"
|
|
set_config grpc_allow_insecure "false"
|
|
|
|
sudo grep -qE '^policy:' ${HEADSCALE_CONFIG} || sudo tee -a ${HEADSCALE_CONFIG} >/dev/null <<'YAML'
|
|
policy:
|
|
mode: file
|
|
path: /etc/headscale/acl.hujson
|
|
YAML
|
|
|
|
sudo chown root:headscale ${HEADSCALE_CONFIG} || true
|
|
sudo chmod 0640 ${HEADSCALE_CONFIG} || true
|
|
sudo systemctl enable --now headscale
|
|
sudo systemctl restart headscale
|
|
|
|
for attempt in $(seq 1 30); do
|
|
if curl -sf -m 3 "$API_URL/health" >/dev/null; then
|
|
echo "headscale local health passed on attempt $attempt"
|
|
break
|
|
fi
|
|
if [ "$attempt" = 30 ]; then
|
|
echo "headscale local health failed"
|
|
sudo systemctl status headscale --no-pager || true
|
|
sudo journalctl -u headscale -n 80 --no-pager || true
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
for user in agent tunnel; do
|
|
if ! sudo headscale users list -o json 2>/dev/null | grep -q "\\"name\\"[[:space:]]*:[[:space:]]*\\"$user\\""; then
|
|
sudo headscale users create "$user"
|
|
fi
|
|
done
|
|
|
|
# jq is needed by the cp-router enrollment below (and is already a cloud-init
|
|
# package on the CP); guard so a stripped host still fails loud, not silent.
|
|
command -v jq >/dev/null 2>&1 || sudo apt-get install -y jq
|
|
|
|
${nginxCertSteps}
|
|
|
|
${cpRouterSteps}
|
|
|
|
sudo test -f "$F" || { echo "env file $F not found on host"; exit 1; }
|
|
sudo cp -n "$F" "$F.bak.arm-headscale" 2>/dev/null || true
|
|
${upserts}
|
|
|
|
echo "--- headscale env now on the box (secrets redacted) ---"
|
|
sudo grep -E '^(HEADSCALE_|AGENT_TOKEN_PRIVATE_KEY_PEM|ELIZA_LOCAL_ROOT_KEY)' "$F" \\
|
|
| sed -E 's/(KEY|PEM)=.*/\\1=<redacted>/'
|
|
|
|
sudo systemctl restart ${SYSTEMD_UNIT}
|
|
sleep 2
|
|
systemctl is-active headscale
|
|
systemctl is-active ${SYSTEMD_UNIT}
|
|
`;
|
|
|
|
if (args["dry-run"]) {
|
|
console.log("# DRY RUN - remote script that WOULD run on", host, ":\n");
|
|
console.log(remote);
|
|
process.exit(0);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
"ssh",
|
|
[
|
|
"-i",
|
|
sshKey,
|
|
"-o",
|
|
"IdentitiesOnly=yes",
|
|
"-o",
|
|
"StrictHostKeyChecking=accept-new",
|
|
"-o",
|
|
"ConnectTimeout=15",
|
|
`deploy@${host}`,
|
|
"bash -s",
|
|
],
|
|
{ input: remote, stdio: ["pipe", "inherit", "inherit"] },
|
|
);
|
|
|
|
if (result.status !== 0)
|
|
die(`remote Headscale arm failed (exit ${result.status})`);
|
|
|
|
console.log(
|
|
"\nHeadscale armed. Next: set matching Worker secrets, then run one provision E2E.",
|
|
);
|