#!/usr/bin/env bash
# scripts/site-ssh-config.sh {render|install|remove|list} [--key PATH] [--config-dir DIR]
#
# Durable, ROOTLESS *shell* access from the vcloud jumphost to the VR1 site VMs
# (D-126, shell layer). Generates an ssh_config fragment of Host aliases with the
# right User + ProxyJump baked from as-built, so `ssh office1-netbox` (etc.) just
# works -- no remembering `-J jessea123@10.10.0.20 ubuntu@10.10.1.10`, and the SAME
# ProxyJump-through-the-containment-VM shape generalizes to the DC VMs once they
# exist. This is the FOUNDATION; scripts/site-forward.sh (the systemd port-forward)
# is a convenience that can ride these same aliases.
#
# SECRETS BOUNDARY (identical to site-forward.sh): this tool NEVER reads key
# material. The SSH key is operator-supplied via --key (a PATH written as an
# `IdentityFile`; ssh reads it, we do not) and is NEVER baked in. The generated
# fragment (which names that path) is written LOCALLY to ~/.ssh/config.d/ and is
# NOT committed -- the repo ships this generator + a <KEY-PATH> template only.
#
# `install` writes the fragment + ensures ~/.ssh/config has an `Include config.d/*`
# line; it does NOT connect (first connect is operator-run: TOFU host-key accept).
# `render`/`list` are read-only. Rootless throughout. ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # self-locate (L9)
FRAG_NAME="vr1-sites"
# --- host table: MEASURED/as-built values only (hard rule 2). Format:
# alias = "HOSTNAME|USER|PROXYJUMP|NOTE"
# PROXYJUMP empty = reached directly from vcloud (same L2 as virbr2/office1-local);
# else the alias to jump THROUGH (itself defined above it).
# As-built (docs/vr1-office1-as-built.md): voffice1 10.10.0.20 (jessea123, direct);
# office1-netbox 10.10.1.10 + office1-tailscale 10.10.1.11 (ubuntu, via voffice1's
# lxdbr0); OPNsense edge 10.10.0.1 (root, direct, tcsh). DC VMs are DEFERRED: their
# containment-VM + inner IPs are not deployed/measured yet -- add rows ONLY once
# measured (never an inferred IP); the DC form sets PROXYJUMP to the containment VM.
#
# PER-ENV KEY (D-126, operator ruling 2026-07-16): each DC env gets its OWN access
# key in its OWN creds folder (`~/vr1-dc0-creds/`, `~/vr1-dc1-creds/`) -- NOT office1's
# key. This tool applies ONE `--key` per invocation, so a DC env is installed as its
# OWN fragment: `install --env vr1-dc0 --key ~/vr1-dc0-creds/<dc0-key>` (the `--env`
# selector is added when the first DC row lands; office1 today = the default env).
# The DC keys are MINTED into those creds folders at DC deploy (pubkey -> DC cloud-init),
# per creds-manifests/<site>.manifest -- not generated speculatively here.
declare -a ALIAS_ORDER=(voffice1 office1-netbox office1-tailscale office1-opnsense)
declare -A HOSTS=(
[voffice1]="10.10.0.20|jessea123||Office1 host (libvirt + LXD + MAAS region)"
[office1-netbox]="10.10.1.10|ubuntu|voffice1|VR1 IPAM apex (NetBox, :8000)"
[office1-tailscale]="10.10.1.11|ubuntu|voffice1|Office1 subnet router (D-107)"
[office1-opnsense]="10.10.0.1|root||OPNsense edge -- root shell is tcsh, feed sh -s"
# [vr1-dc0-<vm>]="<inner-ip>|<user>|<containment-alias>|<note>" # MEASURE post-apply
)
die() { echo "site-ssh-config: $*" >&2; exit 1; }
usage() {
cat <<EOF
usage: bash scripts/site-ssh-config.sh <cmd> [--key PATH] [--config-dir DIR]
render print the ssh_config fragment (read-only; --key optional -- omitted
shows a <KEY-PATH> slot)
install write ~/.ssh/config.d/${FRAG_NAME} + ensure an Include line (needs --key;
does NOT connect -- first connect is operator-run, accepts host keys)
remove delete the fragment (leaves the Include line + ~/.ssh/config)
list show configured host aliases
aliases: ${ALIAS_ORDER[*]}
EOF
}
render_frag() {
local key="${1:-}" keyline
if [ -n "$key" ]; then keyline=" IdentityFile $key"; else keyline=" IdentityFile <KEY-PATH>"; fi
echo "# ssh_config fragment -- VR1 sites (D-126 shell layer). GENERATED by"
echo "# scripts/site-ssh-config.sh -- do not hand-edit; re-render instead. NOT committed"
echo "# (names an operator key path). accept-new = TOFU on first connect."
local a hn user pj note
for a in "${ALIAS_ORDER[@]}"; do
IFS='|' read -r hn user pj note <<<"${HOSTS[$a]}"
echo
echo "Host $a # $note"
echo " HostName $hn"
echo " User $user"
[ -n "$pj" ] && echo " ProxyJump $pj"
echo "$keyline"
echo " IdentitiesOnly yes"
echo " StrictHostKeyChecking accept-new"
done
}
cmd="${1:-}"; shift $(( $# > 0 ? 1 : 0 )) || true
KEY=""; CONFIG_DIR="$HOME/.ssh"
args=("$@")
for ((i=0; i<${#args[@]}; i++)); do
case "${args[$i]}" in
--key) KEY="${args[$((i+1))]:-}"; [ -n "$KEY" ] || die "--key needs a PATH" ;;
--config-dir) CONFIG_DIR="${args[$((i+1))]:-}"; [ -n "$CONFIG_DIR" ] || die "--config-dir needs a DIR" ;;
esac
done
case "$cmd" in
-h|--help|help|"") usage; [ -z "$cmd" ] && exit 1 || exit 0 ;;
list)
echo "configured aliases:"
for a in "${ALIAS_ORDER[@]}"; do echo " $a -> ${HOSTS[$a]}"; done
;;
render)
render_frag "$KEY"
;;
install)
[ -n "$KEY" ] || die "install needs --key PATH (operator-supplied; never inferred)"
[ -r "$KEY" ] || die "key path not readable: $KEY"
mkdir -p "$CONFIG_DIR/config.d"
chmod 700 "$CONFIG_DIR" 2>/dev/null || true
frag="$CONFIG_DIR/config.d/$FRAG_NAME"
render_frag "$KEY" > "$frag"
chmod 600 "$frag"
cfg="$CONFIG_DIR/config"
if [ ! -f "$cfg" ] || ! grep -qE '^\s*Include\s+config\.d/\*' "$cfg" 2>/dev/null; then
{ echo "Include config.d/*"; [ -f "$cfg" ] && cat "$cfg"; } > "$cfg.tmp"
mv "$cfg.tmp" "$cfg"; chmod 600 "$cfg"
echo "added 'Include config.d/*' to $cfg"
fi
echo "wrote $frag"
echo "next (operator): ssh voffice1 # first connect accepts host keys (TOFU)"
;;
remove)
rm -f "$CONFIG_DIR/config.d/$FRAG_NAME"
echo "removed $CONFIG_DIR/config.d/$FRAG_NAME (Include line + config left intact)"
;;
*) die "unknown command '$cmd' (see --help)" ;;
esac