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 <noreply@anthropic.com>
This commit is contained in:
Tommy Rantti 2026-09-26 21:25:19 +03:00
parent 9999e1da41
commit ef28afb9b8
4 changed files with 263 additions and 24 deletions

View file

@ -1,10 +1,21 @@
# travel laptop bootstrap # travel laptop bootstrap
A disposable, extremely light Debian laptop for working on the road. If it's 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 run it, give it a couple of credentials, and you're back to full working
order in minutes. 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 idea
- The travel laptop stays minimal: vim, tmux, mosh, tailscale, VS Code - 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 ```bash
sudo apt update && sudo apt install -y curl sudo apt update && sudo apt install -y curl
curl -fsSL https://git.brainpaingames.com/tommy/infra/raw/branch/main/bootstrap.sh -o bootstrap.sh curl -fsSL https://git.brainpaingames.com/tommy/infra/raw/branch/main/setup-local.sh -o setup-local.sh
chmod +x bootstrap.sh curl -fsSL https://git.brainpaingames.com/tommy/infra/raw/branch/main/lib.sh -o lib.sh
./bootstrap.sh chmod +x setup-local.sh
./setup-local.sh
``` ```
It prompts for: It prompts for:
@ -76,8 +88,8 @@ from within an already-open Remote-SSH window - not set up yet.
## On the home machine side ## On the home machine side
One prerequisite that lives outside this script, on whichever machine you One prerequisite for Remote-SSH that lives outside any script, run once on
Remote-SSH into: whichever machine you Remote-SSH into:
```bash ```bash
sudo tailscale set --ssh sudo tailscale set --ssh
@ -85,10 +97,49 @@ sudo tailscale set --ssh
This lets Tailscale itself authorize SSH logins via your tailnet identity, This lets Tailscale itself authorize SSH logins via your tailnet identity,
so the travel laptop's key never needs to be manually added to 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 Forgejo is for a *different* purpose - git operations against Forgejo, a
separate trust relationship from logging into the home machine.) 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) ## Gotchas hit while building this (so they don't get re-debugged)
- **`usermod`/`visudo`: command not found** after `su` - not missing, just - **`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 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 there even though `id <user>` shows it, the session is stale - reboot or
fully re-login. 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 - **Forgejo API call fails with a vague error** - the script reports the
real HTTP status now (401 = bad/expired token, 403 = missing real HTTP status now (401 = bad/expired token, 403 = missing
`write:user` scope, 404 = check the instance URL). `write:user` scope, 404 = check the instance URL).

28
lib.sh Normal file
View file

@ -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"
}

View file

@ -1,27 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -uo pipefail set -uo pipefail
STEP_NAMES=() SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STEP_STATUS=() # shellcheck source=lib.sh
STEP_DETAIL=() source "$SCRIPT_DIR/lib.sh"
KNOWN_HOSTS=() 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() { print_summary() {
echo
echo "===================== summary ====================="
local any_failed=0 local any_failed=0
for i in "${!STEP_NAMES[@]}"; do print_summary_table || any_failed=1
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 "====================================================="
if [ "$any_failed" = 1 ]; then if [ "$any_failed" = 1 ]; then
echo "One or more steps failed. Fix the issue above and re-run this script -" 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." echo "already-completed steps are safe to skip and will not be repeated."

166
setup-remote.sh Normal file
View file

@ -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