v3: roaming home — sshfs mounts + per-host remote exec over tailnet
This commit is contained in:
parent
9a77392d01
commit
a03cfeb177
10
Dockerfile
10
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
|
||||
|
||||
132
ROAMING.md
Normal file
132
ROAMING.md
Normal 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.
|
||||
@ -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:
|
||||
|
||||
17
dotfiles/.config/roaming/hosts
Normal file
17
dotfiles/.config/roaming/hosts
Normal 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
|
||||
153
dotfiles/.config/roaming/roaming.zsh
Normal file
153
dotfiles/.config/roaming/roaming.zsh
Normal 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
|
||||
@ -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
4
roaming-zshrc.snippet
Normal 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
76
scripts/add_new_machine.sh
Executable 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"
|
||||
Loading…
Reference in New Issue
Block a user