infra/README.md
Tommy Rantti 9999e1da41 Add README summarizing the design and known gotchas
Compacts the design discussion into a reference: the workflow model
(pure Remote-SSH by default, opportunistic local clones for
self-contained python+uv projects only), how to run the script, the
home-machine prerequisite (Tailscale SSH), and the gotchas hit while
building it (PATH issues after su, stale sudo group membership, the
token-paste-into-shell bug, the spurious-space host alias bug, and
the tailnet-wide MagicDNS requirement) so they don't need re-debugging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 20:43:32 +03:00

6.4 KiB

travel laptop bootstrap

A disposable, extremely light Debian laptop for working on the road. If it's lost, stolen, or dropped in the sea: install Debian, fetch bootstrap.sh, run it, give it a couple of credentials, and you're back to full working order in minutes.

The idea

  • The travel laptop stays minimal: vim, tmux, mosh, tailscale, VS Code (+ Remote-SSH extension), uv, and a locked-down browser. No language runtimes, no Docker, no databases.
  • Default workflow is pure Remote-SSH, nothing local. VS Code connects over Tailscale to your home machine (or another tailnet box) and edits that machine's checkout directly. Actually running/building/testing code always happens there too - never on the travel laptop - so a project that generates tens of GB of logs/DB state on a "heavy" run never becomes the travel laptop's problem, regardless of whether that particular project is big or small.
  • Local clones are opportunistic, not default. Only migrate a repo to the laptop itself if it's genuinely self-contained (Python + uv, no services/DB/heavy runtime state) and you specifically want it editable offline. Everything else (e.g. anything needing a distrobox container, a full toolchain, Docker) stays remote-only, always.
  • Offline mode = edit + commit only. Building/running resumes once you're back online and reconnect via Remote-SSH.
  • Idempotent, not transactional: every step checks its own state before acting, so the script is safe to run any number of times. If a step fails, fix the underlying issue and re-run - already-done steps skip, nothing needs to be undone.

Running it

On a fresh Debian install, once you're on the network:

sudo apt update && sudo apt install -y curl
curl -fsSL https://git.brainpaingames.com/tommy/infra/raw/branch/main/bootstrap.sh -o bootstrap.sh
chmod +x bootstrap.sh
./bootstrap.sh

It prompts for:

  • Forgejo instance URL and a device/key label (remembered across runs in ~/.config/travel-bootstrap/config and offered as the default next time - just hit Enter to reuse them).
  • Tailscale login (interactive, opens a URL to approve the device).
  • A Forgejo access token, used once to register a fresh SSH keypair via the API, never stored. Get one at: your Forgejo instance -> avatar (top right) -> Settings -> Applications -> Manage Access Tokens -> grant write:user scope -> Generate Token -> copy it immediately, it's shown once. When prompted, paste it and press Ctrl-D (not Enter) to submit.

What it does, in order: installs packages, joins Tailscale, regenerates ~/.ssh/config from the live tailnet peer list (a wildcard entry covering the whole tailnet's DNS suffix, plus one named Host alias per peer - regenerated fresh every run, so new peers just show up next time), registers the new SSH key to Forgejo, and hardens Firefox (forced incognito, no history, forced uBlock Origin). Ends with a summary and, if everything succeeded, a concrete "here's what to do next" block listing the tailnet hosts it found.

Connecting to a project

Open VS Code -> Ctrl+Shift+P -> Remote-SSH: Connect to Host... -> pick the home machine's alias from the list -> File > Open Folder, same as opening a folder locally, just on the remote filesystem.

For a project living inside a distrobox container on the remote: Remote-SSH gets you the host's shell, not the container - run distrobox enter <name> in the terminal same as you would locally. Nothing about arriving over SSH changes that. Tighter integration (VS Code extensions actually running inside the container) is possible later via the Dev Containers extension's "Attach to Running Container," launched from within an already-open Remote-SSH window - not set up yet.

On the home machine side

One prerequisite that lives outside this script, on whichever machine you Remote-SSH into:

sudo tailscale set --ssh

This lets Tailscale itself authorize SSH logins via your tailnet identity, so the travel laptop's key never needs to be manually added to ~/.ssh/authorized_keys there. (The SSH key this script registers to Forgejo is for a different purpose - git operations against Forgejo, a separate trust relationship from logging into the home machine.)

Gotchas hit while building this (so they don't get re-debugged)

  • usermod/visudo: command not found after su - not missing, just not on PATH. Use su - (with the dash) for a proper root login shell, or call by full path (/usr/sbin/usermod, /usr/sbin/visudo).
  • Group membership looks correct but sudo still refuses - group changes only apply to new login sessions. Check with plain id (no args) in the actual session you're testing in; if sudo isn't listed there even though id <user> shows it, the session is stale - reboot or fully re-login.
  • Forgejo API call fails with a vague error - the script reports the real HTTP status now (401 = bad/expired token, 403 = missing write:user scope, 404 = check the instance URL).
  • A pasted token ends up executed as a shell command - some "copy token" buttons include a trailing newline. A plain read stops at that newline and leaves the rest of the paste sitting in the terminal's input buffer, which the shell then runs once the script exits. Fixed by reading raw stdin until EOF (paste, then Ctrl-D) instead of stopping at the first newline.
  • A generated Host alias had a space in it - Tailscale's raw HostName field is whatever the OS reports (an Android phone's name can be e.g. "Pixel 9"), which breaks unquoted in an SSH config Host line. Fixed by deriving the alias from the already-sanitized DNSName instead.
  • .ts.net names don't resolve, even a device resolving its own name
    • MagicDNS has to be switched on tailnet-wide (Tailscale admin console -> DNS tab), separately from any per-device setting. Each device always gets a .ts.net name assigned regardless of whether this is on - it just won't actually resolve until it is.

Deliberately deferred / manual

  • Disk encryption (LUKS) - a Debian-installer-time choice, not something this script can retrofit. Worth doing given this machine holds an SSH key, even if the disk otherwise stays close to empty.
  • Browser choice: Firefox, for its declarative policies.json.
  • Window manager: left as whatever Debian's installer gives you; a minimal WM (sway/i3) would cut RAM/disk further if that becomes worth it.