diff --git a/v3/IMPLEMENTATION.md b/v3/IMPLEMENTATION.md index e73c140..19096f4 100644 --- a/v3/IMPLEMENTATION.md +++ b/v3/IMPLEMENTATION.md @@ -221,6 +221,7 @@ the in-flight issue-fix PRs to land first). | [602](specs/SPEC-602-synology-guide.md) | Synology walkthrough, no terminal | 503, 504 | Sonnet 5 | low | owner device pass | | [603](specs/SPEC-603-pi-appliance-image.md) | Pi appliance image pipeline (#280) | 501 | Sonnet 5 | high | owner boot test + measurements | | [604](specs/SPEC-604-backup-restore.md) | Backup & restore floor | 401, 501 | Sonnet 5 | high | **Fable 5 security review** (admin-gated secret-bearing endpoint) | +| [605](specs/SPEC-605-setup-page.md) | Setup page: join the tailnet in the browser (#550) | 601 | Opus 5 | high | owner live onboarding test | ## 5. Phase 2 work packages (superseded by §4b — kept for provenance) diff --git a/v3/clients/skills/palaia-install/SKILL.md b/v3/clients/skills/palaia-install/SKILL.md index 861d0e4..73d4414 100644 --- a/v3/clients/skills/palaia-install/SKILL.md +++ b/v3/clients/skills/palaia-install/SKILL.md @@ -64,7 +64,7 @@ Ask this first. Offer the four choices in plain words: - **Their own computer** — uncommon, but fine for a quick try. If it runs Linux, treat it like an existing server (Step 3); when they only want to reach palaia from that same computer, `PALAIA_NO_TAILSCALE=1` takes the place - of the key and Step 4 can be skipped. On a Mac or Windows PC the installer + of the setup code and Step 4 can be skipped. On a Mac or Windows PC the installer does not apply: they need Docker Desktop and the first command under "Quick start" in `v3/deploy/README.md` (with the image name from "Which image this release uses"). @@ -74,35 +74,33 @@ Ask this first. Offer the four choices in plain words: Only for the rented-server path. Ask whether they have already created the server, or are about to. -- **Not yet created** — the easiest path. You will prepare a small setup file - they paste into one field when they create the server, so no commands are - needed at all. Go to Step 2a. -- **Already created (it exists and is empty)** — that paste-at-creation field is - no longer available to them, so you set it up over a remote connection - instead. Go to Step 3. +- **Not yet created** — the easiest path: a small setup file they paste into + one field when they create the server. No commands at all. Go to Step 2a. +- **Already created** — go to Step 3. If it is still empty and they cannot + give you a way in, recreating it with the setup file (Step 2a) avoids the + provider's console entirely; offer that as a choice. + +### The setup code + +Every path ends on palaia's one-time setup page (Step 4), which only opens for +a setup code. Make one up yourself: 12 characters from capital letters and +digits, without 0, O, 1 or I, written in three groups like `K7QM-R2XD-9FTP`. +Tell them the code and ask them to keep it until setup is done. It is not a +password for later: it only works while the setup page is open. ### Step 2a — Not yet created: the paste-at-creation file -1. Get the private-network key first (Step 4). -2. Take `v3/deploy/cloud-init.yaml` exactly as it is and make **one** change: - replace the text `TAILSCALE_AUTH_KEY="tskey-REPLACE_ME"` with - `TAILSCALE_AUTH_KEY=""`. That text appears twice — once in the - explanation at the top and once where the key is really set — and changing - both is fine. -3. Do **not** replace every `tskey-REPLACE_ME` in the file. One of them sits in - the line that checks whether the key was forgotten; change that one too and - the server stops right away with an error nobody is watching. -4. Check your copy before handing it over: the first line is still - `#cloud-config`, the key line holds their key, the checking line still says - `tskey-REPLACE_ME`, and nothing else changed — not the image line, not the +1. Take `v3/deploy/cloud-init.yaml` exactly as it is and make **one** change: + replace `PALAIA_SETUP_CODE="setup-code-REPLACE_ME"` with + `PALAIA_SETUP_CODE=""`, using your code. That text appears twice — in + the explanation at the top and where it is really set — change both. +2. Check your copy: the first line is still `#cloud-config`, the code line + holds the code, and nothing else changed — not the image line, not the indentation. -5. Give them the whole file as one block to copy. Tell them to paste it into - the field their provider shows when creating a server — Hetzner calls it - "Cloud config", others call it "User data" — then create the server. The - file now contains their key: they should not share it or keep it lying - around. -6. Nothing else to run. Setting itself up takes a few minutes after the server - starts. Go to Step 5. +3. Give them the whole file as one block to copy. They paste it into the field + their provider shows when creating a server — Hetzner calls it "Cloud + config", others "User data" — then create the server. +4. Ask for the server's address once the provider shows it. Go to Step 4. ## Step 3 — An existing server: set it up remotely @@ -118,23 +116,29 @@ The installer line comes from the top of `v3/deploy/get-palaia.sh`; today: curl -fsSL https://get.palaia.ai | sh ``` -Put their key (Step 4) between the `|` and `sh`, so it does not stop to ask: +Put your setup code between the `|` and `sh`, so you already know it: ``` -curl -fsSL https://get.palaia.ai | PALAIA_TSKEY= sh +curl -fsSL https://get.palaia.ai | PALAIA_SETUP_CODE= sh ``` For a test release (see "Which image this release uses"), still one line: ``` -curl -fsSL https://get.palaia.ai | PALAIA_TSKEY= PALAIA_CHANNEL=beta sh +curl -fsSL https://get.palaia.ai | PALAIA_SETUP_CODE= PALAIA_CHANNEL=beta sh ``` +The installer sets up the container engine and the private-network software, +then opens the setup page and waits there — up to 30 minutes — until they +have finished it. So start it in a way that keeps running after your command +returns (your tool's background mode, for example), and go to Step 4 while it +waits. Afterwards it starts palaia and ends with a line starting +`done ✓ Open` followed by palaia's address. + The commands below log in as root, which most providers set up; if theirs gave a different user name, use that. "Run the installer line through it" -means: put the line, in single quotes, in place of the final word true. It -takes a few minutes, then go to Step 5. If a login prints an error, see "When -the login fails" in Step 6. +means: put the line, in single quotes, in place of the final word true. If a +login prints an error, see "When the login fails" in Step 6. **3a — With a key.** Check you get in (these options never hang on a question); no output means you are in, then run the installer line through it: @@ -170,48 +174,45 @@ rm -rf ~/.palaia-setup ``` **3c — You cannot run commands for them** (a chat with no Run button and no -terminal). Give them the installer line to paste into their server's terminal -— most providers have a "Console" button on their website that opens one in -the browser — and ask them to send back the last few lines it prints. +terminal). First offer the no-console way if the server is still empty: +recreate it with the setup file (Step 2a). If they would rather keep it, give +them the plain installer line (with `PALAIA_CHANNEL=beta` for a test release, +without a code) to paste into their provider's web console — most providers +have a "Console" button for the server. The screen then shows the address to +open and the setup code. Go to Step 4. -The installer puts everything in place — the container engine, the private -network, the firewall, and palaia itself — and ends with a line starting -`done ✓ Open` followed by the address to open. Go to Step 5. - -## Step 4 — The private-network key (Tailscale) +## Step 4 — The setup page: joining their private network A rented server sits on the open internet, so palaia is placed on the person's -own private network (Tailscale) and is reachable only from their own devices, -never publicly. You need one key for this. - -Walk them through it: - -1. If they do not use Tailscale yet: create a free account and install the - Tailscale app on the computer they will open palaia from. -2. Generate a key at `https://login.tailscale.com/admin/settings/keys` → - **Generate auth key**. -3. Two warnings that save the most common failures: - - Pick **auth key**, not **access token** — they sit next to each other and - the access token will be rejected with a confusing error. An auth key - starts with `tskey-auth-`; one starting with `tskey-api-` is the access - token. - - Leave **Reusable** and **Ephemeral** off. (The setup file's own comment - says a reusable, ephemeral key also works; keep it simple and leave both - off — a server on an ephemeral key can drop off the private network after - it has been offline for a while.) - -Keep the key only long enough to use it; treat it like a password. +own private network (Tailscale) and is reachable only from their own devices. +The setup page does this in the browser, with no key to copy. + +1. If they do not use Tailscale yet: create a free account at tailscale.com + and install the Tailscale app on the device they will open palaia from. +2. Have them open `http://
:8420/` — the server's public address. If + it does not load yet, wait a minute and reload: the server is still + installing. +3. They type the setup code, click **Connect with Tailscale**, and log in with + their Tailscale account. +4. The page says "Done", shows palaia's private address, then closes itself. + +**For technical people only — a key instead of the page.** At +`https://login.tailscale.com/admin/settings/keys` → **Generate auth key**: +type "palaia" as the description, leave **Reusable** and **Ephemeral** off, +and pick an **auth key** (it starts with `tskey-auth-`), not an **access +token** (`tskey-api-`). Then use it instead of the code — in the setup file, +`TAILSCALE_AUTH_KEY="tskey-REPLACE_ME"`; for the installer: -## Step 5 — Confirm it worked, then the next step +``` +curl -fsSL https://get.palaia.ai | PALAIA_TSKEY= sh +``` -However it was installed, the end state is the same: palaia answers in a -browser on the private network, on port `8420`. +## Step 5 — Confirm it worked, then the next step -- **Installer path** — the address is in the installer's last line. -- **Setup-file path** — nothing is printed anywhere they can see. After a few - minutes, ask them to open the machine list in their Tailscale admin page and - look for a machine named `palaia-hub`; its address, with `:8420` added, is - the one to open in a browser (`http://
:8420/`). +palaia answers in a browser on the private network, on port `8420`, at the +address the setup page showed (also in the installer's last line). It is also +listed as a machine named `palaia-hub` in their Tailscale admin page. The +device they open it from needs the Tailscale app switched on. If it answers: give them that address and tell them the next step is opening it and connecting their first AI tool. If you used the password path (Step 3b), @@ -221,11 +222,23 @@ delete the saved password now, as that step says. If it does not, go to Step 6. Name the likely cause in plain words; never paste raw error output at them. -**The installer path** prints its own errors, each starting `palaia: ERROR:` +**The setup page:** + +- **It does not open at all after five minutes** — the provider may have a + firewall in front of the server. Ask them to allow port 8420 there for now + (Hetzner: "Firewalls" in the project), or use the key path instead. +- **"That code is not right."** — a typo; letters and digits only, dashes and + case do not matter. +- **It closed before they finished** — it gives up after 30 minutes. Run the + installer line again (setup file: `sudo bash /opt/palaia/cloud-init-setup.sh` + on the server) for a fresh page. + +**The installer** prints its own errors, each starting `palaia: ERROR:` and saying what to do. Translate it: +- The setup page closed without joining — run the line again. - The key was an access token, expired, or already used — get a fresh - **auth key** (Step 4) and run the line again. + **auth key** and run the line again. - It joined the private network but got no address yet — wait a moment and run the line again. - It could not download palaia's image — check "Which image this release @@ -255,32 +268,21 @@ ssh -o StrictHostKeyChecking=accept-new root@
- **"Connection timed out" / "No route to host"** — wrong address or the server is off; have them check both on the provider's website. -**The setup-file path** writes everything it did to one log on the server: -`/var/log/cloud-init-output.log`. Only the hub itself is kept off the public -internet; SSH still works, so they (or you) can log in to the server's public -address, or use the provider's web console, and run -`sudo tail -n 50 /var/log/cloud-init-output.log`. Every line palaia's own setup -writes starts with `palaia cloud-init:`, so the last of those shows how far it -got: - -- **It stops with `ERROR: edit this file's TAILSCALE_AUTH_KEY`** — the key was - never filled in, or the checking line was changed too. The server exists - now, so do not recreate it: finish with Step 3's installer on it. -- **It stops after `joining the tailnet ...`** with a Tailscale error below it — - the key was rejected (an access token, expired, or a one-time key already - used). Get a fresh auth key, then finish with Step 3's installer. -- **It stops after `pulling`, with a download error** — the setup file names the - right image for its release, so this is almost always a passing network or - download hiccup. Re-run the setup on the server, which is safe to repeat: - `sudo bash /opt/palaia/cloud-init-setup.sh`. If someone edited the image line, - put the original file's line back first. +**The setup file** writes everything it did to +`/var/log/cloud-init-output.log` on the server; its own lines start with +`palaia cloud-init:`. To read it they (or you) log in over SSH or the +provider's console and run `sudo tail -n 50 /var/log/cloud-init-output.log`: + +- **`ERROR: edit this file's PALAIA_SETUP_CODE (or TAILSCALE_AUTH_KEY)`** — + the code was never filled in. The server exists now: finish with Step 3. +- **It stops after `pulling`, with a download error** — almost always a passing + network hiccup. Re-run `sudo bash /opt/palaia/cloud-init-setup.sh`, which is + safe to repeat. If someone edited the image line, put the original back. - **The last line starts `done. Open`, yet the page does not open** — the - device they are browsing from must be on the same Tailscale network with the - app switched on. Right after the server restarts, a minute or two of - "connection refused" is normal: palaia waits for its private-network address - and retries by itself (the "Reboots" note in `v3/deploy/README.md`). Only if - it still refuses after five minutes, look at palaia's own log with - `sudo docker logs --tail 50 palaia-hub` and translate what it says. + device must be on the same Tailscale network with the app on. Right after a + restart, a minute or two of "connection refused" is normal (the "Reboots" + note in `v3/deploy/README.md`). If it still refuses after five minutes, + read `sudo docker logs --tail 50 palaia-hub` and translate it. ## What not to do diff --git a/v3/deploy/README.md b/v3/deploy/README.md index 8298f55..4632c71 100644 --- a/v3/deploy/README.md +++ b/v3/deploy/README.md @@ -46,14 +46,20 @@ curl -fsSL https://get.palaia.ai/docker | bash ## Cloud-init (rented servers) SPEC-601. For a server you rent rather than a machine you already own — -Hetzner Cloud, DigitalOcean, AWS, or similar — the vehicle is +Hetzner Cloud, DigitalOcean, or similar — the vehicle is [`cloud-init.yaml`](cloud-init.yaml): paste the whole file into the provider's server-creation user-data field (Hetzner Cloud calls it "Cloud -config"; DigitalOcean and AWS call it "User data"), enter one Tailscale -auth key first, and the server finishes its own setup with no terminal -session needed. The onboarding page's "A rented server" entry shows the -same file with a copy button; this is the file it copies from — never a -second, hand-typed one. +config"; DigitalOcean calls it "User data"; the file is about 20 KB, so +providers with a 16 KB limit such as AWS need the installer instead), fill +in one setup code first, and the server finishes its own setup with no +terminal session needed: a few minutes after boot, open +`http://:8420/`, enter the code and click "Connect with +Tailscale" — a one-time setup page (SPEC-605, +[`setup-page.py`](setup-page.py)) joins the server to your tailnet and +closes itself. Prefer a key? Fill in `TAILSCALE_AUTH_KEY` instead and no +page is shown. The onboarding page's "A rented server" entry shows the same +file with a copy button; this is the file it copies from — never a second, +hand-typed one. **Already have a server?** cloud-init only ever runs on a machine's *first* boot, so pasting `cloud-init.yaml` into a box that is already running does @@ -66,8 +72,9 @@ container), ending at the identical hub. On the server: curl -fsSL https://get.palaia.ai | sh ``` -It prompts for a Tailscale auth key interactively, or takes `PALAIA_TSKEY` / -`PALAIA_NO_TAILSCALE` non-interactively (see the script header). During the +Without a key it opens the same one-time setup page and prints its address +and code (or uses `PALAIA_SETUP_CODE`); `PALAIA_TSKEY` / `PALAIA_NO_TAILSCALE` +skip the page (see the script header). During the release candidate the `stable` image does not exist yet, so pass `PALAIA_CHANNEL=beta` (`curl -fsSL https://get.palaia.ai | PALAIA_CHANNEL=beta sh`) — the script says so too if the `:stable` pull 404s. The onboarding page's @@ -122,8 +129,9 @@ a cloud instance instead of a local VM. That is an owner action, not something CI claims: - [ ] Create a server on a real provider (Hetzner Cloud, DigitalOcean, or - similar), pasting `cloud-init.yaml` with a real Tailscale auth key - into its user-data field. + similar), pasting `cloud-init.yaml` with a setup code filled in + into its user-data field; finish the setup page at + `http://:8420/`. - [ ] Wait for the server to finish booting (a couple of minutes is typical for the package installs plus image pull). - [ ] From a device already on the same tailnet, open diff --git a/v3/deploy/cloud-init.yaml b/v3/deploy/cloud-init.yaml index a2083af..a2135a4 100644 --- a/v3/deploy/cloud-init.yaml +++ b/v3/deploy/cloud-init.yaml @@ -1,21 +1,26 @@ #cloud-config # palaia v3 — VPS install (SPEC-601). Paste this whole file into your # provider's server-creation user-data field — Hetzner Cloud calls it -# "Cloud config", DigitalOcean and AWS call it "User data" — then create +# "Cloud config", DigitalOcean calls it "User data" — then create # the server. Everything below runs itself: no terminal, no SSH session # needed to finish setup. # # ONE THING TO EDIT — find this line further down and replace the -# placeholder with a real key from -# https://login.tailscale.com/admin/settings/keys (a reusable, ephemeral -# key works well here — this file re-runs safely, see below): +# placeholder with a setup code of your own (12 letters/digits, e.g. +# K7QM-R2XD-9FTP; your AI assistant can make one up for you): # -# TAILSCALE_AUTH_KEY="tskey-REPLACE_ME" +# PALAIA_SETUP_CODE="setup-code-REPLACE_ME" # -# Nothing else in this file needs to change. +# A few minutes after the server starts, open http://:8420/, +# enter that code and click "Connect with Tailscale" — the server joins your +# private network (tailnet) and the page closes itself (SPEC-605). +# +# Prefer a key? Instead fill in the TAILSCALE_AUTH_KEY line below with an +# auth key from https://login.tailscale.com/admin/settings/keys; then no +# setup page is shown. Nothing else in this file needs to change. # # What it does: installs Docker and Tailscale, joins your private network -# (tailnet) with the key above, starts the hub with the exact same +# (tailnet) via the setup page or your key, starts the hub with the exact same # hardened container flags v3/deploy/install.sh uses (never a second, # hand-copied list — see server/tests/deploy/test_cloud_init.py's drift # check), and binds the hub's published port to the tailnet address only @@ -50,6 +55,324 @@ write_files: [Unit] After=tailscaled.service Wants=tailscaled.service + # SPEC-605: the one-time setup page — v3/deploy/setup-page.py verbatim + # (server/tests/deploy/test_setup_page.py checks the copy). + - path: /opt/palaia/setup-page.py + permissions: "0700" + owner: root:root + content: | + #!/usr/bin/env python3 + # palaia v3 — one-time setup page (SPEC-605). + # + # Joins this server to the owner's Tailscale network without anyone handling + # a key: it starts `tailscale up` without an auth key, takes the login link + # Tailscale prints, and shows it on a small web page — but only to someone + # who enters the one-time setup code. The person opens + # http://:8420/, types the code, clicks "Connect with + # Tailscale" and logs in. When the server has its private-network address, + # the page says where palaia will be, and this program exits 0. + # + # It exits 1 on timeout (default 30 minutes) or when Tailscale fails. Either + # way the port is closed again the moment it exits; the installer then starts + # the hub bound to the private-network address only, exactly as before. + # + # Standard library only — python3 is on every image that runs cloud-init. + # The code comes from the PALAIA_SETUP_CODE environment variable, not the + # command line, so it never shows up in a process list. Without one, a fresh + # code is made up and printed together with the address to open. + # + # This file is the single source: get-palaia.sh and cloud-init.yaml embed + # it verbatim (checked by server/tests/deploy/test_setup_page.py). + + import argparse + import hmac + import html + import json + import os + import re + import secrets + import signal + import socket + import subprocess + import sys + import threading + import time + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import parse_qs, urlparse + + LOGIN_URL_RE = re.compile(r"https://\S+") + WRONG_CODE_DELAY = 1.0 + MAX_CLIENTS = 32 # concurrent connections; more are dropped, not queued + CLIENT_TIMEOUT = 10 # seconds a connection may take to send its request + CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I look-alikes + + + def log(message): + print(f"palaia setup page: {message}", flush=True) + + + def normalise(code): + """Codes are compared without case, spaces or dashes.""" + return re.sub(r"[\s-]", "", code or "").upper() + + + def new_code(): + raw = "".join(secrets.choice(CODE_ALPHABET) for _ in range(12)) + return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}" + + + def public_address(): + """The address this server reaches the internet from — no packet is sent.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("1.1.1.1", 80)) + return sock.getsockname()[0] + except OSError: + return "" + + + def tailnet_ip(): + try: + out = subprocess.run( + ["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=10, check=False + ) + except (OSError, subprocess.TimeoutExpired): + return None + first = out.stdout.strip().splitlines()[:1] + return first[0] if out.returncode == 0 and first else None + + + class State: + def __init__(self, code, port): + self.code = normalise(code) + self.port = port + self.login_url = None + self.ip = None + self.failed = None + self.proc = None + self.lock = threading.Lock() + + def code_ok(self, given): + return hmac.compare_digest(normalise(given).encode(), self.code.encode()) + + + def run_tailscale(state, hostname): + """`tailscale up` without a key prints a login link and waits for the login.""" + try: + state.proc = proc = subprocess.Popen( + ["tailscale", "up", f"--hostname={hostname}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except OSError as exc: + state.failed = f"could not start tailscale: {exc}" + return + for line in proc.stdout: + match = LOGIN_URL_RE.search(line) + if match and state.login_url is None: + with state.lock: + state.login_url = match.group(0) + log("login link ready — waiting for the code on the setup page.") + if proc.wait() != 0 and tailnet_ip() is None: + state.failed = "tailscale up ended without joining (see the lines above)." + + + PAGE = """ + + + palaia setup +

palaia setup

{body}""" + + + def code_form(error=""): + hint = f'

{html.escape(error)}

' if error else "" + return ( + "

Enter the setup code you were given.

" + f"{hint}" + '
' + ) + + + def connect_body(state, code): + if state.ip: + return done_body(state) + if state.failed: + return f'

Something went wrong: {html.escape(state.failed)}

' + if not state.login_url: + return ( + "

Preparing the connection — this takes a few seconds.

" + "" + ) + status_url = "/status?code=" + html.escape(normalise(code)) + return ( + "

One last step: connect this server to your private network. " + "Log in with the Tailscale account you use on your own devices.

" + f'Connect with Tailscale' + '

Waiting for you to log in…

' + "" + ) + + + def done_body(state): + url = f"http://{state.ip}:{state.port}/" + return ( + "

Done. This server is now on your private network.

" + f'

In a minute or two, palaia opens at ' + f"{html.escape(url)} — from any device where the Tailscale app is on.

" + '

This setup page closes itself now.

' + ) + + + class BoundedServer(ThreadingHTTPServer): + """One thread per connection, but never more than MAX_CLIENTS at once.""" + + daemon_threads = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.slots = threading.BoundedSemaphore(MAX_CLIENTS) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except Exception: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + + def make_handler(state): + class Handler(BaseHTTPRequestHandler): + timeout = CLIENT_TIMEOUT + + def log_message(self, *args): + pass + + def send(self, status, body, ctype="text/html; charset=utf-8"): + data = body.encode() + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + parsed = urlparse(self.path) + code = parse_qs(parsed.query).get("code", [""])[0] + if parsed.path == "/": + return self.send(200, PAGE.format(body=code_form())) + if parsed.path not in ("/connect", "/status"): + return self.send(404, PAGE.format(body="

Not found.

")) + if not state.code_ok(code): + time.sleep(WRONG_CODE_DELAY) + if parsed.path == "/status": + return self.send(403, json.dumps({"joined": False}), "application/json") + return self.send(403, PAGE.format(body=code_form("That code is not right."))) + if parsed.path == "/status": + return self.send(200, json.dumps({"joined": bool(state.ip)}), "application/json") + return self.send(200, PAGE.format(body=connect_body(state, code))) + + return Handler + + + def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8420, help="where this page listens") + parser.add_argument("--hub-port", type=int, default=8420, help="where palaia will answer") + parser.add_argument("--bind", default="0.0.0.0") + parser.add_argument("--hostname", default="palaia-hub") + parser.add_argument("--timeout", type=int, default=1800, help="seconds") + parser.add_argument("--linger", type=int, default=30, help="seconds to show 'done'") + args = parser.parse_args(argv) + + code = os.environ.get("PALAIA_SETUP_CODE", "").strip() or new_code() + if len(normalise(code)) < 8: + log("ERROR: PALAIA_SETUP_CODE is shorter than 8 characters.") + return 2 + + state = State(code, args.hub_port) + if tailnet_ip(): + log("this server is already on a tailnet — nothing to set up.") + return 0 + + # A plain `kill` (SIGTERM) or a closed terminal (SIGHUP) must still run the + # clean-up below, not leave the page or a pending login behind. + def stop(signum, _frame): + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGHUP, stop) + + server = BoundedServer((args.bind, args.port), make_handler(state)) + threading.Thread(target=server.serve_forever, daemon=True).start() + threading.Thread(target=run_tailscale, args=(state, args.hostname), daemon=True).start() + print( + "\n" + " ============================================================\n" + f" Open http://{public_address()}:{args.port}/\n" + f" Code {code}\n" + " Then click 'Connect with Tailscale' and log in.\n" + " ============================================================\n", + flush=True, + ) + + deadline = time.monotonic() + args.timeout + try: + while time.monotonic() < deadline and not state.failed: + ip = tailnet_ip() + if ip: + state.ip = ip + log(f"joined the tailnet as {ip}.") + time.sleep(args.linger) + return 0 + time.sleep(2) + finally: + server.shutdown() + server.server_close() + if state.proc and state.proc.poll() is None: + state.proc.terminate() + try: + state.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + state.proc.kill() + if not state.ip: + # Drop the pending login, so the link can no longer be used by + # anyone to pull this server into their network. + try: + subprocess.run( + ["tailscale", "logout"], capture_output=True, timeout=30, check=False + ) + except (OSError, subprocess.TimeoutExpired): + log("could not reset the pending Tailscale login — run 'tailscale logout'.") + log(f"ERROR: {state.failed or 'nobody completed the setup page in time'} — the page is closed.") + return 1 + + + if __name__ == "__main__": + sys.exit(main()) - path: /opt/palaia/cloud-init-setup.sh permissions: "0700" owner: root:root @@ -62,6 +385,7 @@ write_files: log() { echo "palaia cloud-init: $*"; } + PALAIA_SETUP_CODE="setup-code-REPLACE_ME" TAILSCALE_AUTH_KEY="tskey-REPLACE_ME" # rc-channel-note — during the 3.0.0 release candidate the `stable` # image does not exist yet (the release workflow only creates it on the @@ -77,10 +401,18 @@ write_files: VOLUME="palaia_home" PORT=8420 - if [ "${TAILSCALE_AUTH_KEY}" = "tskey-REPLACE_ME" ]; then - log "ERROR: edit this file's TAILSCALE_AUTH_KEY before pasting it —" - log " see https://login.tailscale.com/admin/settings/keys" - exit 1 + # Which way to join the tailnet: a filled-in key wins; otherwise the + # setup page with the filled-in code. The checks below look only for + # the marker word, so a copy where every placeholder was replaced + # still passes them. + USE_KEY=1 + case "${TAILSCALE_AUTH_KEY}" in *REPLACE_ME*|"") USE_KEY=0 ;; esac + if [ "$USE_KEY" = "0" ]; then + case "${PALAIA_SETUP_CODE}" in + *REPLACE_ME*|"") + log "ERROR: edit this file's PALAIA_SETUP_CODE (or TAILSCALE_AUTH_KEY) before pasting it." + exit 1 ;; + esac fi # --- Docker -------------------------------------------------------- @@ -105,8 +437,20 @@ write_files: fi systemctl enable --now tailscaled - log "joining the tailnet ..." - tailscale up --auth-key="${TAILSCALE_AUTH_KEY}" --hostname="palaia-hub" + if tailscale ip -4 >/dev/null 2>&1; then + log "already on a tailnet, skipping the join." + elif [ "$USE_KEY" = "1" ]; then + log "joining the tailnet ..." + tailscale up --auth-key="${TAILSCALE_AUTH_KEY}" --hostname="palaia-hub" + else + log "setup page: open http://:${PORT}/ and enter your setup code ..." + PALAIA_SETUP_CODE="${PALAIA_SETUP_CODE}" python3 /opt/palaia/setup-page.py \ + --port "${PORT}" --hub-port "${PORT}" --hostname palaia-hub :8420/, code +# printed below or set with PALAIA_SETUP_CODE) where you join your tailnet in +# the browser. With a key: set PALAIA_TSKEY=tskey-auth-... and pipe to `sh`. # LAN/local without Tailscale: set PALAIA_NO_TAILSCALE=1 (binds to 0.0.0.0). set -euo pipefail @@ -46,23 +48,369 @@ else $SUDO systemctl enable --now tailscaled >/dev/null 2>&1 || true TSKEY="${PALAIA_TSKEY:-}" - if [ -z "$TSKEY" ]; then - if [ -t 0 ]; then - echo "palaia: paste a Tailscale AUTH KEY (NOT an access token) —" - echo " create one at https://login.tailscale.com/admin/settings/keys" - echo " ('Generate auth key'; Reusable + Ephemeral off is fine):" - read -r TSKEY < /dev/tty || true + if [ -n "$TSKEY" ]; then + case "$TSKEY" in + tskey-*) : ;; + *) die "that does not look like an auth key (expected 'tskey-...'). You may have copied an *access token* instead — use 'Generate auth key'." ;; + esac + log "joining the tailnet ..." + if ! $SUDO tailscale up --auth-key="$TSKEY" --hostname="$HOSTNAME_TS"; then + die "Tailscale rejected the key. It is likely expired, already used (non-reusable), or an access token rather than an auth key. Generate a fresh auth key and re-run." fi - fi - [ -n "$TSKEY" ] || die "no Tailscale auth key given. Set PALAIA_TSKEY=tskey-... or run interactively." - case "$TSKEY" in - tskey-*) : ;; - *) die "that does not look like an auth key (expected 'tskey-...'). You may have copied an *access token* instead — use 'Generate auth key'." ;; - esac - - log "joining the tailnet ..." - if ! $SUDO tailscale up --auth-key="$TSKEY" --hostname="$HOSTNAME_TS"; then - die "Tailscale rejected the key. It is likely expired, already used (non-reusable), or an access token rather than an auth key. Generate a fresh auth key and re-run." + elif $SUDO tailscale ip -4 >/dev/null 2>&1; then + log "already on a tailnet, skipping the setup page." + else + # SPEC-605: no key given — a one-time setup page in the browser joins + # the tailnet instead (code-protected, closes itself afterwards). + if ! command -v python3 >/dev/null 2>&1; then + log "installing python3 for the setup page ..." + $SUDO apt-get install -y python3 >/dev/null 2>&1 || die "python3 is needed for the setup page. Install it, or pass PALAIA_TSKEY=tskey-... instead." + fi + $SUDO mkdir -p /opt/palaia + $SUDO tee /opt/palaia/setup-page.py >/dev/null <<'PALAIA_SETUP_PAGE' +#!/usr/bin/env python3 +# palaia v3 — one-time setup page (SPEC-605). +# +# Joins this server to the owner's Tailscale network without anyone handling +# a key: it starts `tailscale up` without an auth key, takes the login link +# Tailscale prints, and shows it on a small web page — but only to someone +# who enters the one-time setup code. The person opens +# http://:8420/, types the code, clicks "Connect with +# Tailscale" and logs in. When the server has its private-network address, +# the page says where palaia will be, and this program exits 0. +# +# It exits 1 on timeout (default 30 minutes) or when Tailscale fails. Either +# way the port is closed again the moment it exits; the installer then starts +# the hub bound to the private-network address only, exactly as before. +# +# Standard library only — python3 is on every image that runs cloud-init. +# The code comes from the PALAIA_SETUP_CODE environment variable, not the +# command line, so it never shows up in a process list. Without one, a fresh +# code is made up and printed together with the address to open. +# +# This file is the single source: get-palaia.sh and cloud-init.yaml embed +# it verbatim (checked by server/tests/deploy/test_setup_page.py). + +import argparse +import hmac +import html +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +LOGIN_URL_RE = re.compile(r"https://\S+") +WRONG_CODE_DELAY = 1.0 +MAX_CLIENTS = 32 # concurrent connections; more are dropped, not queued +CLIENT_TIMEOUT = 10 # seconds a connection may take to send its request +CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I look-alikes + + +def log(message): + print(f"palaia setup page: {message}", flush=True) + + +def normalise(code): + """Codes are compared without case, spaces or dashes.""" + return re.sub(r"[\s-]", "", code or "").upper() + + +def new_code(): + raw = "".join(secrets.choice(CODE_ALPHABET) for _ in range(12)) + return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}" + + +def public_address(): + """The address this server reaches the internet from — no packet is sent.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("1.1.1.1", 80)) + return sock.getsockname()[0] + except OSError: + return "" + + +def tailnet_ip(): + try: + out = subprocess.run( + ["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=10, check=False + ) + except (OSError, subprocess.TimeoutExpired): + return None + first = out.stdout.strip().splitlines()[:1] + return first[0] if out.returncode == 0 and first else None + + +class State: + def __init__(self, code, port): + self.code = normalise(code) + self.port = port + self.login_url = None + self.ip = None + self.failed = None + self.proc = None + self.lock = threading.Lock() + + def code_ok(self, given): + return hmac.compare_digest(normalise(given).encode(), self.code.encode()) + + +def run_tailscale(state, hostname): + """`tailscale up` without a key prints a login link and waits for the login.""" + try: + state.proc = proc = subprocess.Popen( + ["tailscale", "up", f"--hostname={hostname}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except OSError as exc: + state.failed = f"could not start tailscale: {exc}" + return + for line in proc.stdout: + match = LOGIN_URL_RE.search(line) + if match and state.login_url is None: + with state.lock: + state.login_url = match.group(0) + log("login link ready — waiting for the code on the setup page.") + if proc.wait() != 0 and tailnet_ip() is None: + state.failed = "tailscale up ended without joining (see the lines above)." + + +PAGE = """ + + +palaia setup +

palaia setup

{body}""" + + +def code_form(error=""): + hint = f'

{html.escape(error)}

' if error else "" + return ( + "

Enter the setup code you were given.

" + f"{hint}" + '
' + ) + + +def connect_body(state, code): + if state.ip: + return done_body(state) + if state.failed: + return f'

Something went wrong: {html.escape(state.failed)}

' + if not state.login_url: + return ( + "

Preparing the connection — this takes a few seconds.

" + "" + ) + status_url = "/status?code=" + html.escape(normalise(code)) + return ( + "

One last step: connect this server to your private network. " + "Log in with the Tailscale account you use on your own devices.

" + f'Connect with Tailscale' + '

Waiting for you to log in…

' + "" + ) + + +def done_body(state): + url = f"http://{state.ip}:{state.port}/" + return ( + "

Done. This server is now on your private network.

" + f'

In a minute or two, palaia opens at ' + f"{html.escape(url)} — from any device where the Tailscale app is on.

" + '

This setup page closes itself now.

' + ) + + +class BoundedServer(ThreadingHTTPServer): + """One thread per connection, but never more than MAX_CLIENTS at once.""" + + daemon_threads = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.slots = threading.BoundedSemaphore(MAX_CLIENTS) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except Exception: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + +def make_handler(state): + class Handler(BaseHTTPRequestHandler): + timeout = CLIENT_TIMEOUT + + def log_message(self, *args): + pass + + def send(self, status, body, ctype="text/html; charset=utf-8"): + data = body.encode() + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + parsed = urlparse(self.path) + code = parse_qs(parsed.query).get("code", [""])[0] + if parsed.path == "/": + return self.send(200, PAGE.format(body=code_form())) + if parsed.path not in ("/connect", "/status"): + return self.send(404, PAGE.format(body="

Not found.

")) + if not state.code_ok(code): + time.sleep(WRONG_CODE_DELAY) + if parsed.path == "/status": + return self.send(403, json.dumps({"joined": False}), "application/json") + return self.send(403, PAGE.format(body=code_form("That code is not right."))) + if parsed.path == "/status": + return self.send(200, json.dumps({"joined": bool(state.ip)}), "application/json") + return self.send(200, PAGE.format(body=connect_body(state, code))) + + return Handler + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8420, help="where this page listens") + parser.add_argument("--hub-port", type=int, default=8420, help="where palaia will answer") + parser.add_argument("--bind", default="0.0.0.0") + parser.add_argument("--hostname", default="palaia-hub") + parser.add_argument("--timeout", type=int, default=1800, help="seconds") + parser.add_argument("--linger", type=int, default=30, help="seconds to show 'done'") + args = parser.parse_args(argv) + + code = os.environ.get("PALAIA_SETUP_CODE", "").strip() or new_code() + if len(normalise(code)) < 8: + log("ERROR: PALAIA_SETUP_CODE is shorter than 8 characters.") + return 2 + + state = State(code, args.hub_port) + if tailnet_ip(): + log("this server is already on a tailnet — nothing to set up.") + return 0 + + # A plain `kill` (SIGTERM) or a closed terminal (SIGHUP) must still run the + # clean-up below, not leave the page or a pending login behind. + def stop(signum, _frame): + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGHUP, stop) + + server = BoundedServer((args.bind, args.port), make_handler(state)) + threading.Thread(target=server.serve_forever, daemon=True).start() + threading.Thread(target=run_tailscale, args=(state, args.hostname), daemon=True).start() + print( + "\n" + " ============================================================\n" + f" Open http://{public_address()}:{args.port}/\n" + f" Code {code}\n" + " Then click 'Connect with Tailscale' and log in.\n" + " ============================================================\n", + flush=True, + ) + + deadline = time.monotonic() + args.timeout + try: + while time.monotonic() < deadline and not state.failed: + ip = tailnet_ip() + if ip: + state.ip = ip + log(f"joined the tailnet as {ip}.") + time.sleep(args.linger) + return 0 + time.sleep(2) + finally: + server.shutdown() + server.server_close() + if state.proc and state.proc.poll() is None: + state.proc.terminate() + try: + state.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + state.proc.kill() + if not state.ip: + # Drop the pending login, so the link can no longer be used by + # anyone to pull this server into their network. + try: + subprocess.run( + ["tailscale", "logout"], capture_output=True, timeout=30, check=False + ) + except (OSError, subprocess.TimeoutExpired): + log("could not reset the pending Tailscale login — run 'tailscale logout'.") + log(f"ERROR: {state.failed or 'nobody completed the setup page in time'} — the page is closed.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +PALAIA_SETUP_PAGE + # Open the port for the page only if ufw is on and does not allow it + # already — and remove only the rule this run added, also when the + # run is interrupted. + UFW_OPENED=0 + if command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -q "Status: active" \ + && ! $SUDO ufw status 2>/dev/null | grep -q "^${PORT}/tcp .*ALLOW"; then + $SUDO ufw allow "${PORT}/tcp" comment 'palaia setup page, temporary' >/dev/null && UFW_OPENED=1 + fi + close_setup_port() { + if [ "$UFW_OPENED" = "1" ]; then + $SUDO ufw delete allow "${PORT}/tcp" >/dev/null 2>&1 || true + UFW_OPENED=0 + fi + } + trap 'close_setup_port' EXIT + trap 'close_setup_port; exit 130' INT TERM HUP + log "starting the setup page (closes itself after 30 minutes) ..." + # The code travels in the environment, never as a command argument. + export PALAIA_SETUP_CODE="${PALAIA_SETUP_CODE:-}" + SETUP_OK=1 + if [ -n "$SUDO" ]; then + $SUDO --preserve-env=PALAIA_SETUP_CODE python3 /opt/palaia/setup-page.py \ + --port "$PORT" --hub-port "$PORT" --hostname "$HOSTNAME_TS" :8420/, types the code, clicks "Connect with +# Tailscale" and logs in. When the server has its private-network address, +# the page says where palaia will be, and this program exits 0. +# +# It exits 1 on timeout (default 30 minutes) or when Tailscale fails. Either +# way the port is closed again the moment it exits; the installer then starts +# the hub bound to the private-network address only, exactly as before. +# +# Standard library only — python3 is on every image that runs cloud-init. +# The code comes from the PALAIA_SETUP_CODE environment variable, not the +# command line, so it never shows up in a process list. Without one, a fresh +# code is made up and printed together with the address to open. +# +# This file is the single source: get-palaia.sh and cloud-init.yaml embed +# it verbatim (checked by server/tests/deploy/test_setup_page.py). + +import argparse +import hmac +import html +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +LOGIN_URL_RE = re.compile(r"https://\S+") +WRONG_CODE_DELAY = 1.0 +MAX_CLIENTS = 32 # concurrent connections; more are dropped, not queued +CLIENT_TIMEOUT = 10 # seconds a connection may take to send its request +CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I look-alikes + + +def log(message): + print(f"palaia setup page: {message}", flush=True) + + +def normalise(code): + """Codes are compared without case, spaces or dashes.""" + return re.sub(r"[\s-]", "", code or "").upper() + + +def new_code(): + raw = "".join(secrets.choice(CODE_ALPHABET) for _ in range(12)) + return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}" + + +def public_address(): + """The address this server reaches the internet from — no packet is sent.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("1.1.1.1", 80)) + return sock.getsockname()[0] + except OSError: + return "" + + +def tailnet_ip(): + try: + out = subprocess.run( + ["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=10, check=False + ) + except (OSError, subprocess.TimeoutExpired): + return None + first = out.stdout.strip().splitlines()[:1] + return first[0] if out.returncode == 0 and first else None + + +class State: + def __init__(self, code, port): + self.code = normalise(code) + self.port = port + self.login_url = None + self.ip = None + self.failed = None + self.proc = None + self.lock = threading.Lock() + + def code_ok(self, given): + return hmac.compare_digest(normalise(given).encode(), self.code.encode()) + + +def run_tailscale(state, hostname): + """`tailscale up` without a key prints a login link and waits for the login.""" + try: + state.proc = proc = subprocess.Popen( + ["tailscale", "up", f"--hostname={hostname}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except OSError as exc: + state.failed = f"could not start tailscale: {exc}" + return + for line in proc.stdout: + match = LOGIN_URL_RE.search(line) + if match and state.login_url is None: + with state.lock: + state.login_url = match.group(0) + log("login link ready — waiting for the code on the setup page.") + if proc.wait() != 0 and tailnet_ip() is None: + state.failed = "tailscale up ended without joining (see the lines above)." + + +PAGE = """ + + +palaia setup +

palaia setup

{body}""" + + +def code_form(error=""): + hint = f'

{html.escape(error)}

' if error else "" + return ( + "

Enter the setup code you were given.

" + f"{hint}" + '
' + ) + + +def connect_body(state, code): + if state.ip: + return done_body(state) + if state.failed: + return f'

Something went wrong: {html.escape(state.failed)}

' + if not state.login_url: + return ( + "

Preparing the connection — this takes a few seconds.

" + "" + ) + status_url = "/status?code=" + html.escape(normalise(code)) + return ( + "

One last step: connect this server to your private network. " + "Log in with the Tailscale account you use on your own devices.

" + f'Connect with Tailscale' + '

Waiting for you to log in…

' + "" + ) + + +def done_body(state): + url = f"http://{state.ip}:{state.port}/" + return ( + "

Done. This server is now on your private network.

" + f'

In a minute or two, palaia opens at ' + f"{html.escape(url)} — from any device where the Tailscale app is on.

" + '

This setup page closes itself now.

' + ) + + +class BoundedServer(ThreadingHTTPServer): + """One thread per connection, but never more than MAX_CLIENTS at once.""" + + daemon_threads = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.slots = threading.BoundedSemaphore(MAX_CLIENTS) + + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except Exception: + self.slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + + +def make_handler(state): + class Handler(BaseHTTPRequestHandler): + timeout = CLIENT_TIMEOUT + + def log_message(self, *args): + pass + + def send(self, status, body, ctype="text/html; charset=utf-8"): + data = body.encode() + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + parsed = urlparse(self.path) + code = parse_qs(parsed.query).get("code", [""])[0] + if parsed.path == "/": + return self.send(200, PAGE.format(body=code_form())) + if parsed.path not in ("/connect", "/status"): + return self.send(404, PAGE.format(body="

Not found.

")) + if not state.code_ok(code): + time.sleep(WRONG_CODE_DELAY) + if parsed.path == "/status": + return self.send(403, json.dumps({"joined": False}), "application/json") + return self.send(403, PAGE.format(body=code_form("That code is not right."))) + if parsed.path == "/status": + return self.send(200, json.dumps({"joined": bool(state.ip)}), "application/json") + return self.send(200, PAGE.format(body=connect_body(state, code))) + + return Handler + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8420, help="where this page listens") + parser.add_argument("--hub-port", type=int, default=8420, help="where palaia will answer") + parser.add_argument("--bind", default="0.0.0.0") + parser.add_argument("--hostname", default="palaia-hub") + parser.add_argument("--timeout", type=int, default=1800, help="seconds") + parser.add_argument("--linger", type=int, default=30, help="seconds to show 'done'") + args = parser.parse_args(argv) + + code = os.environ.get("PALAIA_SETUP_CODE", "").strip() or new_code() + if len(normalise(code)) < 8: + log("ERROR: PALAIA_SETUP_CODE is shorter than 8 characters.") + return 2 + + state = State(code, args.hub_port) + if tailnet_ip(): + log("this server is already on a tailnet — nothing to set up.") + return 0 + + # A plain `kill` (SIGTERM) or a closed terminal (SIGHUP) must still run the + # clean-up below, not leave the page or a pending login behind. + def stop(signum, _frame): + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGHUP, stop) + + server = BoundedServer((args.bind, args.port), make_handler(state)) + threading.Thread(target=server.serve_forever, daemon=True).start() + threading.Thread(target=run_tailscale, args=(state, args.hostname), daemon=True).start() + print( + "\n" + " ============================================================\n" + f" Open http://{public_address()}:{args.port}/\n" + f" Code {code}\n" + " Then click 'Connect with Tailscale' and log in.\n" + " ============================================================\n", + flush=True, + ) + + deadline = time.monotonic() + args.timeout + try: + while time.monotonic() < deadline and not state.failed: + ip = tailnet_ip() + if ip: + state.ip = ip + log(f"joined the tailnet as {ip}.") + time.sleep(args.linger) + return 0 + time.sleep(2) + finally: + server.shutdown() + server.server_close() + if state.proc and state.proc.poll() is None: + state.proc.terminate() + try: + state.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + state.proc.kill() + if not state.ip: + # Drop the pending login, so the link can no longer be used by + # anyone to pull this server into their network. + try: + subprocess.run( + ["tailscale", "logout"], capture_output=True, timeout=30, check=False + ) + except (OSError, subprocess.TimeoutExpired): + log("could not reset the pending Tailscale login — run 'tailscale logout'.") + log(f"ERROR: {state.failed or 'nobody completed the setup page in time'} — the page is closed.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/v3/server/tests/clients/test_install_skill_drift.py b/v3/server/tests/clients/test_install_skill_drift.py index 0ff57f4..d7e7740 100644 --- a/v3/server/tests/clients/test_install_skill_drift.py +++ b/v3/server/tests/clients/test_install_skill_drift.py @@ -20,11 +20,10 @@ - the image tag is never hardcoded — the skill derives it from ``v3/VERSION`` the same way the onboarding page and ``test_version_drift.py`` do; -- the key substitution the skill prescribes, applied to the real - ``cloud-init.yaml``, yields a cloud-config that still parses and whose - "forgot the key" guard lets the real key through — executed, not just - pattern-matched, because the naive replace-every-placeholder edit silently - produces a server that stops on first boot. +- the setup-code substitution the skill prescribes (SPEC-605), applied to + the real ``cloud-init.yaml``, yields a cloud-config that still parses and + whose "forgot to fill it in" guard lets it through — executed, not just + pattern-matched. """ from __future__ import annotations @@ -63,13 +62,11 @@ #: Same header regex ``deploy-snippets.mjs``'s ``loadGetPalaiaCommand`` uses. _GET_PALAIA_HEADER_RE = re.compile(r"^#\s+(curl\s.*get\.palaia\.ai.*)$", re.MULTILINE) -#: The one edit the skill prescribes to the cloud-init file, and its result. -_KEY_LINE = 'TAILSCALE_AUTH_KEY="tskey-REPLACE_ME"' -_KEY_LINE_FILLED = 'TAILSCALE_AUTH_KEY=""' +#: The one edit the skill prescribes to the cloud-init file, and its result +#: (SPEC-605: a setup code the assistant makes up — no key to handle). +_CODE_LINE = 'PALAIA_SETUP_CODE="setup-code-REPLACE_ME"' +_CODE_LINE_FILLED = 'PALAIA_SETUP_CODE=""' _SETUP_SCRIPT_PATH = "/opt/palaia/cloud-init-setup.sh" -#: How the setup script's "forgot the key" guard opens (found by shape, not -#: by the placeholder, so a wrongly edited copy is still located). -_GUARD_OPENING = 'if [ "${TAILSCALE_AUTH_KEY}" = ' #: Literals the skill quotes that are not palaia's own to ship, so they #: cannot be found in ``v3/deploy`` — each with the reason it may stand. @@ -275,12 +272,12 @@ def test_the_image_tag_is_derived_from_version_not_hardcoded() -> None: assert line.endswith("PALAIA_CHANNEL=beta sh"), line -# --- the key substitution, applied to the real file ---------------------- +# --- the setup-code substitution, applied to the real file ------------------ -def _substitute(cloud_init: str, key: str) -> str: - """The edit Step 2a prescribes: replace the key line's exact text.""" - return cloud_init.replace(_KEY_LINE, _KEY_LINE_FILLED.replace("", key)) +def _substitute(cloud_init: str, code: str) -> str: + """The edit Step 2a prescribes: replace the code line's exact text.""" + return cloud_init.replace(_CODE_LINE, _CODE_LINE_FILLED.replace("", code)) def _setup_script(cloud_init: str) -> str: @@ -292,61 +289,45 @@ def _setup_script(cloud_init: str) -> str: raise AssertionError(f"cloud-init.yaml no longer writes {_SETUP_SCRIPT_PATH}") -def _guard_prefix(script: str) -> str: - """The script up to and including the "forgot the key" guard's ``fi``.""" - lines = script.splitlines() - guard = next( - (i for i, line in enumerate(lines) if line.lstrip().startswith(_GUARD_OPENING)), - None, - ) - assert guard is not None, "the setup script no longer checks for the unfilled key" - end = next(i for i in range(guard, len(lines)) if lines[i].strip() == "fi") - return "\n".join(lines[: end + 1]) + "\n" - - def test_the_skill_prescribes_exactly_this_edit() -> None: literals = _quoted_literals(_skill_body()) - assert _KEY_LINE in literals - assert _KEY_LINE_FILLED in literals + assert _CODE_LINE in literals + assert _CODE_LINE_FILLED in literals def test_the_substituted_file_is_a_valid_cloud_config() -> None: template = CLOUD_INIT_PATH.read_text(encoding="utf-8") - filled = _substitute(template, "tskey-auth-kEXAMPLE1CNTRL-0123456789abcdef") + filled = _substitute(template, "K7QM-R2XD-9FTP") assert filled.startswith("#cloud-config") assert yaml.safe_load(filled) is not None - # Only the key changed: the documented line was actually present, and - # every other line is untouched. + # Only the code line changed: it was actually present, and every other + # line is untouched. changed = [ (old, new) for old, new in zip(template.splitlines(), filled.splitlines(), strict=True) if old != new ] - assert changed, f"{_KEY_LINE!r} is not in cloud-init.yaml any more" - assert all(_KEY_LINE in old for old, _ in changed), changed - - script = _setup_script(filled) - assert 'TAILSCALE_AUTH_KEY="tskey-auth-kEXAMPLE1CNTRL-0123456789abcdef"' in script - # The guard still compares against the placeholder, not the real key. - assert '= "tskey-REPLACE_ME" ]' in script + assert changed, f"{_CODE_LINE!r} is not in cloud-init.yaml any more" + assert all(_CODE_LINE in old for old, _ in changed), changed + assert 'PALAIA_SETUP_CODE="K7QM-R2XD-9FTP"' in _setup_script(filled) @pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") -def test_the_guard_passes_the_substituted_key_and_stops_the_rest(tmp_path: Path) -> None: - """Run the real guard: the skill's edit must pass it; the unedited file, - and the naive "replace every placeholder" edit, must both be stopped.""" +def test_the_guard_passes_the_substituted_code_and_stops_the_unedited_file(tmp_path: Path) -> None: + """Run the real guard: the skill's edit must pass it (setup-page path); + the unedited file must be stopped.""" template = CLOUD_INIT_PATH.read_text(encoding="utf-8") - key = "tskey-auth-kEXAMPLE1CNTRL-0123456789abcdef" cases = { - "skill's edit": (_substitute(template, key), 0), + "skill's edit": (_substitute(template, "K7QM-R2XD-9FTP"), 0), "unedited": (template, 1), - "replace-every-placeholder": (template.replace("tskey-REPLACE_ME", key), 1), } for label, (text, expected) in cases.items(): - script = tmp_path / f"{label.replace(' ', '_')}.sh" - script.write_text(_guard_prefix(_setup_script(text)), encoding="utf-8") + script = _setup_script(text) + guard = script[: script.index("# --- Docker")] + path = tmp_path / f"{label.replace(' ', '_').replace("'", '')}.sh" + path.write_text(guard, encoding="utf-8") result = subprocess.run( - ["bash", str(script)], capture_output=True, text=True, timeout=30, check=False + ["bash", str(path)], capture_output=True, text=True, timeout=30, check=False ) assert result.returncode == expected, (label, result.stdout, result.stderr) diff --git a/v3/server/tests/deploy/test_cloud_init.py b/v3/server/tests/deploy/test_cloud_init.py index 9805365..875abf0 100644 --- a/v3/server/tests/deploy/test_cloud_init.py +++ b/v3/server/tests/deploy/test_cloud_init.py @@ -13,10 +13,10 @@ onboarding page's snippets, and ``server/tests/e2e/test_docker_one_liner_smoke.py`` already applies to its own smoke-test invocation. -3. The one placeholder the SPEC promises ("the Tailscale auth key is the - ONE placeholder... no other editing required") really is the only one, - and the hub port is bound so it is reachable over the tailnet only, - never the public internet. +3. The only thing to fill in is the setup code (SPEC-605) or, instead, a + Tailscale auth key — the guard picks the right path for each — and the + hub port is bound so it is reachable over the tailnet only, never the + public internet. """ from __future__ import annotations @@ -83,24 +83,86 @@ def test_hardening_flags_match_install_sh_verbatim() -> None: ) -def test_tailscale_auth_key_is_the_only_placeholder() -> None: +#: The two values a person may fill in (SPEC-605: the setup code is the +#: default, the Tailscale key the alternative) — and the guard's own pattern. +_PLACEHOLDERS = ("setup-code-REPLACE_ME", "tskey-REPLACE_ME", "*REPLACE_ME*") + + +def test_the_setup_code_and_the_key_are_the_only_placeholders() -> None: cloud_init = CLOUD_INIT_PATH.read_text(encoding="utf-8") - assert "tskey-REPLACE_ME" in cloud_init, ( - "the Tailscale auth key placeholder must be clearly marked as 'tskey-REPLACE_ME'" - ) - # Every occurrence of the word "REPLACE_ME" is part of that one marker - # (the header comment names it, the script itself sets it and checks - # for it) — none stand alone as a second, different placeholder. + for marker in ( + 'PALAIA_SETUP_CODE="setup-code-REPLACE_ME"', + 'TAILSCALE_AUTH_KEY="tskey-REPLACE_ME"', + ): + assert marker in cloud_init, f"the placeholder line {marker!r} is gone" + # Every "REPLACE_ME" belongs to one of those two values or to the guard + # that checks them — none stands alone as a third thing to edit. stray = [ m.start() for m in re.finditer("REPLACE_ME", cloud_init) - if not cloud_init[: m.start()].endswith("tskey-") + if not any( + cloud_init[m.start() - marker.index("REPLACE_ME") :].startswith(marker) + for marker in _PLACEHOLDERS + ) ] - assert not stray, f"a 'REPLACE_ME' not part of 'tskey-REPLACE_ME' at offset(s) {stray}" + assert not stray, f"a 'REPLACE_ME' outside the known placeholders at offset(s) {stray}" assert not re.search(r"CHANGE_?ME|YOUR_[A-Z_]+", cloud_init), ( - "found a second placeholder-looking marker besides the Tailscale auth key" + "found another placeholder-looking marker" + ) + + +def _guard(script: str) -> str: + """The setup script from its start up to the Docker step: variables + guard.""" + return script[: script.index("# --- Docker")] + 'echo "USE_KEY=${USE_KEY}"\n' + + +def _setup_script() -> str: + import yaml + + document = yaml.safe_load(CLOUD_INIT_PATH.read_text(encoding="utf-8")) + files = {entry["path"]: entry["content"] for entry in document["write_files"]} + return str(files["/opt/palaia/cloud-init-setup.sh"]) + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not on PATH") +@pytest.mark.parametrize( + ("edit", "exit_code", "use_key"), + [ + ({}, 1, None), + # An emptied key line is "no key", not a key: it must not skip the page. + ({"tskey-REPLACE_ME": ""}, 1, None), + ({"tskey-REPLACE_ME": "", "setup-code-REPLACE_ME": "K7QM-R2XD-9FTP"}, 0, "0"), + ({"setup-code-REPLACE_ME": "K7QM-R2XD-9FTP"}, 0, "0"), + ({"tskey-REPLACE_ME": "tskey-auth-kEXAMPLE-0123"}, 0, "1"), + ( + { + "setup-code-REPLACE_ME": "K7QM-R2XD-9FTP", + "tskey-REPLACE_ME": "tskey-auth-kEXAMPLE-0123", + }, + 0, + "1", + ), + ], +) +def test_the_guard_picks_the_setup_page_or_the_key( + tmp_path: Path, edit: dict[str, str], exit_code: int, use_key: str | None +) -> None: + """SPEC-605: unedited stops with a clear error; a code alone means the + setup page; a key (with or without a code) means the key path.""" + script = _setup_script() + for placeholder, value in edit.items(): + script = script.replace(f'"{placeholder}"', f'"{value}"') + path = tmp_path / "guard.sh" + path.write_text(_guard(script), encoding="utf-8") + result = subprocess.run( + ["bash", str(path)], capture_output=True, text=True, timeout=30, check=False ) + assert result.returncode == exit_code, (result.stdout, result.stderr) + if use_key is not None: + assert f"USE_KEY={use_key}" in result.stdout + else: + assert "PALAIA_SETUP_CODE" in result.stdout def test_hub_port_is_bound_to_the_tailnet_address_only() -> None: @@ -150,3 +212,9 @@ def test_cloud_init_schema_validates() -> None: ) assert result.returncode == 0, result.stdout + result.stderr assert "Valid schema" in result.stdout, result.stdout + result.stderr + + +def test_cloud_init_fits_the_user_data_limits() -> None: + """Hetzner Cloud takes 32 KiB of user data (DigitalOcean 64 KiB). The + embedded setup page (SPEC-605) made the file bigger — keep it inside.""" + assert len(CLOUD_INIT_PATH.read_bytes()) <= 32 * 1024 diff --git a/v3/server/tests/deploy/test_setup_page.py b/v3/server/tests/deploy/test_setup_page.py new file mode 100644 index 0000000..cb5f8ce --- /dev/null +++ b/v3/server/tests/deploy/test_setup_page.py @@ -0,0 +1,291 @@ +"""SPEC-605: the one-time setup page that joins a server to the tailnet. + +``v3/deploy/setup-page.py`` is run for real here, against a fake +``tailscale`` on ``PATH`` — no network, no real tailnet. The fake prints a +login link like ``tailscale up`` does without a key, and only reports an +address once the test "logs in" (creates a marker file). That proves the +promises the SPEC makes: + +- without the right code, the login link is never served — not on the + page, not in the status answer; +- with it, the page offers "Connect with Tailscale" with that link; +- once the server has its tailnet address the page says done and the + program exits 0, so the installer can continue; +- on timeout it exits 1 and the port is closed; +- the copies embedded in ``get-palaia.sh`` and ``cloud-init.yaml`` are the + same file, byte for byte (one source of truth). +""" + +from __future__ import annotations + +import os +import re +import signal +import socket +import subprocess +import sys +import textwrap +import time +import urllib.error +import urllib.request +from collections.abc import Iterator +from pathlib import Path + +import pytest +import yaml + +DEPLOY_ROOT = Path(__file__).resolve().parents[3] / "deploy" +SETUP_PAGE = DEPLOY_ROOT / "setup-page.py" +LOGIN_URL = "https://login.tailscale.com/a/test0123456789" +CODE = "ABCD-EFGH-JKLM" + +FAKE_TAILSCALE = textwrap.dedent( + f"""\ + #!/bin/sh + # Fake tailscale: `up` prints a login link and waits for the marker; + # `ip -4` answers only once the marker exists. + marker="$FAKE_TS_DIR/logged-in" + case "$1" in + up) + printf '\\nTo authenticate, visit:\\n\\n\\t{LOGIN_URL}\\n\\n' >&2 + while [ ! -f "$marker" ]; do sleep 0.2; done + echo Success. >&2 + exit 0 ;; + ip) + [ -f "$marker" ] && echo 100.64.0.7 && exit 0 + exit 1 ;; + logout) + touch "$FAKE_TS_DIR/logged-out" + exit 0 ;; + esac + exit 0 + """ +) + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _get(url: str) -> tuple[int, str]: + try: + with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 - local test server + return resp.status, resp.read().decode() + except urllib.error.HTTPError as err: + return err.code, err.read().decode() + + +def _wait_for_port(port: int, proc: subprocess.Popen[str]) -> None: + for _ in range(100): + if proc.poll() is not None: + raise AssertionError( + f"setup page exited early: {proc.stdout.read() if proc.stdout else ''}" + ) + with socket.socket() as sock: + if sock.connect_ex(("127.0.0.1", port)) == 0: + return + time.sleep(0.1) + raise AssertionError("setup page never started listening") + + +@pytest.fixture +def fake_ts(tmp_path: Path) -> Path: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + tailscale = bin_dir / "tailscale" + tailscale.write_text(FAKE_TAILSCALE, encoding="utf-8") + tailscale.chmod(0o755) + return tmp_path + + +def _start(fake_ts: Path, port: int, timeout: int = 60, code: str = CODE) -> subprocess.Popen[str]: + env = { + **os.environ, + "PATH": f"{fake_ts / 'bin'}{os.pathsep}{os.environ['PATH']}", + "FAKE_TS_DIR": str(fake_ts), + "PALAIA_SETUP_CODE": code, + } + return subprocess.Popen( + [ + sys.executable, + str(SETUP_PAGE), + "--bind", + "127.0.0.1", + "--port", + str(port), + "--timeout", + str(timeout), + "--linger", + "1", + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +@pytest.fixture +def running(fake_ts: Path) -> Iterator[tuple[subprocess.Popen[str], int]]: + port = _free_port() + proc = _start(fake_ts, port) + _wait_for_port(port, proc) + yield proc, port + if proc.poll() is None: + proc.kill() + proc.wait(timeout=10) + + +def _connect_page(port: int, code: str) -> tuple[int, str]: + # The login link appears a moment after start; the page says "preparing" until then. + for _ in range(50): + status, body = _get(f"http://127.0.0.1:{port}/connect?code={code}") + if status != 200 or "Preparing" not in body: + return status, body + time.sleep(0.1) + return status, body + + +def test_the_start_page_asks_for_the_code_and_never_shows_the_link(running) -> None: + _, port = running + status, body = _get(f"http://127.0.0.1:{port}/") + assert status == 200 + assert "setup code" in body + assert "tailscale.com/a/" not in body + + +def test_a_wrong_code_never_gets_the_link(running) -> None: + _, port = running + status, body = _connect_page(port, "WRONG-CODE-0000") + assert status == 403 + assert "not right" in body + assert "tailscale.com/a/" not in body + status, body = _get(f"http://127.0.0.1:{port}/status?code=WRONG-CODE-0000") + assert status == 403 + assert "tailscale.com/a/" not in body + + +def test_the_right_code_shows_connect_and_the_login_link(running) -> None: + _, port = running + # Case, spaces and dashes do not matter when typing the code. + status, body = _connect_page(port, "abcdefghjklm") + assert status == 200 + assert "Connect with Tailscale" in body + assert LOGIN_URL in body + + +def test_after_login_it_reports_done_and_exits_zero(running, fake_ts: Path) -> None: + proc, port = running + _connect_page(port, CODE) + (fake_ts / "logged-in").touch() + for _ in range(50): + status, body = _get(f"http://127.0.0.1:{port}/status?code={CODE}") + if '"joined": true' in body: + break + time.sleep(0.2) + assert status == 200 and '"joined": true' in body + _, page = _get(f"http://127.0.0.1:{port}/connect?code={CODE}") + assert "http://100.64.0.7:8420/" in page + assert proc.wait(timeout=60) == 0 + + +def test_timeout_exits_one_closes_the_port_and_drops_the_pending_login(fake_ts: Path) -> None: + port = _free_port() + proc = _start(fake_ts, port, timeout=2) + _wait_for_port(port, proc) + assert proc.wait(timeout=30) == 1 + with socket.socket() as sock: + assert sock.connect_ex(("127.0.0.1", port)) != 0 + # The login link must not stay usable after the page is gone. + assert (fake_ts / "logged-out").exists() + + +def test_a_plain_kill_still_cleans_up(fake_ts: Path) -> None: + port = _free_port() + proc = _start(fake_ts, port) + _wait_for_port(port, proc) + proc.send_signal(signal.SIGTERM) + assert proc.wait(timeout=30) != 0 + with socket.socket() as sock: + assert sock.connect_ex(("127.0.0.1", port)) != 0 + assert (fake_ts / "logged-out").exists() + # The fake `tailscale up` (waiting for a login) was stopped, not orphaned. + leftover = subprocess.run( + ["pgrep", "-f", f"{fake_ts}/bin/tailscale up"], capture_output=True, text=True, check=False + ) + assert leftover.stdout.strip() == "" + + +def test_a_joined_server_is_not_logged_out(running, fake_ts: Path) -> None: + proc, _ = running + (fake_ts / "logged-in").touch() + assert proc.wait(timeout=60) == 0 + assert not (fake_ts / "logged-out").exists() + + +def test_idle_connections_cannot_pile_up(running) -> None: + """Connections that never send a request are capped and time out, so a + flood of them cannot lock the owner out of the page.""" + _, port = running + idle = [socket.create_connection(("127.0.0.1", port)) for _ in range(40)] + try: + time.sleep(0.5) + # Over the cap: some of those were closed by the server right away. + closed = 0 + for sock in idle: + sock.settimeout(0.2) + try: + if sock.recv(1) == b"": + closed += 1 + except (TimeoutError, OSError): + pass + assert closed >= 1 + finally: + for sock in idle: + sock.close() + # Once the idle ones are gone, the page answers again. + status, _ = _get(f"http://127.0.0.1:{port}/") + assert status == 200 + + +def test_refuses_a_code_that_is_too_short(fake_ts: Path) -> None: + proc = _start(fake_ts, _free_port(), code="short") + assert proc.wait(timeout=10) == 2 + + +def test_without_a_code_it_makes_one_up_and_prints_it(fake_ts: Path) -> None: + port = _free_port() + proc = _start(fake_ts, port, timeout=2, code="") + out, _ = proc.communicate(timeout=30) + match = re.search(r"Code\s+([A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4})", out) + assert match, out + assert not set(match.group(1).replace("-", "")) & set("01IO") + assert f":{port}/" in out + + +def test_already_joined_server_skips_the_page(fake_ts: Path) -> None: + (fake_ts / "logged-in").touch() + proc = _start(fake_ts, _free_port()) + assert proc.wait(timeout=10) == 0 + + +# --- one source of truth ------------------------------------------------------ + + +def _source() -> str: + return SETUP_PAGE.read_text(encoding="utf-8") + + +def test_get_palaia_embeds_the_setup_page_verbatim() -> None: + script = (DEPLOY_ROOT / "get-palaia.sh").read_text(encoding="utf-8") + match = re.search(r"<<'PALAIA_SETUP_PAGE'\n(.*?)^PALAIA_SETUP_PAGE$", script, re.S | re.M) + assert match, "get-palaia.sh no longer embeds setup-page.py in a 'PALAIA_SETUP_PAGE' heredoc" + assert match.group(1) == _source() + + +def test_cloud_init_embeds_the_setup_page_verbatim() -> None: + document = yaml.safe_load((DEPLOY_ROOT / "cloud-init.yaml").read_text(encoding="utf-8")) + files = {entry["path"]: entry["content"] for entry in document["write_files"]} + assert files.get("/opt/palaia/setup-page.py") == _source() diff --git a/v3/site/docs/src/i18n/onboarding/de.json b/v3/site/docs/src/i18n/onboarding/de.json index 36e9f2b..8cdbc28 100644 --- a/v3/site/docs/src/i18n/onboarding/de.json +++ b/v3/site/docs/src/i18n/onboarding/de.json @@ -33,15 +33,15 @@ "fine": "Kein Terminal, kein SSH. Du fügst eine Datei ein, während Du den Server erstellst, und er richtet sich selbst ein. Jeder kleine Anbieter geht — Hetzner Cloud und DigitalOcean sind gute, günstige Optionen; die kleinste Größe reicht dicke.", "step1H3": "Beginn, einen Server zu erstellen", "step1Body": "Melde Dich bei Deinem Anbieter an und beginn, einen neuen Server zu erstellen (einen \"Cloud Server\" oder ein \"Droplet\"). Wähl irgendein aktuelles Ubuntu- oder Debian-Image und die kleinste Größe — lass die Seite an dem Schritt offen, an dem sie ein Feld Cloud config oder User data anbietet. In dieses Feld kommt die Datei aus Schritt 3.", - "step2H3": "Hol Dir einen Tailscale-Schlüssel", - "step2Body": "palaia tritt einem tailnet bei — einem kleinen, privaten Netzwerk aus nur Deinen eigenen Geräten (über Tailscale, für den privaten Gebrauch kostenlos), damit Dein hub von Deinem Handy und Laptop erreichbar ist, aber nie aus dem offenen Internet. Erstell ein kostenloses Tailscale-Konto, öffne dann die Seite mit den Auth-Keys und erzeug einen Schlüssel (ein wiederverwendbarer, kurzlebiger Schlüssel eignet sich gut). Kopier ihn — im nächsten Schritt fügst Du ihn in die Datei ein.", - "step3H3": "Diese Datei einfügen, den Schlüssel ersetzen, den Server erstellen", - "step3Body": "Kopier die Datei unten in das Feld Cloud config / User data des Anbieters. Änder die eine Zeile, die sie markiert — TAILSCALE_AUTH_KEY=\"tskey-REPLACE_ME\" weiter oben — auf den Schlüssel aus Schritt 2. Sonst musst Du nichts bearbeiten. Dann erstell den Server.", - "afterNote": "Ein paar Minuten nach dem Start des Servers installiert er alles und startet den hub von selbst. Dann öffne Deinen hub unter seiner tailnet-Adresse. Falls er nicht hochkommt, steht in der Datei /var/log/cloud-init-output.log des Servers das vollständige Log dessen, was gelaufen ist." + "step2H3": "Denk Dir einen Setup-Code aus", + "step2Body": "palaia tritt einem Tailnet bei — einem kleinen privaten Netz nur aus Deinen eigenen Geräten (über Tailscale, für private Nutzung kostenlos). So ist Dein Hub von Handy und Laptop erreichbar, aber nie aus dem offenen Internet. Du brauchst ein kostenloses Tailscale-Konto und die Tailscale-App auf dem Gerät, das Du benutzt. Einen Schlüssel musst Du nicht erzeugen: Denk Dir einfach einen Setup-Code aus 12 Buchstaben und Ziffern aus, etwa K7QM-R2XD-9FTP. Du tippst ihn einmal ein, auf palaias Einrichtungsseite.", + "step3H3": "Diese Datei einfügen, Deinen Code eintragen, den Server erstellen", + "step3Body": "Kopiere die Datei unten in das Feld Cloud config / User data Deines Anbieters. Ändere die eine markierte Zeile — PALAIA_SETUP_CODE=\"setup-code-REPLACE_ME\" weit oben — auf Deinen Code aus Schritt 2. Sonst musst Du nichts ändern. Dann erstelle den Server.", + "afterNote": "Ein paar Minuten nach dem Start öffnest Du http://<Server-Adresse>:8420/, gibst Deinen Code ein, klickst auf Connect with Tailscale und meldest Dich an. Die Seite zeigt die private Adresse Deines Hubs und schließt sich dann endgültig. Kommt er nicht hoch, steht in /var/log/cloud-init-output.log auf dem Server das vollständige Protokoll." }, "existing": { "h2": "Schon einen Server? Ein Befehl über SSH", - "body": "Die Datei oben läuft nur beim ersten Start eines Servers, deshalb passiert nichts, wenn Du sie in eine Kiste einfügst, die schon läuft. Für einen Server, den Du schon hast, verbinde Dich per SSH und führ diese eine Zeile aus — der Installer richtet Docker, Tailscale, die nur-tailnet-Firewall und den gehärteten hub ein und bittet Dich beim Ausführen, einen Tailscale-Schlüssel einzufügen:" + "body": "Die Datei oben läuft nur beim ersten Start eines Servers — in einen Server, der schon läuft, einzufügen, bewirkt nichts. Für einen Server, den Du schon hast, melde Dich per SSH an und führe diese eine Zeile aus. Der Installer richtet Docker, Tailscale, die Tailnet-only-Firewall und den gehärteten Hub ein, öffnet dann dieselbe einmalige Einrichtungsseite und zeigt ihre Adresse und den Code an:" }, "owned": { "h2": "Oder eine Maschine, die Du schon hast", diff --git a/v3/site/docs/src/i18n/onboarding/en.json b/v3/site/docs/src/i18n/onboarding/en.json index 66b741d..ca337b1 100644 --- a/v3/site/docs/src/i18n/onboarding/en.json +++ b/v3/site/docs/src/i18n/onboarding/en.json @@ -33,15 +33,15 @@ "fine": "No terminal, no SSH. You paste one file when you create the server and it sets itself up. Any small provider works — Hetzner Cloud and DigitalOcean are good, inexpensive picks; the smallest size is plenty.", "step1H3": "Start creating a server", "step1Body": "Sign in to your provider and begin creating a new server (a \"Cloud Server\" or \"Droplet\"). Pick any recent Ubuntu or Debian image and the smallest size — leave the page open at the step where it offers a Cloud config or User data field. That field is where the file in step 3 goes.", - "step2H3": "Get a Tailscale key", - "step2Body": "palaia joins a tailnet — a small private network of just your own devices (via Tailscale, free for personal use), so your hub is reachable from your phone and laptop but never from the open internet. Create a free Tailscale account, then open the auth-keys page and generate one key (a reusable, ephemeral key works well). Copy it — you paste it into the file in the next step.", - "step3H3": "Paste this file, replace the key, create the server", - "step3Body": "Copy the file below into the provider's Cloud config / User data field. Change the one line it marks — TAILSCALE_AUTH_KEY=\"tskey-REPLACE_ME\" near the top — to the key from step 2. Nothing else needs editing. Then create the server.", - "afterNote": "A couple of minutes after the server boots it installs everything and starts the hub by itself. Then open your hub at its tailnet address. If it does not come up, the server's /var/log/cloud-init-output.log has the full log of what ran." + "step2H3": "Make up a setup code", + "step2Body": "palaia joins a tailnet — a small private network of just your own devices (via Tailscale, free for personal use), so your hub is reachable from your phone and laptop but never from the open internet. You need a free Tailscale account and the Tailscale app on the device you will use. No key to create: just make up a setup code of 12 letters and digits, like K7QM-R2XD-9FTP. You type it once, on palaia's setup page.", + "step3H3": "Paste this file, fill in your code, create the server", + "step3Body": "Copy the file below into the provider's Cloud config / User data field. Change the one line it marks — PALAIA_SETUP_CODE=\"setup-code-REPLACE_ME\" near the top — to your code from step 2. Nothing else needs editing. Then create the server.", + "afterNote": "A few minutes after the server boots, open http://<server-address>:8420/, enter your code, click Connect with Tailscale and log in. The page shows your hub's private address and then closes itself for good. If it does not come up, the server's /var/log/cloud-init-output.log has the full log of what ran." }, "existing": { "h2": "Already have a server? One command over SSH", - "body": "The file above only runs on a server's first boot, so pasting it into a box that is already running does nothing. For a server you already have, SSH in and run this one line — the installer sets up Docker, Tailscale, the tailnet-only firewall and the hardened hub, and asks you to paste a Tailscale key when it runs:" + "body": "The file above only runs on a server's first boot, so pasting it into a box that is already running does nothing. For a server you already have, SSH in and run this one line — the installer sets up Docker, Tailscale, the tailnet-only firewall and the hardened hub, then opens the same one-time setup page and prints its address and code:" }, "owned": { "h2": "Or a machine you already own", diff --git a/v3/site/docs/src/i18n/onboarding/es.json b/v3/site/docs/src/i18n/onboarding/es.json index 0ac1cff..98efcdb 100644 --- a/v3/site/docs/src/i18n/onboarding/es.json +++ b/v3/site/docs/src/i18n/onboarding/es.json @@ -33,15 +33,15 @@ "fine": "Sin terminal, sin SSH. Pegas un archivo al crear el servidor y él se configura solo. Sirve cualquier proveedor pequeño: Hetzner Cloud y DigitalOcean son buenas opciones y baratas; con el tamaño más pequeño te sobra.", "step1H3": "Empieza a crear un servidor", "step1Body": "Inicia sesión en tu proveedor y empieza a crear un servidor nuevo (un \"Cloud Server\" o \"Droplet\"). Elige cualquier imagen reciente de Ubuntu o Debian y el tamaño más pequeño; deja la página abierta en el paso donde te ofrece un campo Cloud config o User data. Ahí es donde va el archivo del paso 3.", - "step2H3": "Consigue una clave de Tailscale", - "step2Body": "palaia se une a una tailnet: una pequeña red privada formada solo por tus propios dispositivos (a través de Tailscale, gratis para uso personal), para que puedas llegar a tu hub desde el móvil y el portátil, pero nunca desde el internet abierto. Crea una cuenta gratuita de Tailscale, abre la página de claves de acceso y genera una clave (una clave reutilizable y efímera va muy bien). Cópiala: la pegarás en el archivo del siguiente paso.", - "step3H3": "Pega este archivo, cambia la clave y crea el servidor", - "step3Body": "Copia el archivo de abajo en el campo Cloud config / User data del proveedor. Cambia la única línea que te indica —TAILSCALE_AUTH_KEY=\"tskey-REPLACE_ME\", cerca del principio— por la clave del paso 2. No hace falta editar nada más. Después crea el servidor.", - "afterNote": "Un par de minutos después de que el servidor arranque, instala todo y pone en marcha el hub por sí solo. Después abre tu hub en su dirección de tailnet. Si no aparece, el archivo /var/log/cloud-init-output.log del servidor contiene el registro completo de lo que se ejecutó." + "step2H3": "Inventa un código de configuración", + "step2Body": "palaia se une a una tailnet: una pequeña red privada formada solo por tus propios dispositivos (mediante Tailscale, gratis para uso personal), así que tu hub es accesible desde tu móvil y tu portátil, pero nunca desde el internet abierto. Necesitas una cuenta gratuita de Tailscale y la app de Tailscale en el dispositivo que vayas a usar. No hay que crear ninguna clave: inventa un código de 12 letras y cifras, por ejemplo K7QM-R2XD-9FTP. Lo escribirás una sola vez, en la página de configuración de palaia.", + "step3H3": "Pega este archivo, pon tu código y crea el servidor", + "step3Body": "Copia el archivo de abajo en el campo Cloud config / User data de tu proveedor. Cambia la única línea marcada — PALAIA_SETUP_CODE=\"setup-code-REPLACE_ME\", cerca del principio — por tu código del paso 2. No hay que editar nada más. Después crea el servidor.", + "afterNote": "Unos minutos después de que arranque el servidor, abre http://<dirección-del-servidor>:8420/, escribe tu código, pulsa Connect with Tailscale e inicia sesión. La página muestra la dirección privada de tu hub y luego se cierra para siempre. Si no arranca, el archivo /var/log/cloud-init-output.log del servidor tiene el registro completo." }, "existing": { "h2": "¿Ya tienes un servidor? Un comando por SSH", - "body": "El archivo de arriba solo se ejecuta en el primer arranque de un servidor, así que pegarlo en un equipo que ya está en marcha no hace nada. Para un servidor que ya tienes, conéctate por SSH y ejecuta esta única línea: el instalador configura Docker, Tailscale, el cortafuegos que solo permite la tailnet y el hub reforzado, y te pide que pegues una clave de Tailscale cuando se ejecuta:" + "body": "El archivo de arriba solo se ejecuta en el primer arranque de un servidor, así que pegarlo en una máquina que ya está en marcha no hace nada. Para un servidor que ya tienes, entra por SSH y ejecuta esta línea: el instalador configura Docker, Tailscale, el cortafuegos solo para la tailnet y el hub reforzado, y luego abre la misma página de configuración de un solo uso y muestra su dirección y su código:" }, "owned": { "h2": "O una máquina que ya tienes", diff --git a/v3/site/docs/src/i18n/onboarding/fr.json b/v3/site/docs/src/i18n/onboarding/fr.json index 21bee33..9a007f6 100644 --- a/v3/site/docs/src/i18n/onboarding/fr.json +++ b/v3/site/docs/src/i18n/onboarding/fr.json @@ -33,15 +33,15 @@ "fine": "Aucun terminal, aucun SSH. Vous collez un fichier au moment de créer le serveur et il s'installe tout seul. N'importe quel petit fournisseur convient — Hetzner Cloud et DigitalOcean sont de bons choix, peu coûteux ; la plus petite taille suffit amplement.", "step1H3": "Commencez à créer un serveur", "step1Body": "Connectez-vous à votre fournisseur et commencez à créer un nouveau serveur (un « Cloud Server » ou un « Droplet »). Choisissez une image Ubuntu ou Debian récente et la plus petite taille — laissez la page ouverte à l'étape où elle propose un champ Cloud config ou User data. C'est dans ce champ que va le fichier de l'étape 3.", - "step2H3": "Obtenez une clé Tailscale", - "step2Body": "palaia rejoint un tailnet — un petit réseau privé composé uniquement de vos propres appareils (via Tailscale, gratuit pour un usage personnel), pour que votre hub soit accessible depuis votre téléphone et votre ordinateur portable, mais jamais depuis l'internet ouvert. Créez un compte Tailscale gratuit, puis ouvrez la page des clés d'authentification et générez une clé (une clé réutilisable et éphémère fonctionne bien). Copiez-la — vous la collerez dans le fichier à l'étape suivante.", - "step3H3": "Collez ce fichier, remplacez la clé, créez le serveur", - "step3Body": "Copiez le fichier ci-dessous dans le champ Cloud config / User data du fournisseur. Modifiez la seule ligne indiquée — TAILSCALE_AUTH_KEY=\"tskey-REPLACE_ME\" près du haut — avec la clé de l'étape 2. Rien d'autre n'a besoin d'être modifié. Créez ensuite le serveur.", - "afterNote": "Quelques minutes après le démarrage du serveur, il installe tout et lance le hub tout seul. Ensuite, ouvrez votre hub à son adresse tailnet. S'il ne démarre pas, le fichier /var/log/cloud-init-output.log du serveur contient le journal complet de ce qui s'est exécuté." + "step2H3": "Inventez un code de configuration", + "step2Body": "palaia rejoint un tailnet — un petit réseau privé composé uniquement de vos propres appareils (via Tailscale, gratuit pour un usage personnel) : votre hub est joignable depuis votre téléphone et votre ordinateur, jamais depuis l'internet ouvert. Il vous faut un compte Tailscale gratuit et l'application Tailscale sur l'appareil que vous utiliserez. Aucune clé à créer : inventez simplement un code de 12 lettres et chiffres, par exemple K7QM-R2XD-9FTP. Vous le saisirez une seule fois, sur la page de configuration de palaia.", + "step3H3": "Collez ce fichier, saisissez votre code, créez le serveur", + "step3Body": "Copiez le fichier ci-dessous dans le champ Cloud config / User data de votre fournisseur. Modifiez la seule ligne indiquée — PALAIA_SETUP_CODE=\"setup-code-REPLACE_ME\", vers le haut — avec votre code de l'étape 2. Rien d'autre à modifier. Puis créez le serveur.", + "afterNote": "Quelques minutes après le démarrage du serveur, ouvrez http://<adresse-du-serveur>:8420/, saisissez votre code, cliquez sur Connect with Tailscale et connectez-vous. La page affiche l'adresse privée de votre hub puis se ferme définitivement. S'il ne démarre pas, le fichier /var/log/cloud-init-output.log du serveur contient le journal complet." }, "existing": { "h2": "Vous avez déjà un serveur ? Une commande via SSH", - "body": "Le fichier ci-dessus ne s'exécute qu'au premier démarrage d'un serveur ; le coller dans une machine déjà en marche ne fait donc rien. Pour un serveur que vous avez déjà, connectez-vous en SSH et lancez cette seule ligne — le programme d'installation met en place Docker, Tailscale, le pare-feu limité au tailnet et le hub renforcé, et vous demande de coller une clé Tailscale au moment de son exécution :" + "body": "Le fichier ci-dessus ne s'exécute qu'au premier démarrage d'un serveur : le coller dans une machine déjà en marche ne fait rien. Pour un serveur existant, connectez-vous en SSH et lancez cette ligne — l'installateur met en place Docker, Tailscale, le pare-feu limité au tailnet et le hub durci, puis ouvre la même page de configuration à usage unique et affiche son adresse et son code :" }, "owned": { "h2": "Ou une machine que vous possédez déjà", diff --git a/v3/site/docs/src/i18n/onboarding/it.json b/v3/site/docs/src/i18n/onboarding/it.json index df43314..979073f 100644 --- a/v3/site/docs/src/i18n/onboarding/it.json +++ b/v3/site/docs/src/i18n/onboarding/it.json @@ -33,15 +33,15 @@ "fine": "Niente terminale, niente SSH. Incolli un file quando crei il server e questo si configura da solo. Va bene qualsiasi piccolo provider — Hetzner Cloud e DigitalOcean sono scelte valide ed economiche; la taglia più piccola è più che sufficiente.", "step1H3": "Inizia a creare un server", "step1Body": "Accedi al tuo provider e inizia a creare un nuovo server (un \"Cloud Server\" o un \"Droplet\"). Scegli una qualsiasi immagine recente di Ubuntu o Debian e la taglia più piccola — lascia la pagina aperta al punto in cui ti offre un campo Cloud config o User data. In quel campo va il file del passaggio 3.", - "step2H3": "Ottieni una chiave Tailscale", - "step2Body": "palaia entra in una tailnet — una piccola rete privata composta solo dai tuoi dispositivi (tramite Tailscale, gratuito per uso personale), così il tuo hub è raggiungibile dal tuo telefono e dal tuo portatile, ma mai da internet aperto. Crea un account Tailscale gratuito, poi apri la pagina delle chiavi di autenticazione e genera una chiave (una chiave riutilizzabile ed effimera funziona bene). Copiala — la incollerai nel file al passaggio successivo.", - "step3H3": "Incolla questo file, sostituisci la chiave, crea il server", - "step3Body": "Copia il file qui sotto nel campo Cloud config / User data del provider. Cambia l'unica riga che segnala — TAILSCALE_AUTH_KEY=\"tskey-REPLACE_ME\" in alto — con la chiave del passaggio 2. Non c'è altro da modificare. Poi crea il server.", - "afterNote": "Un paio di minuti dopo l'avvio del server, questo installa tutto e fa partire l'hub da solo. Poi apri il tuo hub al suo indirizzo tailnet. Se non si avvia, il file /var/log/cloud-init-output.log del server contiene il log completo di ciò che è stato eseguito." + "step2H3": "Inventa un codice di configurazione", + "step2Body": "palaia entra in una tailnet, una piccola rete privata fatta solo dei tuoi dispositivi (tramite Tailscale, gratuito per uso personale): il tuo hub è raggiungibile da telefono e portatile, ma mai dall'internet aperto. Ti servono un account Tailscale gratuito e l'app Tailscale sul dispositivo che userai. Nessuna chiave da creare: inventa un codice di 12 lettere e cifre, per esempio K7QM-R2XD-9FTP. Lo digiterai una sola volta, nella pagina di configurazione di palaia.", + "step3H3": "Incolla questo file, inserisci il tuo codice, crea il server", + "step3Body": "Copia il file qui sotto nel campo Cloud config / User data del tuo provider. Modifica l'unica riga indicata — PALAIA_SETUP_CODE=\"setup-code-REPLACE_ME\", verso l'inizio — con il tuo codice del passo 2. Nient'altro da modificare. Poi crea il server.", + "afterNote": "Qualche minuto dopo l'avvio del server, apri http://<indirizzo-del-server>:8420/, inserisci il tuo codice, fai clic su Connect with Tailscale e accedi. La pagina mostra l'indirizzo privato del tuo hub e poi si chiude per sempre. Se non parte, il file /var/log/cloud-init-output.log sul server contiene il registro completo." }, "existing": { "h2": "Hai già un server? Un comando via SSH", - "body": "Il file qui sopra viene eseguito solo al primo avvio di un server, quindi incollarlo in una macchina già in funzione non fa nulla. Per un server che hai già, collegati via SSH ed esegui questa sola riga — l'installer configura Docker, Tailscale, il firewall limitato alla tailnet e l'hub protetto, e ti chiede di incollare una chiave Tailscale mentre è in esecuzione:" + "body": "Il file qui sopra viene eseguito solo al primo avvio di un server: incollarlo in una macchina già in funzione non fa nulla. Per un server che hai già, accedi via SSH ed esegui questa riga: l'installer configura Docker, Tailscale, il firewall solo-tailnet e l'hub protetto, poi apre la stessa pagina di configurazione monouso e ne mostra indirizzo e codice:" }, "owned": { "h2": "Oppure una macchina che possiedi già", diff --git a/v3/specs/SPEC-605-setup-page.md b/v3/specs/SPEC-605-setup-page.md new file mode 100644 index 0000000..e313231 --- /dev/null +++ b/v3/specs/SPEC-605-setup-page.md @@ -0,0 +1,88 @@ +--- +id: SPEC-605 +title: Setup page — join the tailnet in the browser, no key, no chat +phase: 6 +depends_on: [SPEC-601] +model: opus-5 +effort: high +status: in-progress +--- + +# SPEC-605: Setup page for the private network + +## Goal +Owner decision 2026-10-08, from the live onboarding test: getting the +Tailscale auth key onto the server must be friendly for a non-technical +person and must never pass through the AI chat. Consoles and terminals are +off-putting. Instead, like Home Assistant's first-run page: palaia briefly +shows a setup page in the browser, protected by a one-time code; the person +clicks "Connect with Tailscale", logs in to their Tailscale account, and the +server joins their private network. No key to create, copy or paste. + +## Hard constraints +- **Host-side, not in the hub.** Tailscale runs on the host + (`get-palaia.sh`, `cloud-init.yaml`); the hub container stays + `--cap-drop ALL --read-only` with no access to `tailscaled`. The setup + page is a small helper the installer / cloud-init starts on the host and + stops again. No image change, so the path is testable from `main` + (get.palaia.ai serves `main`) without a release. +- **Short, guarded exposure.** The public port is open only while the + helper runs, with a hard timeout (30 minutes). Every request needs the + one-time code; a wrong code gets nothing (no login link, no hint). After + the server has joined the tailnet, or on timeout, the helper exits and the + public port is closed; the installer then continues exactly as today + (hub bound to the tailnet address, ufw tailnet-only). +- **The key path stays.** `PALAIA_TSKEY` (installer) and the + `TAILSCALE_AUTH_KEY` line (cloud-init) keep working for technical users. + The setup page is the default only when no key is given. +- Keep the machine name `palaia-hub` so "find palaia-hub in your Tailscale + machine list" (install skill, Step 5) still holds. + +## Deliverables +1. `v3/deploy/setup-page.py` (or inlined into both scripts from one source + of truth, same drift-test pattern as the docker-run flags): runs + `tailscale up --hostname=palaia-hub` without an auth key in the + background, captures the login URL it prints, serves one plain page on + `0.0.0.0:8420`: code field → on the right code, a "Connect with + Tailscale" button to that login URL, then "Waiting for you to log in…" + until `tailscale ip -4` answers, then "Done — open http://:8420/ + from a device on your Tailscale network". Python 3 standard library only. +2. One-time code, per path: + - installer run by an AI with a shell: the AI passes + `PALAIA_SETUP_CODE`; otherwise the installer generates one and prints + it prominently; + - console paste: printed by the installer; + - cloud-init: the file carries a `PALAIA_SETUP_CODE="..."` line the AI + (or the onboarding page) fills with a fresh random code — this replaces + the `tskey-REPLACE_ME` edit as the default. + Code format readable aloud and typeable on a phone (e.g. 3×4 characters + without look-alikes). +3. `get-palaia.sh`: no key and no TTY → setup page instead of `die`. + `cloud-init.yaml`: no key filled in → setup page instead of stopping. + Both stay safe to re-run. +4. Install skill (`palaia-install`): Steps 2a / 3 / 4 / 5 rewritten around + the setup page; Step 4 (create a key) becomes the power path only. + Drift tests for the old key edit are replaced, not allow-listed. +5. Onboarding page + `v3/deploy/README.md`: same flow, same wording. + +## Open points +- Runtime: settled — cloud-init itself is written in Python, so every image + that runs it has `python3`; the installer installs it with apt if missing. +- Plain HTTP: the code and the login link travel unencrypted during the + setup window. Accepted for the 30-minute, code-gated window (a browser + warning for a self-signed certificate would scare the very people this is + for); revisit if the page ever stays up longer. +- Remaining terminal touch: an *existing* server that the AI cannot reach + still needs one console paste (a fixed line, no secret, no editing). The + skill should offer, for an empty existing server, recreating it with the + setup file instead (no console at all). + +## Acceptance criteria +- [ ] Fresh Ubuntu VPS, cloud-init without a key: browser page at + `http://:8420/`, code, Tailscale login, hub reachable on + the tailnet; `:8420` no longer answers on the public address. +- [ ] Same via `curl -fsSL https://get.palaia.ai | sh` without a key. +- [ ] Wrong code: no login link is ever served; timeout closes the port. +- [ ] Re-running the installer on a joined server skips the setup page. +- [ ] Live onboarding test (naive user + install skill) reaches a green hub + on the owner's test VPS without the key passing through the chat.