diff --git a/.gitignore b/.gitignore index 619cf86..c359ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ bootstrap/host/inventory/ *.tar.gz *.zip infra/gitea/data/ +infra/matrix-server/synapse-data/ +infra/matrix-server/postgres-data/ +infra/matrix-server/.env +infra/omniroute/.env infra/youtube-backup/.env infra/youtube-backup/cookies.txt infra/youtube-backup/archive.txt @@ -21,6 +25,8 @@ infra/youtube-backup/logs/ *.decrypted.yaml *.plain.yaml *.secret.local.yaml +*.secret.yaml.plain +*.secret.plain .age-key.txt sops-age.key .sops.yaml.tmp diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..cad6810 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,6 @@ +# Copy this file to .sops.yaml after replacing the age recipient with the +# public key generated on the Debian homelab server. +creation_rules: + - path_regex: '(^|/).*\.(secret|enc)\.(ya?ml|json)$' + encrypted_regex: '.*' + age: age1ywayyaqwksdzedhf3w8xlsf89lnzw89c5yxtwjp42pnc5cds4dds5j6yxm diff --git a/homelab.yml b/homelab.yml index bc22a18..1e88da9 100644 --- a/homelab.yml +++ b/homelab.yml @@ -12,6 +12,8 @@ domain: subdomains: - demos.lab2025.duckdns.org - heimdall.lab2025.duckdns.org + - matrix.lab2025.duckdns.org + - element.lab2025.duckdns.org - grafana.lab2025.duckdns.org - prometheus.lab2025.duckdns.org - alertmanager.lab2025.duckdns.org @@ -116,6 +118,32 @@ services: suwayomi_port: 4567 maintainerr_port: 6246 flaresolverr_port: 8191 + matrix: + host: debian + install_dir: /home/jv/matrix-server + synapse_port: 8008 + element_port: 8081 + server_name: matrix.lab2025.duckdns.org + synapse_image: matrixdotorg/synapse:latest + element_image: vectorim/element-web:latest + postgres_image: postgres:16-alpine + postgres_db: synapse + postgres_user: synapse + bot_user: hermes + allowed_users: + - "@jv:matrix.lab2025.duckdns.org" + omniroute: + host: debian + install_dir: /home/jv/homelab-omniroute + port: 20128 + image: diegosouzapw/omniroute:latest + container_name: omniroute + hermes: + host: debian + home: /home/jv/.hermes + install_dir: /home/jv/.hermes/hermes-agent + uv_bin: /home/jv/.hermes/bin/uv + bot_user: hermes traefik: host: kubernetes load_balancer_ip: 192.168.100.240 diff --git a/infra/hermes.secret.yaml b/infra/hermes.secret.yaml new file mode 100644 index 0000000..4f916c8 --- /dev/null +++ b/infra/hermes.secret.yaml @@ -0,0 +1,19 @@ +# Hermes agent secrets - SOPS-encrypted. Do not commit plaintext. +# Decrypted at deploy time by jeannie deploy_hermes. +MATRIX_BOT_PASSWORD: ENC[AES256_GCM,data:+21aq+VzaYZLizC2p8oOEf1614M=,iv:qKpVrpE50ByIOXW32RnYy7zwPIbtt3UybAn7Pb8bY5Y=,tag:utUx46AyxK+jk3c7Wdnygw==,type:str] +HERMES_OMNIROUTE_API_KEY: "" +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBySEFyREdGOS9HOGtkeVRF + M0w4aUcxK2F5NTdSR29CSDY2SHZVbWlXZ0hzCmZOV3VXa3J5eEJLY2pTa2RHTGhV + eWpXdXVmVWFiLzZpMmlyUmJIQ3hUdjAKLS0tIG8ybWVNTXNZTHM4dnJ4Y2Z5VXAr + TXVIVXVWenlBc0lEZ0VSWmdFQmpPSGsK2FtwcyBFqVzCphxGVV8xg6QZea3SC6wt + gX1rp3icFegLisy/m4cUJkfku9zTHoHjsqPjbl+xzs3iaReHSVQRlA== + -----END AGE ENCRYPTED FILE----- + recipient: age1ywayyaqwksdzedhf3w8xlsf89lnzw89c5yxtwjp42pnc5cds4dds5j6yxm + encrypted_regex: .* + lastmodified: "2026-09-16T16:32:24Z" + mac: ENC[AES256_GCM,data:0wKCLSIIDh4+/IHY/UN2KeYEdHBn0ZhCVRcIa3TV4P8CtRbcItQbhj5e95q5FPWJRBASXIxj/WwOkyd5JFUmxH5XhaeKCHvxbgNMClPb8Ic5KSnNMRgSZtrCgKlACySFEDL5aD9mxqkd3f5bVd+JY04HGHkSJUWRfdGnNdwGfnw=,iv:BPyQLTty1qjSldlxeUEC/Uo80H1R8Vlfey9ie26JZ30=,tag:oYZ2TVwKUgf+oPl8nvqeLA==,type:str] + version: 3.13.3 diff --git a/infra/matrix-server/.env.example b/infra/matrix-server/.env.example new file mode 100644 index 0000000..ceba424 --- /dev/null +++ b/infra/matrix-server/.env.example @@ -0,0 +1,19 @@ +# Matrix stack — non-secret defaults. +# Copy to a sibling .env (gitignored) or export these as env vars when running +# deploy_matrix. NEVER put real secrets in this committed example. +MATRIX_SERVER_NAME=matrix.lab2025.duckdns.org +MATRIX_SYNAPSE_PORT=8008 +MATRIX_ELEMENT_PORT=8081 +MATRIX_SYNAPSE_CONTAINER_NAME=matrix-synapse +MATRIX_POSTGRES_CONTAINER_NAME=matrix-postgres +MATRIX_ELEMENT_CONTAINER_NAME=matrix-element +MATRIX_POSTGRES_DB=synapse +MATRIX_POSTGRES_USER=synapse + +# SECRETS (must be supplied at deploy time, not committed): +# MATRIX_POSTGRES_PASSWORD postgres/synapse db password +# MATRIX_REGISTRATION_SHARED_SECRET shared registration secret for register_new_matrix_user +# MATRIX_MACAROON_SECRET_KEY macaroon secret +# MATRIX_FORM_SECRET form secret +# MATRIX_BOT_PASSWORD hermes bot account password +# (The synapse signing key is generated by Synapse on first boot — not a secret you set.) \ No newline at end of file diff --git a/infra/matrix-server/bootstrap.sh b/infra/matrix-server/bootstrap.sh new file mode 100644 index 0000000..518bf7f --- /dev/null +++ b/infra/matrix-server/bootstrap.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Deploy/update the self-hosted Matrix stack (Synapse + Postgres + Element). +# Idempotent: renders homeserver.yaml from the committed template, ensures +# correct data-dir ownership, and starts containers. +# +# Secrets are read from an install-dir .env (written by jeannie deploy_matrix), +# NOT from the repo. The signing key is created by Synapse on first boot. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${MATRIX_ENV_FILE:-${SCRIPT_DIR}/.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Missing secret env file ${ENV_FILE}. Run jeannie deploy-matrix first." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +. "${ENV_FILE}" +set +a + +MATRIX_SERVER_NAME="${MATRIX_SERVER_NAME:-matrix.lab2025.duckdns.org}" +MATRIX_POSTGRES_DB="${MATRIX_POSTGRES_DB:-synapse}" +MATRIX_POSTGRES_USER="${MATRIX_POSTGRES_USER:-synapse}" +SYNAPSE_UID="${MATRIX_SYNAPSE_UID:-991}" +POSTGRES_UID="${MATRIX_POSTGRES_UID:-70}" + +render_homeserver() { + local tpl="${SCRIPT_DIR}/homeserver.yaml.tpl" + local out="${SCRIPT_DIR}/synapse-data/homeserver.yaml" + for var in MATRIX_SERVER_NAME MATRIX_POSTGRES_DB MATRIX_POSTGRES_USER \ + MATRIX_POSTGRES_PASSWORD MATRIX_REGISTRATION_SHARED_SECRET \ + MATRIX_MACAROON_SECRET_KEY MATRIX_FORM_SECRET; do + if [[ -z "${!var:-}" ]]; then + echo "Missing required secret/env ${var}" >&2 + exit 1 + fi + done + # shellcheck disable=SC2016 + envsubst \ + '${MATRIX_SERVER_NAME} ${MATRIX_POSTGRES_DB} ${MATRIX_POSTGRES_USER} ${MATRIX_POSTGRES_PASSWORD} ${MATRIX_REGISTRATION_SHARED_SECRET} ${MATRIX_MACAROON_SECRET_KEY} ${MATRIX_FORM_SECRET}' \ + <"${tpl}" >"${out}" + cp "${SCRIPT_DIR}/log_config.yaml" "${SCRIPT_DIR}/synapse-data/${MATRIX_SERVER_NAME}.log.config" +} + +sudo mkdir -p "${SCRIPT_DIR}/synapse-data" "${SCRIPT_DIR}/postgres-data" + +# First boot: generate signing key + log config (the files a hand-written +# homeserver.yaml would not provide). Without this Synapse fails to boot on a +# missing signing key. Only touches the data dir (not postgres). +if [[ ! -f "${SCRIPT_DIR}/synapse-data/${MATRIX_SERVER_NAME}.signing.key" ]]; then + echo "First boot: generating Synapse signing key + log config..." + docker compose --env-file "${ENV_FILE}" -f "${SCRIPT_DIR}/docker-compose.yml" \ + run --rm --no-deps synapse generate --config-path /data/homeserver.yaml +fi + +render_homeserver + +# Ownership foot-gun: wrong uid prevents boot (synapse 991, postgres 70). +sudo chown -R "${SYNAPSE_UID}:${SYNAPSE_UID}" "${SCRIPT_DIR}/synapse-data" +sudo chown -R "${POSTGRES_UID}:${POSTGRES_UID}" "${SCRIPT_DIR}/postgres-data" + +docker compose --env-file "${ENV_FILE}" -f "${SCRIPT_DIR}/docker-compose.yml" up -d + +echo "Matrix stack up:" +echo " Synapse http://127.0.0.1:${MATRIX_SYNAPSE_PORT:-8008}" +echo " Element http://127.0.0.1:${MATRIX_ELEMENT_PORT:-8081}" +echo " server_name ${MATRIX_SERVER_NAME}" + +cat <<'EOF' +Next: register users (hermes bot, and any humans) via the shared-secret CLI: + docker exec -u 991 matrix-synapse register_new_matrix_user -c /data/homeserver.yaml -u NAME -p PASS [--no-admin] +EOF \ No newline at end of file diff --git a/infra/matrix-server/docker-compose.yml b/infra/matrix-server/docker-compose.yml new file mode 100644 index 0000000..0629295 --- /dev/null +++ b/infra/matrix-server/docker-compose.yml @@ -0,0 +1,49 @@ +services: + synapse: + image: matrixdotorg/synapse:latest + container_name: ${MATRIX_SYNAPSE_CONTAINER_NAME:-matrix-synapse} + restart: unless-stopped + depends_on: + - postgres + environment: + SYNAPSE_SERVER_NAME: ${MATRIX_SERVER_NAME:-matrix.lab2025.duckdns.org} + SYNAPSE_REPORT_STATS: "no" + # federation disabled (user choice): client-only listener on 8008 + ports: + - "${MATRIX_SYNAPSE_PORT:-8008}:8008" + volumes: + - ./synapse-data:/data + networks: + - matrix + + postgres: + image: postgres:16-alpine + container_name: ${MATRIX_POSTGRES_CONTAINER_NAME:-matrix-postgres} + restart: unless-stopped + environment: + POSTGRES_DB: ${MATRIX_POSTGRES_DB:-synapse} + POSTGRES_USER: ${MATRIX_POSTGRES_USER:-synapse} + POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD} + POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=C" + volumes: + - ./postgres-data:/var/lib/postgresql/data + networks: + - matrix + + element: + image: vectorim/element-web:latest + container_name: ${MATRIX_ELEMENT_CONTAINER_NAME:-matrix-element} + restart: unless-stopped + depends_on: + - synapse + ports: + - "${MATRIX_ELEMENT_PORT:-8081}:80" + volumes: + # Must mount the FILE, not a directory (element-web reads /app/config.json) + - ./element-config/config.json:/app/config.json + networks: + - matrix + +networks: + matrix: + driver: bridge \ No newline at end of file diff --git a/infra/matrix-server/element-config/config.json b/infra/matrix-server/element-config/config.json new file mode 100644 index 0000000..3f59d10 --- /dev/null +++ b/infra/matrix-server/element-config/config.json @@ -0,0 +1,21 @@ +{ + "default_server_name": "matrix.lab2025.duckdns.org", + "default_server_config": { + "m.homeserver": { + "base_url": "https://matrix.lab2025.duckdns.org", + "server_name": "matrix.lab2025.duckdns.org" + } + }, + "disable_custom_urls": false, + "disable_guests": true, + "disable_login_language_selector": false, + "disable_3pid_login": false, + "force_verification": false, + "brand": "Matrix", + "default_federate": false, + "show_labs_settings": true, + "default_theme": "dark", + "room_directory": { + "servers": ["matrix.lab2025.duckdns.org"] + } +} \ No newline at end of file diff --git a/infra/matrix-server/homeserver.yaml.tpl b/infra/matrix-server/homeserver.yaml.tpl new file mode 100644 index 0000000..587f7e9 --- /dev/null +++ b/infra/matrix-server/homeserver.yaml.tpl @@ -0,0 +1,43 @@ +# Configuration file for Synapse. +# This is generated by Jeannie from infra/matrix-server/homeserver.yaml.tpl. +# Secrets (registration_shared_secret, macaroon_secret_key, form_secret, postgres +# password, signing key) are injected by the deploy_matrix() bootstrap — never store +# them in this template. + +server_name: "${MATRIX_SERVER_NAME}" +pid_file: /data/homeserver.pid +listeners: + - port: 8008 + resources: + - compress: false + names: + - client + - federation + tls: false + type: http + x_forwarded: true +database: + name: psycopg2 + args: + user: "${MATRIX_POSTGRES_USER}" + password: "${MATRIX_POSTGRES_PASSWORD}" + database: "${MATRIX_POSTGRES_DB}" + host: postgres + port: 5432 + cp_min: 5 + cp_max: 10 +log_config: "/data/${MATRIX_SERVER_NAME}.log.config" +media_store_path: /data/media_store +registration_shared_secret: "${MATRIX_REGISTRATION_SHARED_SECRET}" +report_stats: false +macaroon_secret_key: "${MATRIX_MACAROON_SECRET_KEY}" +form_secret: "${MATRIX_FORM_SECRET}" +signing_key_path: "/data/${MATRIX_SERVER_NAME}.signing.key" +trusted_key_servers: + - server_name: "matrix.org" + +# vim:ft=yaml +user_directory: + enabled: true + search_all_users: true + prefer_local_users: true \ No newline at end of file diff --git a/infra/matrix-server/log_config.yaml b/infra/matrix-server/log_config.yaml new file mode 100644 index 0000000..a92d8bd --- /dev/null +++ b/infra/matrix-server/log_config.yaml @@ -0,0 +1,25 @@ +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + console: + class: logging.StreamHandler + formatter: precise + +loggers: + _placeholder: + level: "INFO" + + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + +root: + level: INFO + handlers: [console] + +disable_existing_loggers: false \ No newline at end of file diff --git a/infra/matrix-server/matrix.secret.yaml b/infra/matrix-server/matrix.secret.yaml new file mode 100644 index 0000000..4932c04 --- /dev/null +++ b/infra/matrix-server/matrix.secret.yaml @@ -0,0 +1,21 @@ +# Matrix homeserver secrets - SOPS-encrypted. Do not commit plaintext. +# Decrypted at deploy time by jeannie deploy_matrix. +MATRIX_POSTGRES_PASSWORD: ENC[AES256_GCM,data:HYti676SrsKpr5UuYX7wrg==,iv:pggYfnjqZAGulrCNVBJ/vcICcsn9hic150hmRb1D5uE=,tag:rs4jSLwMZeoaUgjqCYIkTA==,type:str] +MATRIX_REGISTRATION_SHARED_SECRET: ENC[AES256_GCM,data:+OChmnoaVrWhLCQHPe3sSbfuEs2H6nJuPhFPelRM+hOPCADXYZPiUPxH3RJfLY9niCg=,iv:b2wVg4Xg/qTTT7ghyNJJNOfnafESE7EZAZoVUKFJxJc=,tag:ewdDXpFoeb2/+HlLzYJySw==,type:str] +MATRIX_MACAROON_SECRET_KEY: ENC[AES256_GCM,data:0IY3J7ijJhYiqd10A2D/xo0Utok0llFFjUOOqY57QjnCA8tgeJOH4lNuiPTScKPMnnI=,iv:PkqyW3RaQpA8+TQtKI6ibo11CJVUiod/OO2QLBa4Lrs=,tag:Z8j5xqlcysgkrmbmiMVNEA==,type:str] +MATRIX_FORM_SECRET: ENC[AES256_GCM,data:WfaToWF7NO5VgpeJcuONlhHK+GopNjPnVAjFLorrronMNUzTKIICr2xjrVBq3wvPuf4=,iv:Qgf7fdO9boSV136LL7/IxNcYz33gNP7YbIQohJFEAAI=,tag:FXtUqNj/JtsrWLx/ntypag==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1THJOdkhOb21UeFk2NHdx + bUNSMTZUVDlOeUh1T1N4WnA5akpVVWVEVFRNCk02RG9kNENCVHJUSVVXNkE0ZUFh + ek8vU2NzRUwvMm1EMDNwdnc1YjhzZ2cKLS0tIFpmZU5Pb0FJRTl1ZktVVXA1RTAw + MjBHcmZQTFJqZUdHWVgxdURhQk5wRW8Ka0TXyBnr7Puq+dYVizMK+HIAEncAD2bl + 5JGP5TU1Dp0I9njHJYmDAicx5iaMhfwQe4iL9/73FOy1vnfYPlv9gQ== + -----END AGE ENCRYPTED FILE----- + recipient: age1ywayyaqwksdzedhf3w8xlsf89lnzw89c5yxtwjp42pnc5cds4dds5j6yxm + encrypted_regex: .* + lastmodified: "2026-09-16T16:32:24Z" + mac: ENC[AES256_GCM,data:lyhzE48mseEWnf2+UoYy4lkBPFtvD3PuB4BR/LbTEkz4xrC0KYHqlVma5dEhLoOTs+k5rhFy3ko3lZoZ396kkrOMftd9zyYTK7Nk/ZxPRrg3gPqr/Dd4bTCGDstznNMgbsCgzklhB35IYWgP/PL5ccZiP/OnqWXoTMPRYy/EPC8=,iv:FVhAK3zGSROkI0iyhRo1+PMlXLbZQ+74A4pnlSR6Y80=,tag:x3/mNU6l8PqPBb0cRbPAuQ==,type:str] + version: 3.13.3 diff --git a/infra/omniroute/.env.example b/infra/omniroute/.env.example new file mode 100644 index 0000000..5e4b0ad --- /dev/null +++ b/infra/omniroute/.env.example @@ -0,0 +1,7 @@ +# OmniRoute — non-secret defaults. +# Copy to a sibling .env or export when running deploy_omniroute. +# Secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_*) are auto-generated +# by OmniRoute into its data volume (server.env) on first boot — not stored here. +OMNIROUTE_IMAGE=diegosouzapw/omniroute:latest +OMNIROUTE_CONTAINER_NAME=omniroute +OMNIROUTE_PORT=20128 \ No newline at end of file diff --git a/infra/omniroute/bootstrap.sh b/infra/omniroute/bootstrap.sh new file mode 100644 index 0000000..c920c6f --- /dev/null +++ b/infra/omniroute/bootstrap.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Deploy/update the OmniRoute LLM-routing gateway that Hermes uses as its model +# provider (http://localhost:20128/v1). Idempotent. Secrets live in the data +# volume (server.env) generated by OmniRoute on first boot. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${OMNIROUTE_ENV_FILE:-${SCRIPT_DIR}/.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Missing env file ${ENV_FILE}. Run jeannie deploy-omniroute first." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +. "${ENV_FILE}" +set +a + +sudo mkdir -p "${SCRIPT_DIR}" +docker compose --env-file "${ENV_FILE}" -f "${SCRIPT_DIR}/docker-compose.yml" up -d + +echo "OmniRoute up at http://127.0.0.1:${OMNIROUTE_PORT:-20128}" \ No newline at end of file diff --git a/infra/omniroute/docker-compose.yml b/infra/omniroute/docker-compose.yml new file mode 100644 index 0000000..0a43d5a --- /dev/null +++ b/infra/omniroute/docker-compose.yml @@ -0,0 +1,13 @@ +services: + omniroute: + image: ${OMNIROUTE_IMAGE:-diegosouzapw/omniroute:latest} + container_name: ${OMNIROUTE_CONTAINER_NAME:-omniroute} + restart: unless-stopped + stop_timeout: 40 + ports: + - "${OMNIROUTE_PORT:-20128}:20128" + volumes: + - omniroute-data:/app/data + +volumes: + omniroute-data: \ No newline at end of file diff --git a/jeannie b/jeannie index 04ca5aa..d4bd8ec 100755 --- a/jeannie +++ b/jeannie @@ -4117,6 +4117,233 @@ ENV_EOT echo "Heimdall is reachable at http://127.0.0.1:${http_port}/ and ${public_url%/}/." } +deploy_matrix() { + local mode="${LAB_MATRIX_DEPLOY:-true}" + local install_dir="${LAB_MATRIX_INSTALL_DIR:-/home/jv/matrix-server}" + local image="" + local container_name="" + local http_port="" + local ssh_port="" + local domain="" + local install_docker="false" + local source_dir="${REPO_ROOT}/infra/matrix-server" + local value_name + local value + + require_debian_server "deploy-matrix" + + if disabled_value "${mode}"; then + return 0 + fi + + for value_name in install_dir; do + value="${!value_name}" + if [[ "${value}" == *$'\n'* ]]; then + echo "${value_name} cannot contain a newline." >&2 + exit 1 + fi + done + + if [[ ! -s "${source_dir}/docker-compose.yml" || ! -s "${source_dir}/homeserver.yaml.tpl" || ! -s "${source_dir}/bootstrap.sh" ]]; then + echo "Missing Matrix source files in ${source_dir}" >&2 + exit 1 + fi + + echo "Deploying Matrix stack on the Debian host at ${install_dir}..." + + ensure_local_docker_compose "MATRIX" "${install_docker}" + repair_local_docker_iptables + + sudo mkdir -p "${install_dir}" + sudo cp "${source_dir}/docker-compose.yml" "${install_dir}/docker-compose.yml" + sudo cp "${source_dir}/homeserver.yaml.tpl" "${install_dir}/homeserver.yaml.tpl" + sudo cp "${source_dir}/log_config.yaml" "${install_dir}/log_config.yaml" + sudo cp "${source_dir}/element-config/config.json" "${install_dir}/element-config/config.json" 2>/dev/null || { + sudo mkdir -p "${install_dir}/element-config" + sudo cp "${source_dir}/element-config/config.json" "${install_dir}/element-config/config.json" + } + sudo cp "${source_dir}/bootstrap.sh" "${install_dir}/bootstrap.sh" + sudo chmod +x "${install_dir}/bootstrap.sh" + + # Secrets come from the SOPS-encrypted repo file, decrypted at deploy time. + # (Env vars override the file, for manual/ops overrides.) + if ! sops_load_secrets "${REPO_ROOT}/infra/matrix-server/matrix.secret.yaml"; then + exit 1 + fi + + if [[ -z "${MATRIX_POSTGRES_PASSWORD:-}" || -z "${MATRIX_REGISTRATION_SHARED_SECRET:-}" ]]; then + echo "MATRIX_POSTGRES_PASSWORD and MATRIX_REGISTRATION_SHARED_SECRET missing after decrypt." >&2 + exit 1 + fi + + local matrix_server_name="${LAB_MATRIX_SERVER_NAME:-matrix.lab2025.duckdns.org}" + local matrix_synapse_port="${LAB_MATRIX_SYNAPSE_PORT:-8008}" + local matrix_element_port="${LAB_MATRIX_ELEMENT_PORT:-8081}" + local postgres_db="${LAB_MATRIX_POSTGRES_DB:-synapse}" + local postgres_user="${LAB_MATRIX_POSTGRES_USER:-synapse}" + + sudo tee "${install_dir}/.env" >/dev/null <&2 + exit 1 + fi + + echo "Deploying OmniRoute LLM gateway on the Debian host at ${install_dir}..." + + ensure_local_docker_compose "OMNIROUTE" "${install_docker}" + repair_local_docker_iptables + + sudo mkdir -p "${install_dir}" + sudo cp "${source_dir}/docker-compose.yml" "${install_dir}/docker-compose.yml" + sudo cp "${source_dir}/bootstrap.sh" "${install_dir}/bootstrap.sh" + sudo chmod +x "${install_dir}/bootstrap.sh" + + sudo tee "${install_dir}/.env" >/dev/null <&2 + exit 1 + fi + done + + echo "Deploying Hermes agent on the Debian host at ${install_dir}..." + + ensure_local_docker_compose "HERMES" "${install_docker}" + + # Install uv if missing. + if [[ ! -x "${uv_bin}" ]]; then + mkdir -p "$(dirname "${uv_bin}")" + echo "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | bash + # uv installs to ~/.local/bin; copy/alias into HERMES bin if that path differs. + if command -v uv >/dev/null 2>&1 && [[ ! -x "${uv_bin}" ]]; then + cp "$(command -v uv)" "${uv_bin}" + fi + fi + + # Install Hermes via its official installer (git method, managed venv layout). + if [[ ! -x "${hermes_home}/hermes-agent/venv/bin/python" ]]; then + echo "Installing Hermes agent..." + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash + fi + + # Ensure the Matrix platform dependencies (E2EE) are present in the venv. + if [[ -x "${uv_bin}" && -f "${install_dir}/pyproject.toml" ]]; then + cd "${install_dir}" + "${uv_bin}" pip install 'mautrix[encryption]' -p venv/bin/python + fi + + # Secret env for the gateway (.env under HERMES_HOME, mode 600, never committed). + # Pull the hermes secrets from the SOPS-encrypted repo file; env vars override. + if ! sops_load_secrets "${REPO_ROOT}/infra/hermes.secret.yaml"; then + echo "WARNING: could not load hermes secrets; MATRIX_PASSWORD may be blank." >&2 + fi + + local secrets_file="${hermes_home}/.env" + sudo install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "${hermes_home}" + if [[ -f "${secrets_file}" ]]; then + sudo cp "${secrets_file}" "${secrets_file}.bak-pre-jeannie" + fi + sudo tee "${secrets_file}" >/dev/null </dev/null 2>&1 && docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^matrix-synapse$'; then + if [[ -n "${MATRIX_BOT_PASSWORD:-}" ]]; then + if ! docker exec matrix-synapse register_new_matrix_user -c /data/homeserver.yaml \ + -u hermes -p "${MATRIX_BOT_PASSWORD}" --no-admin >/dev/null 2>&1; then + echo "Hermes bot account already exists (expected on re-run) or auto-registration skipped." >&2 + else + echo "Hermes Matrix bot account ready." + fi + else + echo "MATRIX_BOT_PASSWORD not set; skipping Hermes bot account registration (needed only on fresh install)." >&2 + fi + fi + + # Install/start the gateway systemd user service (Hermes manages the unit). + "${hermes_home}/hermes-agent/venv/bin/python" "${hermes_home}/hermes-agent/hermes" gateway install + systemctl --user daemon-reload + systemctl --user enable --now hermes-gateway 2>/dev/null || true + + echo "Hermes agent deployed. Run 'hermes' to configure the model, or check gateway logs at ${hermes_home}/logs/gateway.log." +} + deploy_rpi_services() { local mode="${LAB_RPI_SERVICES_DEPLOY:-true}" local rpi_host="${LAB_RPI_HOST:-${LAB_RASPBERRY_HOST:-192.168.100.89}}" @@ -5609,6 +5836,22 @@ spec = { "title": "Heimdall deploy", "command": [jeannie, "__up-task", "heimdall-deploy"], }, + { + "id": "matrix-deploy", + "title": "Matrix deploy", + "command": [jeannie, "__up-task", "matrix-deploy"], + }, + { + "id": "omniroute-deploy", + "title": "OmniRoute deploy", + "command": [jeannie, "__up-task", "omniroute-deploy"], + }, + { + "id": "hermes-deploy", + "title": "Hermes deploy", + "needs": ["omniroute-deploy", "matrix-deploy"], + "command": [jeannie, "__up-task", "hermes-deploy"], + }, { "id": "rpi-services", "title": "RPi services", @@ -5656,6 +5899,15 @@ up_task() { heimdall-deploy) deploy_heimdall ;; + matrix-deploy) + deploy_matrix + ;; + omniroute-deploy) + deploy_omniroute + ;; + hermes-deploy) + deploy_hermes + ;; rpi-services) deploy_rpi_services ;; @@ -7579,9 +7831,6 @@ ensure_sops_age_tools() { if ! command -v age-keygen >/dev/null 2>&1; then missing_packages+=(age) fi - if ! command -v sops >/dev/null 2>&1; then - missing_packages+=(sops) - fi if ((${#missing_packages[@]} > 0)); then echo "Installing missing secret-management tools: ${missing_packages[*]}" @@ -7589,10 +7838,26 @@ ensure_sops_age_tools() { sudo apt-get install -y --no-install-recommends "${missing_packages[@]}" fi + # age must be present; it's small and apt-provided on Debian. if ! command -v age-keygen >/dev/null 2>&1; then echo "age-keygen is still unavailable after package installation." >&2 exit 1 fi + + # sops: not packaged on Debian trixie — fall back to the GitHub release binary. + if ! command -v sops >/dev/null 2>&1; then + echo "sops not found in the apt repos; installing from GitHub release..." + local sops_ver sops_url + sops_ver="$(curl -fsSL https://api.github.com/repos/getsops/sops/releases/latest | grep -oE '"tag_name":\s*"v[^"]+"' | head -1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')" + if [[ -z "${sops_ver}" ]]; then + echo "Could not determine the latest SOPS release version." >&2 + exit 1 + fi + sops_url="https://github.com/getsops/sops/releases/download/${sops_ver}/sops-v${sops_ver#v}.linux.amd64" + curl -fsSL -o "/tmp/sops-${sops_ver}" "${sops_url}" && \ + sudo install -m 0755 "/tmp/sops-${sops_ver}" /usr/local/bin/sops && \ + rm -f "/tmp/sops-${sops_ver}" + fi if ! command -v sops >/dev/null 2>&1; then echo "sops is still unavailable after package installation." >&2 exit 1 @@ -7609,6 +7874,56 @@ sops_age_recipient() { awk -F': ' '/^# public key:/ { print $2; exit }' "${key_file}" } +# Decrypt a SOPS-encrypted YAML of `KEY: value` pairs into the current env. +# Usage: sops_load_secrets +# Requires the age key (sops_age_key_file) to decrypt; fails loudly if missing. +sops_load_secrets() { + local file="${1:?sops_load_secrets: missing file}" + local key_file + local out + + if [[ ! -f "${file}" ]]; then + echo "Missing encrypted secret file: ${file} (run ./jeannie secrets-init then sops -e)." >&2 + return 1 + fi + if ! sops_available; then + echo "sops not installed. Run ./jeannie secrets-init first." >&2 + return 1 + fi + + key_file="$(sops_age_key_file)" + if [[ ! -s "${key_file}" ]]; then + echo "Missing SOPS age key at ${key_file}. Run ./jeannie secrets-init, or copy the age key to this machine." >&2 + return 1 + fi + + out="$(SOPS_AGE_KEY_FILE="${key_file}" sops --decrypt "${file}")" || { + echo "SOPS decryption failed for ${file}." >&2 + return 1 + } + + # Export every bare `KEY: value` line (ignore comments/blank). + local line key val syn="0" + while IFS= read -r line; do + [[ "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*: ]] || continue + key="${line%%:*}" + val="${line#*: }" + # Strip surrounding double quotes, if sops kept them. + val="${val%\"}"; val="${val#\"}" + export "${key}=${val}" + syn="$((syn + 1))" + done <<<"${out}" + + if ((syn == 0)); then + echo "No KEY: value pairs found in decrypted ${file}." >&2 + return 1 + fi +} + +sops_available() { + command -v sops >/dev/null 2>&1 +} + secrets_init() { local key_file local key_dir @@ -8108,6 +8423,9 @@ Build And Bootstrap up Deploy the full homelab pipeline. deploy-gitea Deploy the Debian-hosted Gitea container. deploy-heimdall Deploy the Debian-hosted Heimdall dashboard. + deploy-matrix Deploy the Matrix homeserver (Synapse + Element + Postgres). + deploy-omniroute Deploy the OmniRoute LLM routing gateway. + deploy-hermes Deploy the Hermes agent (git-install + venv + gateway service). bootstrap-gitea-repo Ensure the Gitea repo and SSH key wiring exist. rpi-services Deploy Pi-hole, Unbound, and Uptime Kuma on RPi4. ollama-setup Install/configure Ollama on the Debian host. @@ -8395,6 +8713,18 @@ case "${1:-}" in record_change_journal "deploy-heimdall" "$@" deploy_heimdall ;; + deploy-matrix) + record_change_journal "deploy-matrix" "$@" + deploy_matrix + ;; + deploy-omniroute) + record_change_journal "deploy-omniroute" "$@" + deploy_omniroute + ;; + deploy-hermes) + record_change_journal "deploy-hermes" "$@" + deploy_hermes + ;; rpi-services) record_change_journal "rpi-services" "$@" deploy_rpi_services