From a03cfeb17776d6326faced6b2d14883d03e66839 Mon Sep 17 00:00:00 2001 From: wayne Date: Sat, 27 Jun 2026 19:20:20 -0400 Subject: [PATCH] =?UTF-8?q?v3:=20roaming=20home=20=E2=80=94=20sshfs=20moun?= =?UTF-8?q?ts=20+=20per-host=20remote=20exec=20over=20tailnet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 10 +- ROAMING.md | 132 +++++++++++++++++++++++ docker-compose.yml | 25 +++-- dotfiles/.config/roaming/hosts | 17 +++ dotfiles/.config/roaming/roaming.zsh | 153 +++++++++++++++++++++++++++ dotfiles/.zshrc | 4 + roaming-zshrc.snippet | 4 + scripts/add_new_machine.sh | 76 +++++++++++++ 8 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 ROAMING.md create mode 100644 dotfiles/.config/roaming/hosts create mode 100644 dotfiles/.config/roaming/roaming.zsh create mode 100644 roaming-zshrc.snippet create mode 100755 scripts/add_new_machine.sh diff --git a/Dockerfile b/Dockerfile index ddc5a32..2f43a65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN pacman -S --noconfirm --needed \ go \ github-cli \ libnewt \ + openssh sshfs \ && pacman -Scc --noconfirm # ── Crown Jewel #2: AUR ─────────────────────────────────────────────────────── @@ -54,11 +55,14 @@ RUN sudo -u aurbuild yay -S --noconfirm --needed \ tailscale \ && sudo -u aurbuild yay -Scc --noconfirm +# ── FUSE config: allow_other for sshfs mounts ───────────────────────────────── +RUN sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf 2>/dev/null || \ + echo 'user_allow_other' >> /etc/fuse.conf + # ── Dev user with host-matching UID/GID ─────────────────────────────────────── # UID/GID match host so /workspace bind mount has clean permissions both sides. # aurbuild is parked at UID 9001 so there's no collision with host UID. RUN set -e; \ - # Group: handle pre-existing GID gracefully (Arch base has users:1000) if getent group ${USER_GID} >/dev/null; then \ groupmod -n dev "$(getent group ${USER_GID} | cut -d: -f1)"; \ else \ @@ -70,6 +74,10 @@ RUN set -e; \ # ── Skeleton: bake dotfiles into /etc/skel-arch-dev/ ────────────────────────── COPY --chown=dev:dev dotfiles/ /etc/skel-arch-dev/ +# ── Roaming toolkit: add_new_machine.sh onto PATH ───────────────────────────── +COPY scripts/add_new_machine.sh /usr/local/bin/add_new_machine.sh +RUN chmod +x /usr/local/bin/add_new_machine.sh + # ── Initial seed of /home/dev so plugin bake works at build time ────────────── RUN cp -an /etc/skel-arch-dev/. /home/dev/ && \ chown -R dev:dev /home/dev diff --git a/ROAMING.md b/ROAMING.md new file mode 100644 index 0000000..dc25201 --- /dev/null +++ b/ROAMING.md @@ -0,0 +1,132 @@ +# Roaming ~ — arch-dev v3 + +Turn neovim-ide into your **roaming home directory**. SSH in from any device, +and every server on your tailnet feels like home: one keyring, one shell +history, one set of dotfiles, reachable from anywhere. + +This is what makes neovim-ide more than another riced nvim install — it's a +control plane for your whole fleet. + +--- + +## The Model + +``` + You (phone / tablet / chromebook) + ↓ ssh + nvimide.tailnet.ts ← your roaming home (this container) + ↓ ssh / sshfs over tailnet + ┌──────────┬──────────┬──────────┐ + mail proxy dev (any host) +``` + +You live in nvimide. Files live on remote hosts (mounted via sshfs when you +want them). Commands run on remote hosts via short per-host functions. + +Keys never leave nvimide — agent auth handles remote login, remote `sudo` +still prompts (the security boundary stays intact). + +--- + +## Files + +| File | Purpose | +|---|---| +| `~/.config/roaming/hosts` | Registry — one line per host (source of truth) | +| `~/.config/roaming/roaming.zsh` | Engine — generates host functions, mount helpers | +| `add_new_machine.sh` | Onboard a new host (key + register + verify) | + +--- + +## Usage + +### Run commands on a host + +Each registry entry becomes a command. Dumb by design — pipes and redirects +run **locally**, exactly like plain ssh (predictable, no surprises). + +```bash +mail # interactive shell on mail +mail tail -f /var/log/mail.log # TTY allocated — -f works +mail sudo systemctl restart postfix # remote sudo prompts for password +proxy sudo systemctl reload caddy +dev docker compose logs -f +``` + +### Mount a host's filesystem + +```bash +mount_host mail # sshfs the registered path → /workspace/mail +ls /workspace/mail/etc/postfix/ +unmount_host mail # clean unmount (handles stale mounts) +``` + +Mounts use `reconnect,ServerAliveInterval=15,ServerAliveCountMax=3` so a +network blip errors out in ~45s instead of freezing your shell forever. + +### Status & reload + +```bash +roaming-status # list hosts + which are mounted +roaming-reload # re-read registry after editing hosts by hand +``` + +### Add a new machine + +```bash +add_new_machine.sh mail mail.tailnet.ts root / +# 1. ssh-copy-id (one password prompt) +# 2. appends to ~/.config/roaming/hosts +# 3. verifies key-based login +``` + +After that, `mail` works in the next shell (or after `roaming-reload`). + +--- + +## Registry Format + +``` +# name fqdn user mount +mail mail.tailnet.ts root / +proxy proxy.tailnet.ts admin /etc/caddy +dev dev-server.tailnet.ts wayne +``` + +- `user` optional — defaults to `$ROAMING_DEFAULT_USER` or current user +- `mount` optional — omit if you only run commands, never mount + +--- + +## Onboarding a Fresh Client + +The whole point: a new device needs almost nothing. + +1. Install your SSH key to reach nvimide.tailnet.ts +2. SSH in — you're home. Dotfiles, history (atuin), keys, the works. + +No per-device key sprawl. nvimide holds the fleet keyring; clients only need +to reach nvimide. + +--- + +## Gotchas (learned, baked in) + +- **Pipes are local.** `mail tail -f log | grep err` greps locally. Want it + remote? Quote the whole pipeline: `mail 'tail -f log | grep err'`. +- **Stale mount frozen?** `fusermount3 -uz /workspace/` force-clears it. +- **`tail -f` / `htop` / sudo need a TTY** — handled (`ssh -t` always). +- **Agent forwarding (`-A`) is intentionally OFF** — a compromised remote + could abuse a forwarded agent. Add per-host later only if you need to hop. + +--- + +## Roadmap (future hardening) + +- **SSH CA + OIDC (Authelia)** — replace `authorized_keys` with short-lived + certs issued after Authelia auth. Collapses key management fleet-wide. + Deferred: keys stay as the stable fallback until the cert flow is proven. +- **autofs** — system-level lazy mount/unmount (60s idle auto-unmount) as an + alternative to manual `mount_host`. +- **atuin** — single shell-history brain in nvimide, DB in the postgres + cluster; retire atuin-server. diff --git a/docker-compose.yml b/docker-compose.yml index a76c31d..1fc3459 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,6 @@ services: build: context: . args: - # Match host UID/GID for clean /workspace permissions - # Set via: UID=$(id -u) GID=$(id -g) docker compose build - # Or .env file in repo root USER_UID: ${UID:-1000} USER_GID: ${GID:-1000} image: arch-dev:latest @@ -15,11 +12,7 @@ services: tty: true volumes: - # Project files — bind mount, host-visible, host-UID-owned - ./workspace:/workspace - - # Stateful home — named volume, survives --rm - # Reset with: docker volume rm _arch-dev-home - arch-dev-home:/home/dev environment: @@ -30,8 +23,7 @@ services: - GIT_COMMITTER_NAME=${GIT_NAME:-dev} - GIT_COMMITTER_EMAIL=${GIT_EMAIL:-dev@localhost} - # Capability set built up through testing — - # cap_drop ALL then re-add only what's needed. + # Capability set — cap_drop ALL then re-add only what's needed cap_drop: - ALL cap_add: @@ -41,13 +33,20 @@ services: - AUDIT_WRITE # sudoers_audit plugin - NET_ADMIN # tailscale - NET_RAW # tailscale - - CHOWN # pacman temp dir ownership - - DAC_OVERRIDE # pacman lock files - - FOWNER # pacman package ownership + - CHOWN # pacman + - DAC_OVERRIDE # pacman + - FOWNER # pacman + - SYS_ADMIN # sshfs/FUSE mounts (roaming ~) - # Tailscale needs tun device for kernel-mode networking + # Devices: tun for tailscale, fuse for sshfs devices: - /dev/net/tun:/dev/net/tun + - /dev/fuse:/dev/fuse + + # FUSE mounts need apparmor unconfined on many hosts + # (remove if your host doesn't use apparmor) + security_opt: + - apparmor:unconfined volumes: arch-dev-home: diff --git a/dotfiles/.config/roaming/hosts b/dotfiles/.config/roaming/hosts new file mode 100644 index 0000000..42f9af3 --- /dev/null +++ b/dotfiles/.config/roaming/hosts @@ -0,0 +1,17 @@ +# ── arch-dev roaming :: host registry ───────────────────────────────────────── +# One host per line. Lines starting with # are ignored. +# +# Fields (whitespace-separated): +# name short alias used for the shell function (e.g. `mail`) +# fqdn tailnet hostname or any reachable host (e.g. mail.tailnet.ts) +# user ssh user (optional, defaults to $ROAMING_DEFAULT_USER or current user) +# mount remote path to mount via sshfs (optional, e.g. / or /srv) +# +# Examples (delete these, add your own): +# +# name fqdn user mount +# mail mail.tailnet.ts root / +# proxy proxy.tailnet.ts admin /etc/caddy +# dev dev-server.tailnet.ts wayne /srv + +# name fqdn user mount diff --git a/dotfiles/.config/roaming/roaming.zsh b/dotfiles/.config/roaming/roaming.zsh new file mode 100644 index 0000000..3d42cfb --- /dev/null +++ b/dotfiles/.config/roaming/roaming.zsh @@ -0,0 +1,153 @@ +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev roaming :: engine ║ +# ║ Roaming ~ over the tailnet. Source this from .zshrc. ║ +# ║ ║ +# ║ Reads ~/.config/roaming/hosts and generates a function per ║ +# ║ host so you can run: ║ +# ║ mail → interactive shell on mail ║ +# ║ mail tail -f /var/log/x → run with TTY (works for -f, sudo) ║ +# ║ mount_host mail → sshfs the host's mount at ║ +# ║ /workspace/mail ║ +# ╚══════════════════════════════════════════════════════════════════╝ + +export ROAMING_DIR="${ROAMING_DIR:-$HOME/.config/roaming}" +export ROAMING_HOSTS="${ROAMING_HOSTS:-$ROAMING_DIR/hosts}" +export ROAMING_MOUNT_BASE="${ROAMING_MOUNT_BASE:-/workspace}" +export ROAMING_DEFAULT_USER="${ROAMING_DEFAULT_USER:-$USER}" + +# sshfs options that prevent the dreaded indefinite-freeze on network blips. +# ServerAliveInterval=15 + CountMax=3 → I/O errors out after ~45s instead of +# hanging forever. reconnect re-establishes when the host comes back. +export ROAMING_SSHFS_OPTS="reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,follow_symlinks" + +# ── Internal: resolve a host name to its registry fields ────────────────────── +# Sets globals: _RH_NAME _RH_FQDN _RH_USER _RH_MOUNT +# Returns 1 if not found. +_roaming_lookup() { + local want="$1" + [[ -f "$ROAMING_HOSTS" ]] || return 1 + local name fqdn user mount + while read -r name fqdn user mount; do + [[ -z "$name" || "$name" == \#* ]] && continue + if [[ "$name" == "$want" ]]; then + _RH_NAME="$name" + _RH_FQDN="$fqdn" + _RH_USER="${user:-$ROAMING_DEFAULT_USER}" + _RH_MOUNT="$mount" + return 0 + fi + done < "$ROAMING_HOSTS" + return 1 +} + +# ── Internal: the actual remote-exec logic ──────────────────────────────────── +# No args → interactive shell. Args → run with a TTY (so tail -f / sudo work). +# "Dumb" by design: pipes/redirects run LOCALLY, exactly like plain ssh. +_roaming_exec() { + local name="$1"; shift + if ! _roaming_lookup "$name"; then + echo "roaming: unknown host '$name' (check $ROAMING_HOSTS)" >&2 + return 1 + fi + local target="${_RH_USER}@${_RH_FQDN}" + if [[ $# -eq 0 ]]; then + ssh -t "$target" + else + ssh -t "$target" "$@" + fi +} + +# ── mount_host ───────────────────────────────────────────────────────── +mount_host() { + local name="$1" + if [[ -z "$name" ]]; then + echo "usage: mount_host " >&2 + return 1 + fi + if ! _roaming_lookup "$name"; then + echo "roaming: unknown host '$name'" >&2 + return 1 + fi + if [[ -z "$_RH_MOUNT" ]]; then + echo "roaming: '$name' has no mount path in registry" >&2 + return 1 + fi + local mp="$ROAMING_MOUNT_BASE/$name" + # Already mounted? + if mountpoint -q "$mp" 2>/dev/null; then + echo "roaming: $name already mounted at $mp" + return 0 + fi + mkdir -p "$mp" + echo "roaming: mounting ${_RH_USER}@${_RH_FQDN}:${_RH_MOUNT} → $mp" + sshfs "${_RH_USER}@${_RH_FQDN}:${_RH_MOUNT}" "$mp" \ + -o "$ROAMING_SSHFS_OPTS" \ + && echo "roaming: ✓ mounted" \ + || echo "roaming: ✗ mount failed" >&2 +} + +# ── unmount_host ─────────────────────────────────────────────────────── +unmount_host() { + local name="$1" + if [[ -z "$name" ]]; then + echo "usage: unmount_host " >&2 + return 1 + fi + local mp="$ROAMING_MOUNT_BASE/$name" + if ! mountpoint -q "$mp" 2>/dev/null; then + echo "roaming: $name not mounted" + return 0 + fi + # fusermount3 first; -z (lazy) as fallback for stale mounts + fusermount3 -u "$mp" 2>/dev/null \ + || fusermount3 -uz "$mp" 2>/dev/null \ + || umount "$mp" 2>/dev/null + if mountpoint -q "$mp" 2>/dev/null; then + echo "roaming: ✗ failed to unmount $name (stale? try: fusermount3 -uz $mp)" >&2 + return 1 + fi + rmdir "$mp" 2>/dev/null + echo "roaming: ✓ unmounted $name" +} + +# ── roaming-status ──────────────────────────────────────────────────────────── +roaming-status() { + echo "── registered hosts ──" + if [[ -f "$ROAMING_HOSTS" ]]; then + local name fqdn user mount + while read -r name fqdn user mount; do + [[ -z "$name" || "$name" == \#* ]] && continue + local mp="$ROAMING_MOUNT_BASE/$name" + local mflag="" + mountpoint -q "$mp" 2>/dev/null && mflag=" [mounted: $mp]" + printf " %-10s %s@%s%s\n" "$name" "${user:-$ROAMING_DEFAULT_USER}" "$fqdn" "$mflag" + done < "$ROAMING_HOSTS" + else + echo " (no registry at $ROAMING_HOSTS)" + fi +} + +# ── Generate a function per host ────────────────────────────────────────────── +# After this loop, each registry entry `mail` becomes a `mail` command. +_roaming_generate() { + [[ -f "$ROAMING_HOSTS" ]] || return 0 + local name fqdn user mount + while read -r name fqdn user mount; do + [[ -z "$name" || "$name" == \#* ]] && continue + # Skip if it would clobber an existing command (safety) + if command -v "$name" >/dev/null 2>&1 && ! typeset -f "$name" >/dev/null 2>&1; then + echo "roaming: skipping '$name' — conflicts with existing command" >&2 + continue + fi + eval "$name() { _roaming_exec '$name' \"\$@\"; }" + done < "$ROAMING_HOSTS" +} + +# Reload registry + regenerate functions (call after editing hosts) +roaming-reload() { + _roaming_generate + echo "roaming: reloaded $(grep -cvE '^\s*#|^\s*$' "$ROAMING_HOSTS" 2>/dev/null || echo 0) host(s)" +} + +# Generate on source +_roaming_generate diff --git a/dotfiles/.zshrc b/dotfiles/.zshrc index f751414..f055ca3 100644 --- a/dotfiles/.zshrc +++ b/dotfiles/.zshrc @@ -201,3 +201,7 @@ if [[ -s "$NVM_DIR/nvm.sh" ]]; then } fi +# ── Roaming ~ (v3) ──────────────────────────────────────────────────────────── +# Fleet-wide remote control + sshfs mounts over the tailnet. +# Registry: ~/.config/roaming/hosts Add hosts: add_new_machine.sh +[[ -f "$HOME/.config/roaming/roaming.zsh" ]] && source "$HOME/.config/roaming/roaming.zsh" diff --git a/roaming-zshrc.snippet b/roaming-zshrc.snippet new file mode 100644 index 0000000..e9a9cd3 --- /dev/null +++ b/roaming-zshrc.snippet @@ -0,0 +1,4 @@ +# ── Roaming ~ (v3) ──────────────────────────────────────────────────────────── +# Fleet-wide remote control + sshfs mounts over the tailnet. +# Registry: ~/.config/roaming/hosts Add hosts: add_new_machine.sh +[[ -f "$HOME/.config/roaming/roaming.zsh" ]] && source "$HOME/.config/roaming/roaming.zsh" diff --git a/scripts/add_new_machine.sh b/scripts/add_new_machine.sh new file mode 100755 index 0000000..cf4088c --- /dev/null +++ b/scripts/add_new_machine.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# ╔══════════════════════════════════════════════════════════════════╗ +# ║ arch-dev roaming :: add_new_machine ║ +# ║ Onboard a new host into the roaming fleet: ║ +# ║ 1. push your SSH key (ssh-copy-id) ║ +# ║ 2. register it in ~/.config/roaming/hosts ║ +# ║ 3. optionally test the connection + mount ║ +# ╚══════════════════════════════════════════════════════════════════╝ +set -euo pipefail + +ROAMING_DIR="${ROAMING_DIR:-$HOME/.config/roaming}" +ROAMING_HOSTS="${ROAMING_HOSTS:-$ROAMING_DIR/hosts}" +ROAMING_DEFAULT_USER="${ROAMING_DEFAULT_USER:-$USER}" + +usage() { + cat < [user] [mount] + + name short alias for the host (e.g. mail) + fqdn reachable hostname (e.g. mail.tailnet.ts) + user ssh user (default: $ROAMING_DEFAULT_USER) + mount remote path to expose via sshfs (optional, e.g. / or /srv) + +Examples: + add_new_machine.sh mail mail.tailnet.ts root / + add_new_machine.sh proxy proxy.tailnet.ts admin /etc/caddy + add_new_machine.sh dev dev-server.tailnet.ts wayne +EOF + exit 1 +} + +[[ $# -lt 2 ]] && usage + +NAME="$1" +FQDN="$2" +USER_ARG="${3:-$ROAMING_DEFAULT_USER}" +MOUNT="${4:-}" + +mkdir -p "$ROAMING_DIR" +touch "$ROAMING_HOSTS" + +# ── Guard: already registered? ──────────────────────────────────────────────── +if grep -qE "^\s*${NAME}\s" "$ROAMING_HOSTS" 2>/dev/null; then + echo "✗ '$NAME' is already in $ROAMING_HOSTS" + echo " edit the file directly to change it" + exit 1 +fi + +# ── Step 1: SSH key ─────────────────────────────────────────────────────────── +echo "── Step 1: pushing SSH key to ${USER_ARG}@${FQDN} ──" +echo " (you'll be prompted for the remote password once)" +if ssh-copy-id "${USER_ARG}@${FQDN}"; then + echo " ✓ key installed" +else + echo " ✗ ssh-copy-id failed — aborting, nothing registered" + exit 1 +fi + +# ── Step 2: register ────────────────────────────────────────────────────────── +echo "── Step 2: registering in $ROAMING_HOSTS ──" +printf "%-10s %-26s %-8s %s\n" "$NAME" "$FQDN" "$USER_ARG" "$MOUNT" >> "$ROAMING_HOSTS" +echo " ✓ registered" + +# ── Step 3: verify ──────────────────────────────────────────────────────────── +echo "── Step 3: verifying connection ──" +if ssh -o BatchMode=yes -o ConnectTimeout=8 "${USER_ARG}@${FQDN}" 'echo ok' >/dev/null 2>&1; then + echo " ✓ key-based SSH works" +else + echo " ⚠ key-based SSH test failed (host may still be fine — check manually)" +fi + +echo "" +echo "✓ Done. In a new shell (or after 'roaming-reload'):" +echo " $NAME # interactive shell" +echo " $NAME sudo systemctl status # run a command" +[[ -n "$MOUNT" ]] && echo " mount_host $NAME # sshfs ${MOUNT} → /workspace/$NAME"