Newer
Older
openstack-caracal-dc-dc / scripts / site-forward.sh
#!/usr/bin/env bash
# scripts/site-forward.sh {render|install|status|remove|list} <site> [--key PATH]
#
# Durable, ROOTLESS SSH access from the vcloud jumphost to a site service VM
# (D-126). Manages a per-site systemd --user unit that holds an autossh-style
# local port-forward alive across reboots, so `http://127.0.0.1:<port>` on the
# jumphost reaches a service VM that lives behind a containment/edge VM -- with
# NO root, NO persisted L3 route (which would race libvirt bringing up virbr2),
# and NO sudo at run time. It reuses the SAME transport we already use by hand
# (ssh through voffice1 / a DC containment VM), just kept up by the user manager.
#
# WHY user-systemd + a forward, not a route: the jumphost reaches office1-netbox
# (10.10.1.10) only through voffice1's lxdbr0 NAT. A `sudo ip route` is lost on
# reboot and, persisted via netplan, races libvirt (virbr2 is libvirt-managed).
# A user unit is rootless, survives reboot (with linger), and the SAME shape
# generalizes to the DCs via ProxyJump through their containment VM.
#
# SECRETS BOUNDARY: this tool NEVER reads key or token material. The SSH key is
# operator-supplied via --key (a PATH, passed to `ssh -i`; ssh reads it, we do
# not) and is NEVER baked in. The NetBox API token is a separate concern the
# importers handle on the netbox host -- this forward only carries read/UI TCP.
#
# `install` and the first connect are OPERATOR-RUN: they touch the operator's
# key + accept a host key (TOFU) + may need `loginctl enable-linger` (printed,
# not executed here). `render`/`list`/`status` are read-only. ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"   # self-locate (L9)

# --- site table: MEASURED/as-built values only (hard rule 2). Format:
#     name = "SSH_DEST|PROXYJUMP|TARGET_HOST|TARGET_PORT|LOCAL_PORT"
#   SSH_DEST   the host ssh connects TO and that opens the forward
#   PROXYJUMP  optional `-J` hop (empty = none); the DC form jumps THROUGH the
#              containment VM to reach an inner service VM
#   TARGET_*   what SSH_DEST forwards to (host:port on SSH_DEST's near side)
#   LOCAL_PORT the 127.0.0.1 port bound on the jumphost
#
# office1-netbox: voffice1 (10.10.0.20, as-built) is the lxdbr0 gateway, so it
# forwards straight to 10.10.1.10:8000 (measured: HTTP 302). No ProxyJump.
# DC inner VMs are DEFERRED: their containment-VM + inner IPs are not measured
# yet -- add a row ONLY once measured (never an inferred IP). See the template.
declare -A SITES=(
  [office1-netbox]="jessea123@10.10.0.20||10.10.1.10|8000|8000"
  # [vr1-dc0-<svc>]="<inner-vm-user@ip>|<containment-vm-user@ip>|<inner-ip>|<port>|<localport>"  # MEASURE first
)

UNIT_PREFIX="site-forward-"

die() { echo "site-forward: $*" >&2; exit 1; }

usage() {
  cat <<EOF
usage: bash scripts/site-forward.sh <cmd> <site> [--key PATH]
  render  <site> [--key PATH]   print the systemd --user unit (read-only; --key
                                optional here -- omitted shows a <KEY-PATH> slot)
  install <site> --key PATH     write + enable --now the user unit (OPERATOR-RUN:
                                needs the key + first-connect host-key accept)
  status  <site>                systemctl --user status for the unit
  remove  <site>                disable + delete the user unit (rootless)
  list                          show configured sites
sites: ${!SITES[*]}
EOF
}

lookup() {
  local site="$1"
  [ -n "${SITES[$site]+x}" ] || die "unknown site '$site' (have: ${!SITES[*]})"
  IFS='|' read -r DEST PJUMP THOST TPORT LPORT <<<"${SITES[$site]}"
  [ -n "$DEST" ] && [ -n "$THOST" ] && [ -n "$TPORT" ] && [ -n "$LPORT" ] \
    || die "site '$site' row is malformed"
}

