v3: roaming home — sshfs mounts + per-host remote exec over tailnet

This commit is contained in:
Wayne Hayes 2026-06-27 19:20:20 -04:00
parent 9a77392d01
commit a03cfeb177
8 changed files with 407 additions and 14 deletions

View File

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

132
ROAMING.md Normal file
View File

@ -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/<name>` 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.

View File

@ -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 <project>_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:

View File

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

View File

@ -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 <name> ─────────────────────────────────────────────────────────
mount_host() {
local name="$1"
if [[ -z "$name" ]]; then
echo "usage: mount_host <name>" >&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 <name> ───────────────────────────────────────────────────────
unmount_host() {
local name="$1"
if [[ -z "$name" ]]; then
echo "usage: unmount_host <name>" >&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

View File

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

4
roaming-zshrc.snippet Normal file
View File

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

76
scripts/add_new_machine.sh Executable file
View File

@ -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 <<EOF
Usage: add_new_machine.sh <name> <fqdn> [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"