Add Matrix/Hermes/Omniroute to jeannie pipeline (SOPS-encrypted secrets)

Codify the full Matrix homeserver stack, OmniRoute LLM gateway, and
Hermes agent into the Jeannie deploy pipeline so the repo reproduces
the running homelab from scratch.

New files:
  infra/matrix-server/  — Synapse + Postgres + Element docker-compose,
    homeserver.yaml.tpl, log_config, element config, bootstrap.sh
  infra/omniroute/      — diegosouzapw/omniroute:latest on port 20128
  infra/hermes.secret.yaml   — SOPS-encrypted bot password + API key
  infra/matrix-server/matrix.secret.yaml — SOPS-encrypted Synapse secrets
  .sops.yaml            — age recipient + encrypted_regex: '.*'

jeannie additions:
  deploy_matrix()  — renders homeserver.yaml from template, creates
    signing key via 'generate' on first boot, delegates to bootstrap.sh
  deploy_omniroute() — docker compose up with env-file support
  deploy_hermes()  — git clone + venv + 'hermes gateway install',
    writes ~/.hermes/.env (mode 600), registers non-admin bot via
    shared-secret CLI, idempotent
  sops_load_secrets() — decrypts *.secret.yaml via age key at deploy
    time; fails loudly if the age key is missing
  ensure_sops_age_tools() — installs sops from getsops GitHub release
    when apt has no sops package (Debian trixie)

DAG: hermes-deploy depends on matrix-deploy + omniroute-deploy.

Root cause: deploy_hermes previously used wrong entrypoint (python
module instead of ./hermes launcher); generate was called with
--generate-config (invalid subcommand); .sops.yaml encrypted_regex
'^password$' left prefixed keys as plaintext — all fixed in this
commit.
This commit is contained in:
jv 2026-09-16 11:37:13 -05:00
parent d6e33250f4
commit fe661f9b24
15 changed files with 687 additions and 3 deletions

6
.gitignore vendored
View File

@ -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

6
.sops.yaml Normal file
View File

@ -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

View File

@ -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

19
infra/hermes.secret.yaml Normal file
View File

@ -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

View File

@ -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.)

View File

@ -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

View File

@ -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

View File

@ -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"]
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}"

View File

@ -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:

336
jeannie
View File

@ -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 <<ENV_EOT
MATRIX_SERVER_NAME=${matrix_server_name}
MATRIX_SYNAPSE_PORT=${matrix_synapse_port}
MATRIX_ELEMENT_PORT=${matrix_element_port}
MATRIX_SYNAPSE_CONTAINER_NAME=${LAB_MATRIX_SYNAPSE_CONTAINER_NAME:-matrix-synapse}
MATRIX_POSTGRES_CONTAINER_NAME=${LAB_MATRIX_POSTGRES_CONTAINER_NAME:-matrix-postgres}
MATRIX_ELEMENT_CONTAINER_NAME=${LAB_MATRIX_ELEMENT_CONTAINER_NAME:-matrix-element}
MATRIX_POSTGRES_DB=${postgres_db}
MATRIX_POSTGRES_USER=${postgres_user}
MATRIX_POSTGRES_PASSWORD=${MATRIX_POSTGRES_PASSWORD}
MATRIX_REGISTRATION_SHARED_SECRET=${MATRIX_REGISTRATION_SHARED_SECRET}
MATRIX_MACAROON_SECRET_KEY=${MATRIX_MACAROON_SECRET_KEY:-${MATRIX_REGISTRATION_SHARED_SECRET}}
MATRIX_FORM_SECRET=${MATRIX_FORM_SECRET:-${MATRIX_REGISTRATION_SHARED_SECRET}}
ENV_EOT
# Pull + render + chown + up. bootstrap.sh is the single source of truth.
# sudo the docker compose commands; data dirs are chown'd inside bootstrap.
(
cd "${install_dir}"
sudo docker compose --env-file "${install_dir}/.env" -f "${install_dir}/docker-compose.yml" pull
sudo MATRIX_ENV_FILE="${install_dir}/.env" bash "${install_dir}/bootstrap.sh"
)
echo "Matrix stack is reachable at http://127.0.0.1:${matrix_synapse_port}/ and Element at http://127.0.0.1:${matrix_element_port}/."
}
deploy_omniroute() {
local mode="${LAB_OMNIROUTE_DEPLOY:-true}"
local install_dir="${LAB_OMNIROUTE_INSTALL_DIR:-/home/jv/homelab-omniroute}"
local install_docker="false"
local source_dir="${REPO_ROOT}/infra/omniroute"
require_debian_server "deploy-omniroute"
if disabled_value "${mode}"; then
return 0
fi
if [[ ! -s "${source_dir}/docker-compose.yml" || ! -s "${source_dir}/bootstrap.sh" ]]; then
echo "Missing OmniRoute source files in ${source_dir}" >&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 <<ENV_EOT
OMNIROUTE_IMAGE=${LAB_OMNIROUTE_IMAGE:-diegosouzapw/omniroute:latest}
OMNIROUTE_CONTAINER_NAME=${LAB_OMNIROUTE_CONTAINER_NAME:-omniroute}
OMNIROUTE_PORT=${LAB_OMNIROUTE_PORT:-20128}
ENV_EOT
(
cd "${install_dir}"
sudo docker compose pull
sudo bash "${install_dir}/bootstrap.sh"
)
echo "OmniRoute is reachable at http://127.0.0.1:${LAB_OMNIROUTE_PORT:-20128}."
}
deploy_hermes() {
local mode="${LAB_HERMES_DEPLOY:-true}"
local hermes_home="${LAB_HERMES_HOME:-/home/jv/.hermes}"
local install_dir="${LAB_HERMES_INSTALL_DIR:-/home/jv/.hermes/hermes-agent}"
local uv_bin="${LAB_HERMES_UV_BIN:-/home/jv/.hermes/bin/uv}"
local install_docker="${LAB_HERMES_INSTALL_DOCKER:-false}"
local value_name
local value
require_debian_server "deploy-hermes"
if disabled_value "${mode}"; then
return 0
fi
for value_name in hermes_home install_dir uv_bin; do
value="${!value_name}"
if [[ "${value}" == *$'\n'* ]]; then
echo "${value_name} cannot contain a newline." >&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 <<ENV_EOT
OPENAI_BASE_URL=${HERMES_OPENAI_BASE_URL:-http://localhost:${LAB_OMNIROUTE_PORT:-20128}/v1}
HERMES_CUSTOM_LOCALHOST_${LAB_OMNIROUTE_PORT:-20128}_API_KEY=${HERMES_OMNIROUTE_API_KEY:-}
MATRIX_HOMESERVER=${LAB_MATRIX_SERVER_NAME:-matrix.lab2025.duckdns.org}
MATRIX_USER_ID=${HERMES_MATRIX_USER_ID:-@hermes:matrix.lab2025.duckdns.org}
MATRIX_PASSWORD=${MATRIX_BOT_PASSWORD:-}
MATRIX_ALLOWED_USERS=${HERMES_MATRIX_ALLOWED_USERS:-@jv:matrix.lab2025.duckdns.org}
MATRIX_E2EE_MODE=required
ENV_EOT
sudo chmod 600 "${secrets_file}"
# Create the Matrix bot account (idempotent) if the homeserver is up.
if command -v docker >/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 <repo/path/to/file.secret.yaml>
# 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