render_unit() {
  # $1 site, $2 key-path-or-empty
  local site="$1" key="${2:-}"
  lookup "$site"
  local keyexpr pj=""
  if [ -n "$key" ]; then keyexpr="-i $key"; else keyexpr="-i <KEY-PATH>"; fi
  [ -n "$PJUMP" ] && pj="-J $PJUMP "
  # MEASURE ssh at render time (hard rule 2 -- never bake an unmeasured path); the
  # unit renders on the host it runs on, so this is the runtime path too.
  local sshbin; sshbin="$(command -v ssh)" || die "no ssh binary on PATH"
  # No network-online ordering: the --user manager does NOT provide that target,
  # so it would be inert. Resilience is Restart=always + ExitOnForwardFailure --
  # ssh simply fails until the network + jump host are up, and systemd retries.
  # -N no shell, -T no tty, BatchMode (never prompt), ExitOnForwardFailure so a
  # dead forward exits (Restart brings it back) instead of a live-but-useless
  # tunnel, keepalives to reap half-open, accept-new for first-run TOFU, bind
  # 127.0.0.1 only (never 0.0.0.0).
  cat <<EOF
[Unit]
Description=D-126 durable SSH forward to ${site} (127.0.0.1:${LPORT} -> ${THOST}:${TPORT})

[Service]
Type=simple
ExecStart=${sshbin} -N -T ${pj}${keyexpr} \\
  -o BatchMode=yes -o ExitOnForwardFailure=yes \\
  -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\
  -o StrictHostKeyChecking=accept-new \\
  -L 127.0.0.1:${LPORT}:${THOST}:${TPORT} ${DEST}
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
EOF
}

cmd="${1:-}"; site="${2:-}"; KEY=""
shift $(( $# > 0 ? 1 : 0 )) || true
# parse --key from the remaining args
args=("$@")
for ((i=0; i<${#args[@]}; i++)); do
  case "${args[$i]}" in
    --key) KEY="${args[$((i+1))]:-}"; [ -n "$KEY" ] || die "--key needs a PATH" ;;
  esac
done

case "$cmd" in
  -h|--help|help|"") usage; [ -z "$cmd" ] && exit 1 || exit 0 ;;
  list) echo "configured sites:"; for s in "${!SITES[@]}"; do echo "  $s -> ${SITES[$s]}"; done ;;
  render)
    [ -n "$site" ] || die "render needs a <site>"
    render_unit "$site" "$KEY"
    ;;
  install)
    [ -n "$site" ] || die "install needs a <site>"
    [ -n "$KEY" ] || die "install needs --key PATH (operator-supplied; never inferred)"
    [ -r "$KEY" ] || die "key path not readable: $KEY"
    lookup "$site"
    udir="$HOME/.config/systemd/user"
    unit="${UNIT_PREFIX}${site}.service"
    mkdir -p "$udir"
    render_unit "$site" "$KEY" > "$udir/$unit"
    echo "wrote $udir/$unit"
    systemctl --user daemon-reload
    systemctl --user enable --now "$unit"
    systemctl --user --no-pager status "$unit" | head -6 || true
    if [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]; then
      echo
      echo "NOTE: linger is OFF -- the unit will NOT start at boot until you run"
      echo "      (once, may need sudo):  loginctl enable-linger $USER"
    fi
    ;;
  status)
    [ -n "$site" ] || die "status needs a <site>"
    lookup "$site"
    systemctl --user --no-pager status "${UNIT_PREFIX}${site}.service" || true
    ;;
  remove)
    [ -n "$site" ] || die "remove needs a <site>"
    lookup "$site"
    unit="${UNIT_PREFIX}${site}.service"
    systemctl --user disable --now "$unit" 2>/dev/null || true
    rm -f "$HOME/.config/systemd/user/$unit"
    systemctl --user daemon-reload
    echo "removed $unit"
    ;;
  *) die "unknown command '$cmd' (see --help)" ;;
esac