Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e087a679 |
158
scripts/bezalel_tailscale_bootstrap.py
Normal file
158
scripts/bezalel_tailscale_bootstrap.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bezalel Tailscale bootstrap scaffold.
|
||||
|
||||
Refs: timmy-home #535
|
||||
|
||||
Safe by default:
|
||||
- builds a remote bootstrap shell script
|
||||
- can write that script to disk
|
||||
- can print the SSH command needed to execute it
|
||||
- only runs remote SSH when --apply is explicitly passed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_HOST = "159.203.146.185"
|
||||
DEFAULT_HOSTNAME = "bezalel"
|
||||
DEFAULT_PEERS = {
|
||||
"mac": "100.124.176.28",
|
||||
"ezra": "100.126.61.75",
|
||||
}
|
||||
|
||||
|
||||
def build_remote_script(
|
||||
*,
|
||||
auth_key: str,
|
||||
ssh_public_key: str,
|
||||
peers: dict[str, str] | None = None,
|
||||
hostname: str = DEFAULT_HOSTNAME,
|
||||
) -> str:
|
||||
peer_map = peers or DEFAULT_PEERS
|
||||
lines = [
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||
f"tailscale up --authkey {shlex.quote(auth_key)} --ssh --hostname {shlex.quote(hostname)}",
|
||||
"install -d -m 700 ~/.ssh",
|
||||
f"touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys",
|
||||
f"grep -qxF {shlex.quote(ssh_public_key)} ~/.ssh/authorized_keys || printf '%s\\n' {shlex.quote(ssh_public_key)} >> ~/.ssh/authorized_keys",
|
||||
"tailscale status --json",
|
||||
]
|
||||
for name, ip in peer_map.items():
|
||||
lines.append(f"ping -c 1 {shlex.quote(ip)} >/dev/null && echo 'PING_OK:{name}:{ip}'")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_tailscale_status(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self_block = payload.get("Self") or {}
|
||||
peers = payload.get("Peer") or {}
|
||||
return {
|
||||
"self": {
|
||||
"hostname": self_block.get("HostName"),
|
||||
"dns_name": self_block.get("DNSName"),
|
||||
"tailscale_ips": list(self_block.get("TailscaleIPs") or []),
|
||||
},
|
||||
"peers": {
|
||||
peer.get("HostName") or peer_key: list(peer.get("TailscaleIPs") or [])
|
||||
for peer_key, peer in peers.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_ssh_command(host: str, remote_script_path: str = "/tmp/bezalel_tailscale_bootstrap.sh") -> list[str]:
|
||||
return ["ssh", host, f"bash {shlex.quote(remote_script_path)}"]
|
||||
|
||||
|
||||
def write_script(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def run_remote(host: str, remote_script_path: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(build_ssh_command(host, remote_script_path), capture_output=True, text=True, timeout=120)
|
||||
|
||||
|
||||
def parse_peer_args(items: list[str]) -> dict[str, str]:
|
||||
peers = dict(DEFAULT_PEERS)
|
||||
for item in items:
|
||||
name, ip = item.split("=", 1)
|
||||
peers[name.strip()] = ip.strip()
|
||||
return peers
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Prepare or execute Tailscale bootstrap for the Bezalel VPS.")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument("--hostname", default=DEFAULT_HOSTNAME)
|
||||
parser.add_argument("--auth-key", help="Tailscale auth key")
|
||||
parser.add_argument("--auth-key-file", type=Path, help="Path to file containing the Tailscale auth key")
|
||||
parser.add_argument("--ssh-public-key", help="SSH public key to append to authorized_keys")
|
||||
parser.add_argument("--ssh-public-key-file", type=Path, help="Path to the SSH public key file")
|
||||
parser.add_argument("--peer", action="append", default=[], help="Additional peer as name=ip")
|
||||
parser.add_argument("--script-out", type=Path, default=Path("/tmp/bezalel_tailscale_bootstrap.sh"))
|
||||
parser.add_argument("--remote-script-path", default="/tmp/bezalel_tailscale_bootstrap.sh")
|
||||
parser.add_argument("--apply", action="store_true", help="Execute the generated script over SSH")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _read_secret(value: str | None, path: Path | None) -> str | None:
|
||||
if value:
|
||||
return value.strip()
|
||||
if path and path.exists():
|
||||
return path.read_text().strip()
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
auth_key = _read_secret(args.auth_key, args.auth_key_file)
|
||||
ssh_public_key = _read_secret(args.ssh_public_key, args.ssh_public_key_file)
|
||||
peers = parse_peer_args(args.peer)
|
||||
|
||||
if not auth_key:
|
||||
raise SystemExit("Missing Tailscale auth key. Use --auth-key or --auth-key-file.")
|
||||
if not ssh_public_key:
|
||||
raise SystemExit("Missing SSH public key. Use --ssh-public-key or --ssh-public-key-file.")
|
||||
|
||||
script = build_remote_script(auth_key=auth_key, ssh_public_key=ssh_public_key, peers=peers, hostname=args.hostname)
|
||||
write_script(args.script_out, script)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"host": args.host,
|
||||
"hostname": args.hostname,
|
||||
"script_out": str(args.script_out),
|
||||
"remote_script_path": args.remote_script_path,
|
||||
"ssh_command": build_ssh_command(args.host, args.remote_script_path),
|
||||
"peer_targets": peers,
|
||||
"applied": False,
|
||||
}
|
||||
|
||||
if args.apply:
|
||||
result = run_remote(args.host, args.remote_script_path)
|
||||
payload["applied"] = True
|
||||
payload["exit_code"] = result.returncode
|
||||
payload["stdout"] = result.stdout
|
||||
payload["stderr"] = result.stderr
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
print("--- Bezalel Tailscale Bootstrap ---")
|
||||
print(f"Host: {args.host}")
|
||||
print(f"Local script: {args.script_out}")
|
||||
print("SSH command: " + " ".join(payload["ssh_command"]))
|
||||
if args.apply:
|
||||
print(f"Exit code: {payload['exit_code']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
80
tests/test_bezalel_tailscale_bootstrap.py
Normal file
80
tests/test_bezalel_tailscale_bootstrap.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from scripts.bezalel_tailscale_bootstrap import (
|
||||
DEFAULT_PEERS,
|
||||
build_remote_script,
|
||||
build_ssh_command,
|
||||
parse_peer_args,
|
||||
parse_tailscale_status,
|
||||
)
|
||||
|
||||
|
||||
def test_build_remote_script_contains_install_up_and_key_append():
|
||||
script = build_remote_script(
|
||||
auth_key="tskey-auth-123",
|
||||
ssh_public_key="ssh-ed25519 AAAATEST timmy@mac",
|
||||
peers=DEFAULT_PEERS,
|
||||
hostname="bezalel",
|
||||
)
|
||||
|
||||
assert "curl -fsSL https://tailscale.com/install.sh | sh" in script
|
||||
assert "tailscale up --authkey tskey-auth-123 --ssh --hostname bezalel" in script
|
||||
assert "install -d -m 700 ~/.ssh" in script
|
||||
assert "authorized_keys" in script
|
||||
assert "grep -qxF 'ssh-ed25519 AAAATEST timmy@mac' ~/.ssh/authorized_keys" in script
|
||||
|
||||
|
||||
def test_build_remote_script_pings_expected_peer_targets():
|
||||
script = build_remote_script(
|
||||
auth_key="tskey-auth-123",
|
||||
ssh_public_key="ssh-ed25519 AAAATEST timmy@mac",
|
||||
peers={"mac": "100.124.176.28", "ezra": "100.126.61.75"},
|
||||
hostname="bezalel",
|
||||
)
|
||||
|
||||
assert "PING_OK:mac:100.124.176.28" in script
|
||||
assert "PING_OK:ezra:100.126.61.75" in script
|
||||
|
||||
|
||||
def test_parse_tailscale_status_extracts_self_and_peer_ips():
|
||||
payload = {
|
||||
"Self": {
|
||||
"HostName": "bezalel",
|
||||
"DNSName": "bezalel.tailnet.ts.net",
|
||||
"TailscaleIPs": ["100.90.0.10"],
|
||||
},
|
||||
"Peer": {
|
||||
"node-1": {"HostName": "ezra", "TailscaleIPs": ["100.126.61.75"]},
|
||||
"node-2": {"HostName": "mac", "TailscaleIPs": ["100.124.176.28"]},
|
||||
},
|
||||
}
|
||||
|
||||
result = parse_tailscale_status(payload)
|
||||
|
||||
assert result == {
|
||||
"self": {
|
||||
"hostname": "bezalel",
|
||||
"dns_name": "bezalel.tailnet.ts.net",
|
||||
"tailscale_ips": ["100.90.0.10"],
|
||||
},
|
||||
"peers": {
|
||||
"ezra": ["100.126.61.75"],
|
||||
"mac": ["100.124.176.28"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_build_ssh_command_targets_remote_script_path():
|
||||
assert build_ssh_command("159.203.146.185", "/tmp/bootstrap.sh") == [
|
||||
"ssh",
|
||||
"159.203.146.185",
|
||||
"bash /tmp/bootstrap.sh",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_peer_args_merges_overrides_into_defaults():
|
||||
peers = parse_peer_args(["forge=100.70.0.9", "ezra=100.126.61.76"])
|
||||
|
||||
assert peers == {
|
||||
"mac": "100.124.176.28",
|
||||
"ezra": "100.126.61.76",
|
||||
"forge": "100.70.0.9",
|
||||
}
|
||||
Reference in New Issue
Block a user