Gives the Hermes agent a real directory of markdown for the luna_wiki
vault, at /var/lib/livesync-bridge/vault and mounted into her container at
/opt/data/vault (inside HERMES_WRITE_SAFE_ROOT, so she can write, not only
read). Obsidian itself is an Electron GUI with no headless mode, and an
agent wants files rather than an app.
livesync-bridge is Deno, not packaged, and publishes no image — upstream
ships only a `build: .` compose file. So it comes in as a pinned non-flake
input and runs under systemd. Two things that are not obvious:
- The source is COPIED to a fixed path rather than run from /nix/store.
Deno keys localStorage — where the bridge records per-file sync state —
by the main module's origin. Verified by running one source tree from
two paths against a single DENO_DIR: two origin directories appear. Run
from the store, every input bump would silently reset both peers to a
full rescan.
- It runs as uid 986/gid 983, the same identity the hermes container
uses. Two uids in a shared group only works while every file stays
group-writable, and one 0644 file dropped by the agent would stall sync
on that path.
Talks to CouchDB over the tailnet (jupiter.orbit.sol:5984), so neptun's
vhost, its TLS and its path allowlist are all out of the picture.
Verified before deploying: `deno check` passes on nixpkgs' 2.8.3 (upstream
pins 2.6.9), and the bridge starts, reads LSB_CONFIG, detects a file and
writes its health heartbeat. Both directions confirmed working on mars
afterwards.
Credentials are currently the `obsidian` admin account and the personal
vault's passphrase, which means mars can decrypt every vault database and
not just luna's. Deliberate reuse of what existed; hosts/mars/secrets.nix
records the two independent ways to narrow it.
⚠️ Upstream has three open, unanswered issues on the storage->couchdb
direction (#50, #23, #46) and all fail silently — the log reports the
upload and the database is never updated. Do not treat this directory as
durable storage for anything luna cannot regenerate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLN5nkLBtCciD3ZnUwtw2b
101 lines
5.3 KiB
Nix
101 lines
5.3 KiB
Nix
{ config, ... }:
|
|
|
|
# sops-nix wiring for mars. Encrypted values in ../../secrets/mars.yaml,
|
|
# decrypted with mars's own SSH host key (recipient in ../../.sops.yaml).
|
|
# The host key is pre-generated on the laptop and shipped at install
|
|
# (nixos-anywhere --extra-files -> /etc/ssh/ssh_host_ed25519_key).
|
|
{
|
|
sops.defaultSopsFile = ../../secrets/mars.yaml;
|
|
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
|
|
|
sops.secrets.darman_password.neededForUsers = true;
|
|
users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path;
|
|
|
|
sops.secrets.tailscale_authkey = { };
|
|
|
|
# Credentials file for the //jupiter/data cifs mount (see configuration.nix).
|
|
# Same value as jupiter's own samba_password (services/network/samba.nix) —
|
|
# mars authenticates as the same smb user, mirroring terra's setup.
|
|
sops.secrets.samba_password = { };
|
|
sops.templates."jupiter-smb.credentials".content = ''
|
|
username=darman
|
|
password=${config.sops.placeholder.samba_password}
|
|
'';
|
|
|
|
# Hermes Agent (hermes-agent.nix) — moved here from jupiter (see that
|
|
# host's git history); same Telegram bot token, opencode key, and
|
|
# Authentik OIDC client secret, so no new bot/app to provision.
|
|
sops.secrets.opencode_go_api_key = { };
|
|
sops.secrets.telegram_bot_token = { };
|
|
sops.secrets.hermes_dashboard_oidc_client_secret = { };
|
|
# Same value as in secrets/jupiter.yaml (the sending side), stored WITHOUT a
|
|
# trailing newline — a stray newline would change the key the HMAC is
|
|
# computed with and fail every delivery. `scripts/edit_secrets` writes a
|
|
# bare value. hermes-agent.nix trims one anyway, belt and braces.
|
|
#
|
|
# This is NOT in the container's env any more. It used to be, because
|
|
# hermes-agent-webhook-route ran `hermes webhook subscribe` inside the
|
|
# container and read the secret back out of its environment — which meant
|
|
# podman-hermes-agent had to be restarted first on rotation, or the
|
|
# subscription silently pinned the stale value. The route config is now
|
|
# written host-side (hermes-agent-webhook-routes reads this file directly),
|
|
# so that ordering constraint is gone and the secret no longer sits in an
|
|
# env var luna can read with `env`.
|
|
sops.secrets.gitea_hermes_webhook_secret = {
|
|
restartUnits = [ "hermes-agent-webhook-routes.service" ];
|
|
};
|
|
sops.templates."hermes-agent.env".content = ''
|
|
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
|
|
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
|
|
TELEGRAM_HOME_CHANNEL=15151223
|
|
TELEGRAM_ALLOWED_USERS=15151223
|
|
WEBHOOK_ENABLED=true
|
|
WEBHOOK_PORT=8644
|
|
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
|
|
'';
|
|
|
|
# luna's own gitea push token (services/dev/gitea.nix provisions the
|
|
# account + PR-tier repo access on jupiter; this is the per-user token
|
|
# generated once via `gitea admin user generate-access-token --username
|
|
# luna --scopes write:repository,read:user` on jupiter — read:user is
|
|
# required, `tea logins add` fails without it). Read directly by
|
|
# hermes-agent.nix's prepare-dirs oneshot (default root:root owner is
|
|
# fine — that oneshot already runs as root) to set up a git
|
|
# credential-store file and a `tea` login, both written into hermesHome
|
|
# so they're visible inside the container at /opt/data/....
|
|
# restartUnits re-provisions both on rotation, without a full mars deploy.
|
|
sops.secrets.gitea_luna_token.restartUnits = [ "hermes-agent-prepare-dirs.service" ];
|
|
|
|
# livesync-bridge (livesync-bridge.nix) — luna's Obsidian vault, mirrored
|
|
# out of CouchDB on jupiter. Both values are consumed by the rendered
|
|
# config.json rather than read directly, so the sops default of root:root
|
|
# 0400 is correct here; only the TEMPLATE needs an owner (set where it is
|
|
# defined, next to the vault path it references).
|
|
#
|
|
# couchdb_luna_password holds jupiter's `obsidian` ADMIN password — the same
|
|
# value as secrets/jupiter.yaml's couchdb_admin_password — and
|
|
# obsidian_luna_passphrase is the same passphrase as the personal vault.
|
|
# That is a deliberate choice to reuse what already existed, but it is worth
|
|
# being clear about what it costs: mars can decrypt and read EVERY vault
|
|
# database, not just luna's, and mars is the box running an autonomous
|
|
# agent. The two are independent to fix, cheapest first:
|
|
#
|
|
# 1. A vault-specific passphrase (re-encrypts luna's remote database, but
|
|
# leaves the personal vault's contents unreadable from here).
|
|
# 2. A CouchDB account scoped to luna's database via _security (three curl
|
|
# calls, in README -> "Obsidian vaults"), which also stops mars from
|
|
# reaching the other databases at all.
|
|
#
|
|
# Neither is required for the bridge to work; both shrink the blast radius
|
|
# if mars is ever compromised.
|
|
sops.secrets.couchdb_luna_password = { };
|
|
|
|
# The E2EE passphrase for luna's vault, as entered in the Obsidian plugin.
|
|
# Vault passphrases otherwise never leave the clients (see the note in
|
|
# services/dev/obsidian-livesync.nix) — this one has to be here because mars
|
|
# IS a client: it decrypts in order to write real markdown to disk. Path
|
|
# obfuscation uses the same passphrase in the plugin, so the bridge's
|
|
# separate obfuscatePassphrase field is fed from this one value.
|
|
sops.secrets.obsidian_luna_passphrase = { };
|
|
}
|