From 1f61c0d78e9918168ff352b8dd253532bd739d69 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 29 Dec 2025 17:56:24 -0800 Subject: [PATCH 1/3] feat(vyos): add SSH key and password bootstrap via SOPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solve the VyOS bootstrapping problem where ansible couldn't connect after initial USB-based installation because no SSH key was configured. Changes: - Add SOPS-encrypted ssh.sops.yaml with SSH key pair and console password - Modify provision-usb.py to inject SSH credentials into gateway.conf - Add ansible/init.sh to extract SSH private key to ~/.ssh/vyos-gateway - Update ansible inventory to use the new default key path The bootstrap flow is now: 1. provision-usb.py injects SSH public key + password into config 2. VyOS is installed with working SSH access from the start 3. Operators run init.sh once to get the private key for ansible 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bootstrap/genesis/scripts/provision-usb.py | 137 +++++++++++++++++- infrastructure/network/vyos/.sops.yaml | 7 + infrastructure/network/vyos/ansible/init.sh | 91 ++++++++++++ .../network/vyos/ansible/inventory/hosts.yml | 3 +- infrastructure/network/vyos/ssh.sops.yaml | 33 +++++ 5 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 infrastructure/network/vyos/.sops.yaml create mode 100755 infrastructure/network/vyos/ansible/init.sh create mode 100644 infrastructure/network/vyos/ssh.sops.yaml diff --git a/bootstrap/genesis/scripts/provision-usb.py b/bootstrap/genesis/scripts/provision-usb.py index b079a7e..b249dce 100755 --- a/bootstrap/genesis/scripts/provision-usb.py +++ b/bootstrap/genesis/scripts/provision-usb.py @@ -94,6 +94,9 @@ # e2 credentials file (SOPS encrypted) E2_CREDENTIALS_FILE = REPO_ROOT / "images/e2.sops.yaml" +# SSH credentials file (SOPS encrypted) +SSH_CREDENTIALS_FILE = REPO_ROOT / "infrastructure/network/vyos/ssh.sops.yaml" + @dataclass class E2Credentials: @@ -105,6 +108,15 @@ class E2Credentials: bucket: str +@dataclass +class SSHCredentials: + """SSH key and password credentials for VyOS management.""" + + private_key: str + public_key: str + password: str + + @dataclass class USBDevice: """USB device information.""" @@ -182,6 +194,38 @@ def load_e2_credentials() -> E2Credentials: raise ValueError(f"Missing required key in e2 credentials: {e}") from e +def load_ssh_credentials() -> SSHCredentials: + """Load SSH credentials from SOPS-encrypted file.""" + if not SSH_CREDENTIALS_FILE.exists(): + raise FileNotFoundError(f"SSH credentials file not found: {SSH_CREDENTIALS_FILE}") + + # Check if sops is available + if not shutil.which("sops"): + raise RuntimeError("sops is not installed. Install with: brew install sops") + + # Decrypt the file using sops + result = subprocess.run( + ["sops", "-d", str(SSH_CREDENTIALS_FILE)], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to decrypt SSH credentials: {result.stderr}") + + # Parse the YAML + data = yaml.safe_load(result.stdout) + + try: + return SSHCredentials( + private_key=data["private_key"], + public_key=data["public_key"], + password=data["password"], + ) + except KeyError as e: + raise ValueError(f"Missing required key in SSH credentials: {e}") from e + + # ============================================================================= # Console Output # ============================================================================= @@ -1043,8 +1087,69 @@ def confirm_device(device: USBDevice, skip_ventoy: bool, yes: bool) -> bool: return console.confirm("Continue?", default=True) +def inject_ssh_key_into_config( + config_content: str, ssh_public_key: str, password: str | None = None +) -> str: + """Inject SSH public key and password into VyOS configuration. + + The public key format is: ssh-ed25519 AAAAC3... comment + We need to extract the type and key data for VyOS config format. + + The password is injected as plaintext-password - VyOS will hash it on load. + """ + # Parse the public key: "ssh-ed25519 AAAAC3... vyos-gateway" + parts = ssh_public_key.strip().split() + if len(parts) < 2: + raise ValueError(f"Invalid SSH public key format: {ssh_public_key}") + + key_type = parts[0] # e.g., "ssh-ed25519" + key_data = parts[1] # e.g., "AAAAC3..." + + # The placeholder in gateway.conf looks like: + # user vyos { + # authentication { + # /* SSH public keys added by Ansible deploy.yml */ + # /* Password set manually for console access */ + # } + # } + # We replace the comments with actual key configuration + + old_auth_block = """user vyos { + authentication { + /* SSH public keys added by Ansible deploy.yml */ + /* Password set manually for console access */ + } + }""" + + # Build the new authentication block + password_line = "" + if password and password != "CHANGE_ME": + password_line = f""" + plaintext-password "{password}\"""" + + new_auth_block = f"""user vyos {{ + authentication {{{password_line} + public-keys vyos-gateway {{ + key {key_data} + type {key_type} + }} + }} + }}""" + + if old_auth_block not in config_content: + raise ValueError( + "Could not find SSH key placeholder in gateway.conf. " + "Expected user vyos authentication block with comments." + ) + + return config_content.replace(old_auth_block, new_auth_block) + + def copy_files_to_usb( - mount_point: Path, vyos_iso: Path | None, talos_iso: Path | None + mount_point: Path, + vyos_iso: Path | None, + talos_iso: Path | None, + ssh_credentials: SSHCredentials | None = None, ) -> None: """Copy ISOs and config to USB.""" console.info("Copying files to USB...") @@ -1065,7 +1170,18 @@ def copy_files_to_usb( if VYOS_CONFIG.exists(): console.info("Copying VyOS configuration...") - shutil.copy2(VYOS_CONFIG, mount_point / VYOS_CONFIG.name) + config_content = VYOS_CONFIG.read_text() + + if ssh_credentials: + console.info("Injecting SSH credentials into configuration...") + config_content = inject_ssh_key_into_config( + config_content, ssh_credentials.public_key, ssh_credentials.password + ) + console.success("SSH credentials injected") + + # Write the (possibly modified) config to USB + dest_config = mount_point / VYOS_CONFIG.name + dest_config.write_text(config_content) console.success("VyOS configuration copied") console.success("All files copied to USB") @@ -1100,6 +1216,19 @@ def main(device: str | None, skip_download: bool, skip_ventoy: bool, yes: bool) console.error(str(e)) raise SystemExit(1) + # Load SSH credentials for config injection + ssh_credentials = None + try: + console.info("Loading SSH credentials...") + ssh_credentials = load_ssh_credentials() + console.success("SSH credentials loaded") + except FileNotFoundError as e: + console.error(str(e)) + raise SystemExit(1) + except RuntimeError as e: + console.error(str(e)) + raise SystemExit(1) + # Initialize managers usb_mgr = USBDeviceManager() download_mgr = DownloadManager(credentials=e2_credentials) @@ -1214,7 +1343,7 @@ def main(device: str | None, skip_download: bool, skip_ventoy: bool, yes: bool) raise SystemExit(1) console.success(f"Ventoy partition mounted at: {mount_point}") - copy_files_to_usb(mount_point, vyos_iso, talos_iso) + copy_files_to_usb(mount_point, vyos_iso, talos_iso, ssh_credentials) # Eject USB console.info("Ejecting USB device...") @@ -1231,7 +1360,7 @@ def main(device: str | None, skip_download: bool, skip_ventoy: bool, yes: bool) console.console.print(" - Ventoy bootloader installed") console.console.print(" - VyOS Stream ISO (for router installation)") console.console.print(" - Talos ISO with embedded config (for UM760 bootstrap)") - console.console.print(" - gateway.conf (VyOS configuration)") + console.console.print(" - gateway.conf (VyOS configuration with SSH key)") console.console.print() console.console.print("[bold]Next steps:[/bold]") console.console.print( diff --git a/infrastructure/network/vyos/.sops.yaml b/infrastructure/network/vyos/.sops.yaml new file mode 100644 index 0000000..29c9589 --- /dev/null +++ b/infrastructure/network/vyos/.sops.yaml @@ -0,0 +1,7 @@ +creation_rules: + - path_regex: .*\.sops\.yaml$ + key_groups: + - age: + - age1d9n4x345xfahp0wmjak4gjzawpjvmcxmyf5knct7yy84mznq2sdqp0dy2d + pgp: + - 3965F16E293466CFE77D47F38C15553EEB22DB2A diff --git a/infrastructure/network/vyos/ansible/init.sh b/infrastructure/network/vyos/ansible/init.sh new file mode 100755 index 0000000..74b2bc8 --- /dev/null +++ b/infrastructure/network/vyos/ansible/init.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# +# init.sh - Initialize local environment for VyOS ansible management +# +# This script extracts the VyOS management SSH key from SOPS-encrypted +# storage and places it in ~/.ssh for use by ansible. +# +# Usage: +# ./init.sh +# +# Requirements: +# - sops (brew install sops) +# - Access to SOPS decryption keys (age or GPG) +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SSH_SOPS_FILE="${SCRIPT_DIR}/../ssh.sops.yaml" +SSH_KEY_PATH="${HOME}/.ssh/vyos-gateway" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" + exit 1 +} + +# Check if sops is installed +if ! command -v sops &>/dev/null; then + error "sops is not installed. Install with: brew install sops" +fi + +# Check if SSH key file exists +if [[ ! -f "${SSH_SOPS_FILE}" ]]; then + error "SSH credentials file not found: ${SSH_SOPS_FILE}" +fi + +# Check if key already exists +if [[ -f "${SSH_KEY_PATH}" ]]; then + warn "SSH key already exists at ${SSH_KEY_PATH}" + read -p "Overwrite? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + info "Skipping SSH key extraction" + exit 0 + fi +fi + +# Extract private key from SOPS file +info "Decrypting SSH credentials..." +PRIVATE_KEY=$(sops -d --extract '["private_key"]' "${SSH_SOPS_FILE}" 2>/dev/null) + +if [[ -z "${PRIVATE_KEY}" ]]; then + error "Failed to decrypt SSH credentials" +fi + +# Ensure ~/.ssh directory exists with correct permissions +mkdir -p "${HOME}/.ssh" +chmod 700 "${HOME}/.ssh" + +# Write private key with correct permissions +info "Writing SSH key to ${SSH_KEY_PATH}..." +echo "${PRIVATE_KEY}" > "${SSH_KEY_PATH}" +chmod 600 "${SSH_KEY_PATH}" + +# Also extract and write public key for convenience +info "Writing public key to ${SSH_KEY_PATH}.pub..." +PUBLIC_KEY=$(sops -d --extract '["public_key"]' "${SSH_SOPS_FILE}" 2>/dev/null) +echo "${PUBLIC_KEY}" > "${SSH_KEY_PATH}.pub" +chmod 644 "${SSH_KEY_PATH}.pub" + +info "SSH key initialized successfully!" +echo "" +echo "Key location: ${SSH_KEY_PATH}" +echo "" +echo "You can now run ansible playbooks:" +echo " cd ${SCRIPT_DIR}" +echo " ansible-playbook playbooks/deploy.yml -i inventory/hosts.yml" diff --git a/infrastructure/network/vyos/ansible/inventory/hosts.yml b/infrastructure/network/vyos/ansible/inventory/hosts.yml index 12f8b76..483af52 100644 --- a/infrastructure/network/vyos/ansible/inventory/hosts.yml +++ b/infrastructure/network/vyos/ansible/inventory/hosts.yml @@ -14,7 +14,8 @@ all: ansible_connection: ansible.netcommon.network_cli # SSH key authentication (no password) - ansible_ssh_private_key_file: "{{ lookup('env', 'VYOS_SSH_KEY') | default('~/.ssh/id_rsa', true) }}" + # Default key is extracted by running: ../init.sh + ansible_ssh_private_key_file: "{{ lookup('env', 'VYOS_SSH_KEY') | default('~/.ssh/vyos-gateway', true) }}" # VyOS-specific settings ansible_become: false # VyOS uses configure mode, not sudo diff --git a/infrastructure/network/vyos/ssh.sops.yaml b/infrastructure/network/vyos/ssh.sops.yaml new file mode 100644 index 0000000..f75b30e --- /dev/null +++ b/infrastructure/network/vyos/ssh.sops.yaml @@ -0,0 +1,33 @@ +#ENC[AES256_GCM,data:1qB/FeCVSfaO23ie0K2Au5evS65G,iv:HVV7ok79jLZ5ljkX6L79T0GjgzmgyDDdvVDdbM7f2f0=,tag:S+sGhHmQ9b8362jtXVVGjQ==,type:comment] +#ENC[AES256_GCM,data:iuT5kC4hD9zRcoU3X5AxxL6LqDJEPAfYvJDcLtCpE/JPk87SY6KFMUSefix2nRYa2Xw=,iv:K9V+CJwxHJgo5nARvAE/3NmcKcU/V/PM7SUl889nYic=,tag:Orv9t20FJJVYCgK78HvBrA==,type:comment] +private_key: ENC[AES256_GCM,data:pteu5aHQwxETWqcLImAq2Ait1grgwxQswSc5ZCoahmpLORutDmE1oAacoW/C4OCAI6hLfDYpymKXxMnlVAl0jM2qUhdqwGgH0hGeMH9b5oiBDP/2X9Vwu9H4yVL/jCgWHHgbsBR+TT5zfzhL6Lwg6rCHTn2NiF73r/rQeefvpH2JquS2zuMOnAPY31KSljAbOk9gxOf48Ty9S5BnJerh7Vyx66Q5I9HhPLh5EsuKmsxzCcERRMP6mv6skn3sb14sZDtCajQj0qbF3s5eyCGH6eVNVQgAtYUOKFxwr0lNRcB47uNlwZRutwqmCnZFUjJM+B6fkLhajcaB2aF5o810qlZ432ukaKUyBbgbF8Z6lxRup4MRdqdMir2Svoi/0FxbGLt1PWl9M/QVtpePAWPXLqS0yll1IUkwl7Za4+XTAJSN//sEUMZCubtFPoImsIpsA5iiVfz+n5SU4zgw7nTJ6jdkjPrWb9Bx4VZk288acVs3N+GU/YHUMv0zs5AQEM8etCIkYZRJDzZLxQrlo8Fp,iv:jSVHgAm5FYH32Gcv10FJcn3Txs4PwbdB7QfwdRgkvMk=,tag:G1e2JBJZ/aCxuFb2OvjObg==,type:str] +public_key: ENC[AES256_GCM,data:w6ePa1pQhA4yCCf0eHfYPVi9aUHDF91Tdx3p9wjscbtG19o63EzLpYyr4zMhFHfLcBeeWzz8yvT9TNVZJG1Yb2cp2FNdFM2QgyX0jaQ9SQynhckVkc8n17oDbb7a,iv:atuKMPLFPPsHo8gYqblLp7tmelZrgnEOhyOPxB5UaOU=,tag:vbd0s29Kq5EUqb95VuCM/g==,type:str] +#ENC[AES256_GCM,data:HZmo2k4GmkN3knacm019kfgtgGaU4QlkcljqOBlDiKs9mo+g0G8v8+1nXWPQOr0ekgDGwsbBH2KnJXbzDmaAKItza/jDIy30gy8=,iv:FBX6BNVNtbmo5F1Z/UFimiFe4LIngDR3X0c0Ns9/OM8=,tag:l0trEgC5M7hIfmWlkNA2Qg==,type:comment] +password: ENC[AES256_GCM,data:N0fHYbf4a8af8gesU7iG,iv:gn+ioaRTj/QcgOi1ycAisLBj9FZrg/iBupN9GSI1EeQ=,tag:Vz2HfhWXPk58MkfQUtu18A==,type:str] +sops: + age: + - recipient: age1d9n4x345xfahp0wmjak4gjzawpjvmcxmyf5knct7yy84mznq2sdqp0dy2d + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvNS81RENxR25PZE9KZ3lZ + WFFKYzZNUzVpZWl1Z0l2QVVKbmRydEFUTUFNCithTXR5eHVlQ1ZzVlY1SHBWWk10 + NUYyTHhUN0IzTGpVaU9VZ1AxWTRYY2sKLS0tIGdzd05kVTVBemVtUDhkREo0T2p6 + elNqNktNcWUvYkNNMG9zZnBkelBZdlEKXGSooaQj/dZ5LhxOeIgzRWk46YDX0x5c + nalWztEAhX3+NDZ0BwA+JGUSzT6OwuZDnZJfnzPnGKvp7oHCqdBsmA== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2025-12-30T01:53:22Z" + mac: ENC[AES256_GCM,data:16ze69cFTEY+TCgAXTwSusx3tqpec11VyjENFHWAALI4agmRlMvOi+GrINNBP7c1/WLEt6HAWwy9p0j0RWls8Wwu3+aNZYhjHoM7SZp/jahQOimi7y2j2j3j4JJZOcu4h/Z0ZwZ6tfEmHZkufwU4cdUNOyXHXEpcGgxpTZbghgg=,iv:O0jt57LBVTogPDyf19/KVK3HIDN2jKO8K7OsM/eM8ac=,tag:NIvsRmIgVMTm4hDzJPe93Q==,type:str] + pgp: + - created_at: "2025-12-30T01:52:26Z" + enc: |- + -----BEGIN PGP MESSAGE----- + + hF4DhYpGbIWgl5wSAQdAyWL1gh1onVDoRB0TGS77p4XiFlxqhRR5h6AEgXQp2n0w + m2tATLw99RoB13O07xCtWxLtmbA9Q3MZvNHaYIL+Pzg8xzHLlL9k6Tx8ZL2OCpeI + 0l4BCWcUzMWykuiiuJFVlWDYfWQvPPpFdXC+8M4MGEkwZ5l03Ez+nxpcBl59l+jP + pqTzIdGjueq/ZAD9FDCKvUC7HxpYrH9asXCjVpNb8PX4yvKuKLtxGngGati497ao + =areg + -----END PGP MESSAGE----- + fp: 3965F16E293466CFE77D47F38C15553EEB22DB2A + unencrypted_suffix: _unencrypted + version: 3.11.0 From 6a97fcfbaf90f673335f44006874f036a3d05f8c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 29 Dec 2025 18:53:15 -0800 Subject: [PATCH 2/3] fix(vyos): correct static route description placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move description from next-hop block to route block level. VyOS circinus schema only allows description at the route level, not nested under next-hop. The incorrect placement caused VyOS to silently ignore the entire protocols static section during config load. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- infrastructure/network/vyos/configs/gateway.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/network/vyos/configs/gateway.conf b/infrastructure/network/vyos/configs/gateway.conf index f2c1dce..974a4dd 100644 --- a/infrastructure/network/vyos/configs/gateway.conf +++ b/infrastructure/network/vyos/configs/gateway.conf @@ -280,13 +280,13 @@ protocols { } static { route 0.0.0.0/0 { + description "Default route via CCR2004" next-hop 10.0.0.1 { - description "Default route via CCR2004" } } route 192.168.1.0/24 { + description "Home network via CCR2004" next-hop 10.0.0.1 { - description "Home network via CCR2004" } } } From cb17f88d4f8df74ff7a0536a104afecb55d43b9e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 29 Dec 2025 18:55:03 -0800 Subject: [PATCH 3/3] style(genesis): fix line length in provision-usb.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bootstrap/genesis/scripts/provision-usb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bootstrap/genesis/scripts/provision-usb.py b/bootstrap/genesis/scripts/provision-usb.py index b249dce..8e577df 100755 --- a/bootstrap/genesis/scripts/provision-usb.py +++ b/bootstrap/genesis/scripts/provision-usb.py @@ -197,7 +197,9 @@ def load_e2_credentials() -> E2Credentials: def load_ssh_credentials() -> SSHCredentials: """Load SSH credentials from SOPS-encrypted file.""" if not SSH_CREDENTIALS_FILE.exists(): - raise FileNotFoundError(f"SSH credentials file not found: {SSH_CREDENTIALS_FILE}") + raise FileNotFoundError( + f"SSH credentials file not found: {SSH_CREDENTIALS_FILE}" + ) # Check if sops is available if not shutil.which("sops"):