Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 135 additions & 4 deletions bootstrap/genesis/scripts/provision-usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand Down Expand Up @@ -182,6 +194,40 @@ 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
# =============================================================================
Expand Down Expand Up @@ -1043,8 +1089,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...")
Expand All @@ -1065,7 +1172,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")
Expand Down Expand Up @@ -1100,6 +1218,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)
Expand Down Expand Up @@ -1214,7 +1345,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...")
Expand All @@ -1231,7 +1362,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(
Expand Down
7 changes: 7 additions & 0 deletions infrastructure/network/vyos/.sops.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
creation_rules:
- path_regex: .*\.sops\.yaml$
key_groups:
- age:
- age1d9n4x345xfahp0wmjak4gjzawpjvmcxmyf5knct7yy84mznq2sdqp0dy2d
pgp:
- 3965F16E293466CFE77D47F38C15553EEB22DB2A
91 changes: 91 additions & 0 deletions infrastructure/network/vyos/ansible/init.sh
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 2 additions & 1 deletion infrastructure/network/vyos/ansible/inventory/hosts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions infrastructure/network/vyos/configs/gateway.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
33 changes: 33 additions & 0 deletions infrastructure/network/vyos/ssh.sops.yaml
Original file line number Diff line number Diff line change
@@ -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