From ef28afb9b82e3551ad8248b3923f18adbe1a31a2 Mon Sep 17 00:00:00 2001 From: Tommy Rantti Date: Sat, 26 Sep 2026 21:25:19 +0300 Subject: [PATCH] Split into setup-local.sh + setup-remote.sh, add RDP configuration Renames bootstrap.sh to setup-local.sh to match its actual scope (the travel laptop only) and adds setup-remote.sh for the machine you Remote-SSH/RDP into, since that's a different machine's one-time setup and was already living outside setup-local.sh's reach (same reasoning as the existing Tailscale-SSH prerequisite). setup-remote.sh configures GNOME's system-level RDP (works from a cold GDM login screen, not just an existing session) restricted to the tailscale interface via a ufw rule, a self-signed TLS cert, and RDP credentials that are deliberately separate from the account password and never cached - only prompted if unset. Shares step-tracking helpers with setup-local.sh via a new lib.sh rather than duplicating them. Tested the branching logic (credentials-already-set, RDP-already- enabled, ufw-already-active-with-rule, ufw-inactive-confirm/decline) against realistic stubbed command output. Caught and fixed a real bug in the process: the inactive/active ufw check used a bare `grep -qi active`, which also matches the substring inside "inactive" - it was silently skipping the enable-confirmation gate and going straight to adding a firewall rule on a firewall that was never turned on. Fixed by anchoring the match. Co-Authored-By: Claude Sonnet 5 --- README.md | 71 ++++++++++++-- lib.sh | 28 ++++++ bootstrap.sh => setup-local.sh | 22 +---- setup-remote.sh | 166 +++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 lib.sh rename bootstrap.sh => setup-local.sh (94%) create mode 100644 setup-remote.sh diff --git a/README.md b/README.md index d82a817..d76d8d1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,21 @@ # 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`, +lost, stolen, or dropped in the sea: install Debian, fetch `setup-local.sh`, run it, give it a couple of credentials, and you're back to full working order in minutes. +Two scripts, one per machine role: +- **`setup-local.sh`** - run on the disposable travel laptop. Everything in + this README that isn't explicitly about the home machine refers to this. +- **`setup-remote.sh`** - run once on the machine you Remote-SSH/RDP *into* + (e.g. your home desktop). Configures RDP access for occasional full-desktop + use (browser sessions already logged into Gmail etc.) - see "Remote + desktop access" below. + +Both share `lib.sh` (step-tracking/summary helpers) - all three files need +to stay together when you fetch this repo. + ## The idea - The travel laptop stays minimal: vim, tmux, mosh, tailscale, VS Code @@ -35,9 +46,10 @@ On a fresh Debian install, once you're on the network: ```bash 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 +curl -fsSL https://git.brainpaingames.com/tommy/infra/raw/branch/main/setup-local.sh -o setup-local.sh +curl -fsSL https://git.brainpaingames.com/tommy/infra/raw/branch/main/lib.sh -o lib.sh +chmod +x setup-local.sh +./setup-local.sh ``` It prompts for: @@ -76,8 +88,8 @@ 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: +One prerequisite for Remote-SSH that lives outside any script, run once on +whichever machine you Remote-SSH into: ```bash sudo tailscale set --ssh @@ -85,10 +97,49 @@ 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 +`~/.ssh/authorized_keys` there. (The SSH key `setup-local.sh` registers to Forgejo is for a *different* purpose - git operations against Forgejo, a separate trust relationship from logging into the home machine.) +## Remote desktop access (occasional, full-desktop use) + +Remote-SSH covers development, but not things like an already-logged-in +Gmail session - for that, `setup-remote.sh` configures GNOME's built-in RDP +(`gnome-remote-desktop`) on the home machine. Run it there once: + +```bash +./setup-remote.sh +``` + +It restricts the RDP port to the tailscale interface only (via a `ufw` +rule - if `ufw` isn't active yet, it asks before turning it on, since that +changes this machine's default network posture more broadly), sets up a +self-signed TLS cert, and prompts for RDP credentials **only if none are +set yet** - these are deliberately never cached anywhere. + +**Two authentication layers, not one**: the RDP username/password you set +here just gates the RDP connection itself and gets you to the login +screen - it is *not* your account password and doesn't grant a session by +itself. You still log in with your real account password once connected, +same as sitting at the machine. Keep both: relying on tailnet-reachability +alone as the only gate would collapse this to a single point of failure, +the same reasoning that already justified keeping SSH keys behind Tailscale +SSH rather than trusting tailnet membership alone. + +From the travel laptop: GNOME Connections (ships by default with a GNOME +desktop, nothing extra to install) or `xfreerdp`, pointed at the home +machine's tailscale address, port 3389. + +**Connecting to a fresh boot with nobody logged in locally** (e.g. a power +outage and the machine restarts unattended) works *if* the disk isn't +encrypted, since Tailscale and the RDP daemon are both system services that +start before any login - `setup-remote.sh` targets exactly this +"system/GDM-level" RDP mode rather than the simpler per-session one, which +only shares a session that already exists. If this machine is ever +LUKS-encrypted later, this recovery path breaks (nothing starts until +someone types the disk passphrase locally) unless something like +`dropbear-initramfs` is added for remote unlock. + ## Gotchas hit while building this (so they don't get re-debugged) - **`usermod`/`visudo`: command not found** after `su` - not missing, just @@ -99,6 +150,12 @@ separate trust relationship from logging into the home machine.) args) in the actual session you're testing in; if `sudo` isn't listed there even though `id ` shows it, the session is stale - reboot or fully re-login. +- **`grep -qi active` also matches "in`active`"** - a substring check for + whether ufw is active matched the *inactive* case too, silently skipping + the enable-confirmation step and going straight to adding a firewall rule + on a firewall that was never actually turned on. Caught by testing the + branch directly rather than assuming; fixed by anchoring the match + (`^Status: active`). - **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). diff --git a/lib.sh b/lib.sh new file mode 100644 index 0000000..8a298de --- /dev/null +++ b/lib.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Shared step-tracking helpers for setup-local.sh and setup-remote.sh + +STEP_NAMES=() +STEP_STATUS=() +STEP_DETAIL=() + +step_ok() { STEP_NAMES+=("$1"); STEP_STATUS+=("OK"); STEP_DETAIL+=("${2:-}"); echo "[OK] $1"; } +step_skip() { STEP_NAMES+=("$1"); STEP_STATUS+=("SKIPPED"); STEP_DETAIL+=("${2:-}"); echo "[SKIPPED] $1 - ${2:-}"; } +step_fail() { STEP_NAMES+=("$1"); STEP_STATUS+=("FAILED"); STEP_DETAIL+=("${2:-}"); echo "[FAILED] $1 - ${2:-}"; } + +section() { echo; echo "== $1 =="; } + +# Prints the OK/SKIPPED/FAILED table. Returns 1 if any step failed, 0 otherwise. +# Does not exit and does not print anything past the table - callers add their +# own pass/fail wording and next-steps text. +print_summary_table() { + echo + echo "===================== summary =====================" + local any_failed=0 + for i in "${!STEP_NAMES[@]}"; do + printf '[%-8s] %s\n' "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" + [ -n "${STEP_DETAIL[$i]}" ] && printf ' %s\n' "${STEP_DETAIL[$i]}" + [ "${STEP_STATUS[$i]}" = "FAILED" ] && any_failed=1 + done + echo "=====================================================" + return "$any_failed" +} diff --git a/bootstrap.sh b/setup-local.sh similarity index 94% rename from bootstrap.sh rename to setup-local.sh index 4ff44c4..4214cf6 100755 --- a/bootstrap.sh +++ b/setup-local.sh @@ -1,27 +1,15 @@ #!/usr/bin/env bash set -uo pipefail -STEP_NAMES=() -STEP_STATUS=() -STEP_DETAIL=() +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + KNOWN_HOSTS=() -step_ok() { STEP_NAMES+=("$1"); STEP_STATUS+=("OK"); STEP_DETAIL+=("${2:-}"); echo "[OK] $1"; } -step_skip() { STEP_NAMES+=("$1"); STEP_STATUS+=("SKIPPED"); STEP_DETAIL+=("${2:-}"); echo "[SKIPPED] $1 - ${2:-}"; } -step_fail() { STEP_NAMES+=("$1"); STEP_STATUS+=("FAILED"); STEP_DETAIL+=("${2:-}"); echo "[FAILED] $1 - ${2:-}"; } - -section() { echo; echo "== $1 =="; } - print_summary() { - echo - echo "===================== summary =====================" local any_failed=0 - for i in "${!STEP_NAMES[@]}"; do - printf '[%-8s] %s\n' "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" - [ -n "${STEP_DETAIL[$i]}" ] && printf ' %s\n' "${STEP_DETAIL[$i]}" - [ "${STEP_STATUS[$i]}" = "FAILED" ] && any_failed=1 - done - echo "=====================================================" + print_summary_table || any_failed=1 if [ "$any_failed" = 1 ]; then echo "One or more steps failed. Fix the issue above and re-run this script -" echo "already-completed steps are safe to skip and will not be repeated." diff --git a/setup-remote.sh b/setup-remote.sh new file mode 100644 index 0000000..0f81e08 --- /dev/null +++ b/setup-remote.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +RDP_PORT=3389 +TLS_DIR="/etc/gnome-remote-desktop/tls" + +print_summary() { + local any_failed=0 + print_summary_table || any_failed=1 + if [ "$any_failed" = 1 ]; then + echo "One or more steps failed. Fix the issue above and re-run this script -" + echo "already-completed steps are safe to skip and will not be repeated." + exit 1 + fi + echo "All steps OK or already satisfied." + echo + echo "================== next steps ======================" + echo "Connect from the travel laptop with GNOME Connections (or xfreerdp)" + echo "to this machine's tailscale address, port $RDP_PORT. You'll be asked" + echo "for the RDP username/password you just set - that gets you to the" + echo "actual login screen, where you still log in with your real account" + echo "password same as sitting at the machine." + echo "=====================================================" +} + +preflight_env() { + section "preflight: environment" + if [ "$(id -u)" = "0" ]; then + step_fail "environment check" "do not run as root - script uses sudo for the specific commands that need it" + print_summary + exit 1 + fi + if ! command -v grdctl >/dev/null 2>&1; then + step_fail "environment check" "grdctl not found - this script targets a machine with gnome-remote-desktop installed" + print_summary + exit 1 + fi + step_ok "environment check" +} + +harden_firewall() { + section "firewall (restrict RDP to tailscale)" + + if ! command -v ufw >/dev/null 2>&1; then + step_fail "firewall" "ufw not installed" + return + fi + + if ! sudo ufw status | head -1 | grep -qi "^Status: active"; then + echo "ufw is currently inactive on this machine. Enabling it switches to a" + echo "deny-incoming-by-default posture, which could affect any other LAN" + echo "service here that doesn't already have an explicit allow rule." + echo "Rules that will be in place once enabled:" + sudo ufw show added + read -rp "Type 'yes' to enable ufw now: " confirm + if [ "$confirm" != "yes" ]; then + step_fail "firewall" "not confirmed - ufw left inactive, RDP rule not added" + return + fi + sudo ufw --force enable + fi + + if sudo ufw status | grep -qF "${RDP_PORT}/tcp on tailscale0"; then + step_skip "firewall" "RDP already restricted to tailscale0" + return + fi + + if sudo ufw allow in on tailscale0 to any port "$RDP_PORT" proto tcp; then + step_ok "firewall" "RDP (${RDP_PORT}/tcp) allowed on tailscale0 only" + else + step_fail "firewall" "ufw rule failed to apply" + fi +} + +configure_tls() { + section "tls certificate" + sudo mkdir -p "$TLS_DIR" + + if sudo test -f "$TLS_DIR/cert.pem" && sudo test -f "$TLS_DIR/key.pem"; then + step_skip "tls certificate" "already present at $TLS_DIR - not regenerating (would invalidate the fingerprint clients already trust)" + else + if sudo openssl req -x509 -newkey rsa:4096 -nodes \ + -keyout "$TLS_DIR/key.pem" -out "$TLS_DIR/cert.pem" \ + -days 3650 -subj "/CN=$(hostname)" >/tmp/grd-tls.log 2>&1; then + sudo chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/key.pem" "$TLS_DIR/cert.pem" + sudo chmod 600 "$TLS_DIR/key.pem" + sudo chmod 644 "$TLS_DIR/cert.pem" + step_ok "tls certificate" "self-signed cert generated at $TLS_DIR" + else + step_fail "tls certificate" "openssl req failed - see /tmp/grd-tls.log" + return + fi + fi + + sudo grdctl --system rdp set-tls-cert "$TLS_DIR/cert.pem" + sudo grdctl --system rdp set-tls-key "$TLS_DIR/key.pem" +} + +configure_credentials() { + section "rdp credentials" + local rdp_status + rdp_status=$(sudo grdctl --system rdp status --show-credentials 2>&1) + + if ! echo "$rdp_status" | grep -q "Username: (null)"; then + step_skip "rdp credentials" "already configured - not touching an existing password" + return + fi + + local rdp_user + read -rp "RDP username [$(whoami)]: " rdp_user + rdp_user="${rdp_user:-$(whoami)}" + + echo "RDP password - a separate secret from your account login password," + echo "gates only the initial RDP connection (you still log into the actual" + echo "session with your real account password afterwards). Not stored by" + echo "this script anywhere. Paste/type it, then press Enter and Ctrl-D:" + local rdp_pass + rdp_pass=$(cat) + rdp_pass="${rdp_pass//[[:space:]]/}" + + if [ -z "$rdp_pass" ]; then + step_fail "rdp credentials" "empty password entered - not setting" + return + fi + + if sudo grdctl --system rdp set-credentials "$rdp_user" "$rdp_pass"; then + step_ok "rdp credentials" "set for user $rdp_user" + else + step_fail "rdp credentials" "grdctl set-credentials failed" + fi + unset rdp_pass +} + +enable_rdp() { + section "enable rdp backend" + sudo grdctl --system rdp set-port "$RDP_PORT" + sudo grdctl --system rdp disable-port-negotiation + + if sudo grdctl --system rdp status 2>&1 | grep -qi "Status: *enabled"; then + step_skip "enable rdp backend" "already enabled" + return + fi + + if sudo grdctl --system rdp enable; then + step_ok "enable rdp backend" + else + step_fail "enable rdp backend" "grdctl --system rdp enable failed" + fi +} + +main() { + preflight_env + harden_firewall + configure_tls + configure_credentials + enable_rdp + print_summary +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi