From 15ae1cf608efc2172bf67efe44af4229a2b80fa2 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 25 Aug 2026 23:25:37 +0200 Subject: [PATCH 1/2] obsidian: self-hosted vault sync via CouchDB on jupiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds services/dev/obsidian-livesync.nix — CouchDB 3 from the native nixpkgs module, tuned as the backend for the Self-hosted LiveSync plugin — and publishes it as notes.mgaction.town through neptun. It goes out over the public reverse proxy rather than staying on the LAN because Obsidian's mobile apps refuse cleartext HTTP and *.jupiter.sol cannot hold a publicly trusted cert. That makes the hardening load-bearing rather than decorative: - require_valid_user in both [chttpd] and [chttpd_auth], so nothing answers unauthenticated on the open internet; - neptun's vhost matches on CouchDB's own naming rule (system endpoints all begin with `_`, user databases never can), so Fauxton, /_all_dbs and /_node/_local/_config — which rewrites the server config given admin credentials — 404 at the proxy while any number of per-vault databases pass. Verified against both sets of paths with caddy run against a stub backend; - the plugin's own E2EE carries the actual confidentiality: jupiter only ever stores ciphertext. Its passphrase is deliberately NOT in sops — it never leaves the clients, and pairing it with the server credential would defeat the point. flush_interval -1 is required, not tuning: replication rides a continuous _changes feed that caddy would otherwise buffer into a stall. Storage sits on the array with RequiresMountsFor, since a CouchDB that starts without /mnt/data would create an empty database on the eMMC and LiveSync would replicate that emptiness back to every client. Logs go to journald rather than the unrotated /var/log/couchdb.log, for the same 29G-eMMC reasons as the rest of jupiter. The admin password reaches CouchDB as an [admins] ini fragment via extraConfigFiles; services.couchdb.adminPass would have rendered it into the world-readable store. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TLN5nkLBtCciD3ZnUwtw2b --- hosts/jupiter/configuration.nix | 1 + hosts/jupiter/secrets.nix | 17 ++++ hosts/neptun/configuration.nix | 56 +++++++++++++ secrets/jupiter.yaml | 5 +- services/dev/obsidian-livesync.nix | 128 +++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 services/dev/obsidian-livesync.nix diff --git a/hosts/jupiter/configuration.nix b/hosts/jupiter/configuration.nix index 98c1861..a634d26 100644 --- a/hosts/jupiter/configuration.nix +++ b/hosts/jupiter/configuration.nix @@ -24,6 +24,7 @@ ../../services/media/seerr.nix ../../services/media/immich.nix ../../services/dev/gitea.nix + ../../services/dev/obsidian-livesync.nix ]; # sabnzbd's unrar dependency is unfree; scope the allowance to just that diff --git a/hosts/jupiter/secrets.nix b/hosts/jupiter/secrets.nix index 752484b..034e15e 100644 --- a/hosts/jupiter/secrets.nix +++ b/hosts/jupiter/secrets.nix @@ -69,4 +69,21 @@ sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd"; sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd"; + # CouchDB admin account for Obsidian LiveSync + # (services/dev/obsidian-livesync.nix). Rendered into an [admins] ini + # fragment rather than passed as services.couchdb.adminPass, which would put + # the plaintext in the world-readable store. + # + # owner = couchdb on BOTH: couchdb re-reads its ini chain as its own + # User=/Group= after systemd drops privileges, and sops defaults to + # root:root 0400 — without this it comes up with no admin configured, which + # under require_valid_user means every request 401s. + sops.secrets.couchdb_admin_password.owner = "couchdb"; + sops.templates."couchdb-admins.ini" = { + owner = "couchdb"; + content = '' + [admins] + obsidian = ${config.sops.placeholder.couchdb_admin_password} + ''; + }; } diff --git a/hosts/neptun/configuration.nix b/hosts/neptun/configuration.nix index 15da275..5226657 100644 --- a/hosts/neptun/configuration.nix +++ b/hosts/neptun/configuration.nix @@ -111,6 +111,62 @@ reverse_proxy http://jupiter.orbit.sol:2283 ''; + # ---- Obsidian LiveSync (CouchDB on jupiter) ---- + # Obsidian's mobile apps refuse cleartext HTTP and *.jupiter.sol cannot hold + # a publicly trusted cert, so the vault database is published here instead of + # staying on the LAN. That means a credentialed database on the open + # internet; two things keep it sane: + # + # 1. The plugin's end-to-end encryption, switched on BEFORE the first sync. + # jupiter then stores only ciphertext, so a breach here is not a leak of + # the notes themselves. + # 2. This allowlist. CouchDB serves far more than the replication API — + # Fauxton (/_utils), /_all_dbs, and /_node/_local/_config, the last of + # which REWRITES the server's config given admin credentials. Only the + # paths the plugin actually speaks are proxied; everything else is + # answered here and never reaches jupiter. Use the tailnet for the rest: + # `curl http://jupiter.orbit.sol:5984/_utils/`. + # + # ONE DATABASE PER VAULT, and the matcher keys off CouchDB's own naming rule + # rather than listing them: every system endpoint begins with `_`, and a + # user-creatable database never can (CouchDB requires a lowercase letter + # first). So adding a vault needs no edit here. `_session` is the single + # underscore path let through, for cookie auth. + # + # The flip side of not listing them: a mistyped but otherwise LEGAL database + # name is proxied through and reaches CouchDB, which answers a real 404 the + # plugin can report. An ILLEGAL one — anything starting with a capital or an + # underscore — fails the matcher instead and gets caddy's 404, which carries + # no CORS headers and surfaces in Obsidian as a connection failure with no + # error message at all. If a new vault refuses to connect and the plugin + # says nothing, check the database name is lowercase first. + # + # Never point two vaults at one database: LiveSync merges them into a single + # file tree, which is not cleanly reversible. + # + # Known consequence: LiveSync's "Check database configuration" panel reads + # /_node/_local/_config and so reports the server as unconfigured from + # outside. Expected — that config is declarative in + # services/dev/obsidian-livesync.nix and is not the plugin's to patch. + # + # `flush_interval -1` is required, not tuning: replication rides a + # continuous _changes feed, which caddy would otherwise buffer — sync then + # stalls until the buffer fills (same reason vpn.mgaction.town sets it). + # + # No netcup edge-firewall change: this rides the 443 the other vhosts + # already use, unlike gitea's :2222. + services.caddy.virtualHosts."notes.mgaction.town".extraConfig = '' + @livesync path_regexp ^/(_session|[a-z][a-z0-9_$()+-]*)?(/.*)?$ + handle @livesync { + reverse_proxy http://jupiter.orbit.sol:5984 { + flush_interval -1 + } + } + handle { + respond 404 + } + ''; + # ---- Hermes dashboard ---- # Authentik-gated (hosts/mars/hermes-agent.nix has the OIDC config and the # "create the Authentik app" instructions — moved here from jupiter). diff --git a/secrets/jupiter.yaml b/secrets/jupiter.yaml index 80a9535..4bcf8b5 100644 --- a/secrets/jupiter.yaml +++ b/secrets/jupiter.yaml @@ -15,6 +15,7 @@ sabnzbd_nzb_key: ENC[AES256_GCM,data:DNVenqhJ7wf5Ng0XRA1gJN95e+90e6D9NImOSHJv/Us sabnzbd_eweka_username: ENC[AES256_GCM,data:eLsTZoM8T8fAlGaXWlDaoQ==,iv:eawyGhN7+d6UfBIbI3y1qgq+MYBGrXP6VfAkSOK6llA=,tag:ELOfQGHU5NOxZFhKOKf8LA==,type:str] sabnzbd_eweka_password: ENC[AES256_GCM,data:Mt3ZHAe2wzacCQq3x9Uy8WxjrVNad1SmU6sl8ZgrkMLymfq2eP4JzO/uPdD33A==,iv:PnFT95Zxqz4QBpPF5PRloKpoa15AU7Ef/Owwy+iDotw=,tag:/uRX00RzHLJN3gws5Qz8SA==,type:str] gitea_hermes_webhook_secret: ENC[AES256_GCM,data:Q8e+mj05MJI7CEJwRonpOmQphAZ0CfnZFoGxrDSSiyHoH3BNhqBU5gBmzuu+6NK9OS33kN+JnFvrwCeEzVxooA==,iv:mdsKOMD5B0Jzh1YRmRh71P8Io9RFtI6aqAky5x+WxOQ=,tag:v3LKz5a8ayl7WAzIPbwj6Q==,type:str] +couchdb_admin_password: ENC[AES256_GCM,data:QHkCFUwLbQdd5yKETI5qAz4CkEfsPcl2iCU8F9mX3PA=,iv:2dPNKjjoXEgm7wfC6MlhQTMvAXSNDtaXnjWl2ldl4fk=,tag:1ZltqH94Q5/6GbXAnaIgdg==,type:str] sops: age: - enc: | @@ -35,7 +36,7 @@ sops: CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ== -----END AGE ENCRYPTED FILE----- recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt - lastmodified: "2026-08-23T03:16:52Z" - mac: ENC[AES256_GCM,data:uQcOxORIWugK43LpQLI7JEjH6oGooseKCQQt0d+n43i7o23JGdUN5Wy/iD7GqmtVVZod02gl1ohEXV+kpvgetFpAO5NZu76HUVPFgaLOx+2LjrR1pNpC+52Iqlx52uypwby9eDvnC01jLFHu2l13NGBrLM3JQGmEXF57phzM/Q4=,iv:H7o3gdx/1GmZ1FRm7z97TNmiVpm6YFCEk0Puw4ZETDs=,tag:bsz3bcK2z3szrwpo55bSzQ==,type:str] + lastmodified: "2026-08-25T20:39:37Z" + mac: ENC[AES256_GCM,data:Z59BCw8gETfddXqul4LXrq6V3LBJA1itF7A1VNUERwK4NfaUGwWUhbl9h7YF/srzgtg9yGjbFB/f5kwmT3k/TWTG+C0M/4KOyTVs4y5UvB9gI4g8awYbtnFDPRAcqqcxoMD0sgapgVcNh48KWv76ndF6UGn+QfWn9eF4KBP+ZzQ=,iv:57myu1aTMMSLTz+1ldwxdusnzh8cyPwrLiEIx3rLS9w=,tag:isthpdOydD4ZNoFZyflViw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3 diff --git a/services/dev/obsidian-livesync.nix b/services/dev/obsidian-livesync.nix new file mode 100644 index 0000000..7bb37e3 --- /dev/null +++ b/services/dev/obsidian-livesync.nix @@ -0,0 +1,128 @@ +{ config, ... }: + +# CouchDB, tuned as the backend for Obsidian Self-hosted LiveSync +# (vrtmrz/obsidian-livesync). The plugin replicates the vault into CouchDB +# chunk-by-chunk over PouchDB's replication protocol, so this is a plain +# CouchDB 3 node — nothing Obsidian-specific runs here. +# +# Published PUBLICLY as https://notes.mgaction.town via neptun's caddy (see +# hosts/neptun/configuration.nix), because Obsidian's mobile apps refuse +# cleartext HTTP and jupiter's *.jupiter.sol names cannot get a real cert. +# That makes the settings below security-relevant, not cosmetic: +# +# - `require_valid_user` in BOTH [chttpd] and [chttpd_auth]: without it +# CouchDB answers unauthenticated GETs on the open internet. +# - neptun's vhost allowlists only the endpoints the plugin uses, so Fauxton +# (/_utils) and the cluster/config endpoints are not reachable from +# outside at all — reach them over the tailnet instead. +# - Turn ON end-to-end encryption in the plugin (Settings → Remote Database +# → End-to-End Encryption, plus "Obfuscate Properties", which covers the +# paths and timestamps that E2EE alone leaves readable). Then this server +# only ever holds ciphertext, which is what makes a publicly-reachable +# credentialed database an acceptable trade rather than a bad one. +# +# Its passphrase is a SEPARATE secret from couchdb_admin_password below — +# deliberately, and it must stay that way. The couchdb password +# authenticates to this server and is stored here (hashed) and in +# secrets/jupiter.yaml; the E2EE passphrase never leaves the Obsidian +# clients and CouchDB has no idea it exists. Reusing one string for both +# hands whoever obtains that credential the decryption key as well, which +# is precisely the failure E2EE is here to prevent. The passphrase is +# therefore NOT in sops (nothing on this host consumes it) — it lives in +# the HomeLab Proton Pass vault, with the deploy credentials. +# +# Losing it costs the remote database, not the notes: wipe it and +# re-initialize from a device that still holds the plaintext vault. +{ + services.couchdb = { + enable = true; + + # Listens on all interfaces, same reasoning as immich: :5984 is NOT opened + # in the firewall, so it is reachable over tailscale0 (trusted in + # common.nix) and localhost only. That is the path neptun's caddy takes. + bindAddress = "0.0.0.0"; + port = 5984; + + # The vault database is the ONLY copy of the notes once LiveSync is the + # source of truth, so it belongs on the array, not the 29G eMMC. All three + # of these default under /var/lib/couchdb and have to move together — + # configFile especially, since CouchDB writes to it at runtime (below). + databaseDir = "/mnt/data/AppData/couchdb"; + viewIndexDir = "/mnt/data/AppData/couchdb"; + configFile = "/mnt/data/AppData/couchdb/local.ini"; + + # The admin password, as an [admins] ini fragment from sops. + # services.couchdb.adminPass would render it into the world-readable + # store; extraConfigFiles is the module's own documented hook for this + # (hosts/jupiter/secrets.nix renders the template). + # + # ⚠️ CouchDB hashes a plaintext admin password at startup and persists the + # hash to the LAST, writable file in its ini chain — local.ini above, + # which then takes precedence over this fragment. So changing the sops + # value alone does NOT rotate the password: delete the `[admins]` line + # from /mnt/data/AppData/couchdb/local.ini and restart as well. + extraConfigFiles = [ config.sops.templates."couchdb-admins.ini".path ]; + + # Values taken from LiveSync's own CouchDB setup documentation; the plugin + # refuses to replicate (or silently truncates) without them. + extraConfig = { + couchdb = { + # Creates _users/_replicator on first boot instead of leaving the node + # in the un-set-up state where every request 500s. + single_node = "true"; + # LiveSync splits notes into chunks, but a big pasted image still + # arrives as one document. 8MB (the default) is too small. + max_document_size = "50000000"; + }; + + chttpd = { + require_valid_user = "true"; + max_http_request_size = "4294967296"; + enable_cors = "true"; + }; + + chttpd_auth = { + require_valid_user = "true"; + authentication_redirect = "/_utils/session.html"; + }; + + httpd = { + # Makes CouchDB answer 401 with a WWW-Authenticate challenge rather + # than a bare 401 body — the plugin's basic-auth flow depends on it. + "WWW-Authenticate" = ''Basic realm="couchdb"''; + enable_cors = "true"; + }; + + # Obsidian is an Electron/Capacitor app, so its requests carry these + # non-http origins. Without them desktop and mobile both fail CORS + # preflight and the plugin reports a bare "cannot connect". + cors = { + credentials = "true"; + origins = "app://obsidian.md,capacitor://localhost,http://localhost"; + headers = "accept, authorization, content-type, origin, referer"; + methods = "GET, PUT, POST, HEAD, DELETE"; + max_age = "3600"; + }; + + # The module points [log] file at /var/log/couchdb.log, which nothing + # rotates — on a 29G eMMC an info-level log of every replication request + # is a slow disk-fill. stderr hands it to journald's capped storage + # instead (the file setting is then ignored). + log = { + writer = "stderr"; + level = "warning"; + }; + }; + }; + + # /mnt/data/AppData is drwx--x--- darman:users, so the couchdb user needs + # group "users" just to traverse into its own database dir. The dir itself + # is created couchdb:couchdb by the module's tmpfiles rule. + users.users.couchdb.extraGroups = [ "users" ]; + + # databaseDir is outside /var/lib, so systemd derives no mount dependency + # from it. Without this CouchDB starts with the array missing, creates an + # empty database on the eMMC, and LiveSync sees a remote vault that lost + # every note — which it would then happily replicate back to the clients. + systemd.services.couchdb.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; +} -- 2.54.0 From a8a1cffa3ee00097d9335cef5dfe84741e17a309 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 26 Aug 2026 00:10:34 +0200 Subject: [PATCH 2/2] mars: mirror luna's Obsidian vault to disk with livesync-bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TLN5nkLBtCciD3ZnUwtw2b --- README.md | 87 +++++++++++++++ flake.lock | 17 +++ flake.nix | 12 +++ hosts/mars/configuration.nix | 1 + hosts/mars/hermes-agent.nix | 7 ++ hosts/mars/livesync-bridge.nix | 187 +++++++++++++++++++++++++++++++++ hosts/mars/secrets.nix | 32 ++++++ secrets/mars.yaml | 6 +- 8 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 hosts/mars/livesync-bridge.nix diff --git a/README.md b/README.md index 2429ef4..afb27b2 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,93 @@ another way in. (via the `/mnt/jupiter` samba mount) before the first switch if you want it preserved instead of starting clean. +### Obsidian vaults (jupiter CouchDB + mars bridge) + +CouchDB itself is fully declarative (`services/dev/obsidian-livesync.nix`), but +three things are runtime state it cannot own. + +**1. Each vault's database is created by the plugin.** Point Self-hosted +LiveSync at `https://notes.mgaction.town` (URI field) with the database name in +its own field — *not* as a path on the URI. Turn on End-to-End Encryption and +Obfuscate Properties **before the first sync**; both are remote-format +decisions and changing them later means converting or rebuilding the database. +The passphrase lives in the HomeLab Proton Pass vault, never in sops — it is +what keeps a publicly reachable database from being a readable one. + +Database names must start with a lowercase letter (`a-z0-9_$()+-` after that). +An illegal name is rejected by neptun's matcher rather than CouchDB, and shows +up in Obsidian as a connection failure with **no error message at all**. + +**2. luna's vault credentials on mars.** `hosts/mars/secrets.nix` needs two +values before mars will activate: `couchdb_luna_password` and +`obsidian_luna_passphrase`. + +``` +sops --set '["couchdb_luna_password"] ""' secrets/mars.yaml +sops --set '["obsidian_luna_passphrase"] ""' secrets/mars.yaml +``` + +Keep both alphanumeric. sops substitutes into already-rendered JSON, so a `"` +or `\` in either produces an invalid `config.json`; the bridge logs +`Could not parse configuration!` and then runs on with **zero peers** instead +of exiting, which looks exactly like a bridge that is simply idle. + +As set up today these are the `obsidian` admin password and the same +passphrase as the personal vault, which means mars — the box running an +autonomous agent — can decrypt and read every vault database. Optional +hardening, either half independently: + +``` +# password comes straight out of sops; never echo it +LUNA_PW=$(sops --decrypt --extract '["couchdb_luna_password"]' secrets/mars.yaml) +ADMIN=obsidian # prompts for the admin password +curl -u "$ADMIN" -X PUT http://jupiter.orbit.sol:5984/_users/org.couchdb.user:luna \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"luna\",\"type\":\"user\",\"roles\":[],\"password\":\"$LUNA_PW\"}" +curl -u "$ADMIN" -X PUT http://jupiter.orbit.sol:5984/luna_wiki/_security \ + -H 'Content-Type: application/json' \ + -d '{"admins":{"names":[],"roles":[]},"members":{"names":["luna"],"roles":[]}}' +unset LUNA_PW +``` + +then set `username` in `hosts/mars/livesync-bridge.nix` to `luna` and put that +account's password in `couchdb_luna_password`. Run it against jupiter over the +tailnet — `/_users` is blocked on the public vhost on purpose. A vault-specific +passphrase is the other half, changed in the plugin and mirrored into sops. + +**3. The database name must match.** `database` in +`hosts/mars/livesync-bridge.nix` has to be exactly the name entered in the +plugin. A mismatch does not error — with an admin credential PouchDB simply +creates the misnamed database and replicates an empty vault into it. + +Order matters: set the vault up from Obsidian first so the database exists and +carries the plugin's own tweaks, then deploy mars. Afterwards: + +``` +systemctl status livesync-bridge # on mars +cat /var/lib/livesync-bridge/health.json # per-peer ok/backendUp/detail +ls /var/lib/livesync-bridge/vault # her notes, as real markdown +``` + +The vault is mounted into the agent container at `/opt/data/vault`, inside +`HERMES_WRITE_SAFE_ROOT`, so luna can write as well as read. + +A note luna writes reaches CouchDB as soon as the bridge sees it, but whether +it then reaches your devices depends on that vault's **Sync Mode** in the +plugin. Only "LiveSync (real-time)" pulls continuously; the periodic/on-save +presets need their timer or a manual **Replicate**. A file that appears only +after clicking Replicate is the client waiting, not the bridge failing — the +database already had it. Check the bridge's own side in the journal: + +``` +journalctl -u livesync-bridge | grep -- '--> luna-remote' +``` + +⚠️ **Verify her writes actually land before trusting this.** Upstream has three +open issues on the storage→CouchDB direction (#50, #23, #46) and all fail +silently — the log reports the upload and the database never updates. Create a +note as luna, confirm it appears on a phone, and re-check after any input bump. + ### mercury (Raspberry Pi 3B+) - `./deploy flash mercury /dev/sdX` writes the dedicated age key to the root diff --git a/flake.lock b/flake.lock index 9240eb0..719f0f9 100644 --- a/flake.lock +++ b/flake.lock @@ -176,6 +176,22 @@ "url": "https://git.mgaction.town/darman/hypr-chrome.git" } }, + "livesync-bridge": { + "flake": false, + "locked": { + "lastModified": 1787571662, + "narHash": "sha256-btLnQNbFzCPaSVcY9rtiPdYeXrZjoK9AYvfA9+ovsIc=", + "owner": "vrtmrz", + "repo": "livesync-bridge", + "rev": "c3760beaa0851214da4860903445d7f6420ca025", + "type": "github" + }, + "original": { + "owner": "vrtmrz", + "repo": "livesync-bridge", + "type": "github" + } + }, "media-manager": { "flake": false, "locked": { @@ -490,6 +506,7 @@ "disko": "disko", "home-manager": "home-manager", "hypr-chrome": "hypr-chrome", + "livesync-bridge": "livesync-bridge", "mediamanager-nix": "mediamanager-nix", "nix-flatpak": "nix-flatpak", "nixos-anywhere": "nixos-anywhere", diff --git a/flake.nix b/flake.nix index fc1b2a8..11341a7 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,18 @@ url = "github:strangeglyph/mediamanager-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; + # livesync-bridge — headless CouchDB <-> filesystem sync for Obsidian + # LiveSync, used on mars to give luna a real directory of markdown + # (hosts/mars/livesync-bridge.nix). Not a flake and not in nixpkgs, so it + # comes in as plain source pinned by flake.lock; the service copies it out + # and runs it under deno. Pinning matters more than usual here — this is a + # small third-party project with open bugs on the storage->couchdb path, + # so an unreviewed bump could quietly change how the agent's notes are + # written back. + livesync-bridge = { + url = "github:vrtmrz/livesync-bridge"; + flake = false; + }; authentik-nix.url = "github:nix-community/authentik-nix"; nix-flatpak.url = "github:gmodena/nix-flatpak"; # Own Hyprland plugin (border + title bar), public repo, fetched over diff --git a/hosts/mars/configuration.nix b/hosts/mars/configuration.nix index 13a0d3f..0f2dda9 100644 --- a/hosts/mars/configuration.nix +++ b/hosts/mars/configuration.nix @@ -8,6 +8,7 @@ ./disk-config.nix # disko: OS-disk partitions + filesystems ./secrets.nix # sops-nix: samba/tailscale/hermes secrets ./hermes-agent.nix + ./livesync-bridge.nix ../../common.nix # shared base: user / ssh / nix / firewall ../../services/containers.nix ../../services/vpn/tailscale.nix diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 4e7cd56..1b3599a 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -278,6 +278,13 @@ in "${hermesHome}:/opt/data" "${dropboxDir}:/opt/data/dropbox" + # luna's Obsidian vault, kept in sync with CouchDB on jupiter by + # livesync-bridge.nix. Under /opt/data so it lands inside + # HERMES_WRITE_SAFE_ROOT and she can write notes, not just read them — + # same reasoning as the dropbox above. The bridge runs as this very + # uid/gid, so no ownership fixup is needed on either side. + "/var/lib/livesync-bridge/vault:/opt/data/vault" + # git/tea for luna: the image doesn't ship `tea` (and shouldn't be # trusted to have a known-good `git` either), so both come from this # host's Nix store instead — mounted read-only at fixed PATH-visible diff --git a/hosts/mars/livesync-bridge.nix b/hosts/mars/livesync-bridge.nix new file mode 100644 index 0000000..3b763ba --- /dev/null +++ b/hosts/mars/livesync-bridge.nix @@ -0,0 +1,187 @@ +{ config, pkgs, inputs, ... }: + +# livesync-bridge (vrtmrz) — mirrors an Obsidian LiveSync vault out of CouchDB +# on jupiter (services/dev/obsidian-livesync.nix) into a real directory of +# markdown here, so luna can read and write the vault as files. Obsidian itself +# is an Electron GUI with no headless mode, and an agent wants files anyway. +# +# ⚠️ THE WRITE-BACK PATH IS THE RISKY ONE. Upstream has three open, unanswered +# issues on storage->couchdb — #50 (Jun 2026, writes detected and logged as +# uploaded, database never updated), #23 (only lowercase filenames transmitted +# from storage), #46 (silent stall on files over ~30KB). All fail QUIETLY: the +# log says success and the note never arrives. So do not treat this directory +# as durable storage for anything luna cannot regenerate, and check that her +# edits actually reach your devices before trusting it. (E2EE itself is fine — +# PeerCouchDB.ts hard-errors if a passphrase is missing for an encrypted +# remote, so it is a deliberate code path. The one issue claiming E2EE breaks +# bridging, #12, is a single unreproduced report with no maintainer reply.) +# +# +# EXPECTED NOISE ON FIRST SYNC: a stack trace per historically-deleted file — +# NotFound: ... remove '/Welcome.md' at PeerStorage.delete +# CouchDB keeps deletion tombstones, and the bridge replays them against a +# directory where the file never existed. PeerStorage.ts:33-40 catches it, +# logs, and returns false, so nothing is wrong; it only LOOKS fatal because +# main.ts pins the logger to LOG_LEVEL_DEBUG, which prints exception dumps +# that are otherwise verbose-level. It stops once the initial catch-up ends. +# Talks to CouchDB over the TAILNET (jupiter.orbit.sol:5984), not through +# neptun: mars is a tailnet node, so the public vhost, its TLS and its path +# allowlist are all irrelevant here. +let + stateDir = "/var/lib/livesync-bridge"; + appDir = "${stateDir}/app"; + vaultDir = "${stateDir}/vault"; + + # The same uid/gid the hermes-agent container runs as (hermes-agent.nix). + # Deliberate: the bridge and luna both read and write these files, and + # sharing one uid removes any dependence on the container's umask. Two + # different uids in a shared group only works while every file stays + # group-writable, and a single 0644 file dropped by the agent would stall + # sync on that path with nothing but a permission error in the log. + hermesUid = 986; + + # Which vault. `group` is what pairs the two peers — both must match or the + # bridge starts cleanly and simply never syncs anything. + # + # ⚠️ `database` must be the name entered in the Obsidian plugin for luna's + # vault. Get it wrong and nothing errors: the credential below is CouchDB's + # admin, so PouchDB CREATES the misnamed database and replicates an empty + # vault into it quite happily. + peerGroup = "luna"; + database = "luna_wiki"; +in +{ + # hermes-agent.nix declares the GROUP (gid 983) but no user: the container + # brings its own uid and needs no host account. The bridge does need one to + # run as, so the matching user is declared here. + users.users.hermes = { + uid = hermesUid; + group = "hermes"; + isSystemUser = true; + home = stateDir; + description = "Hermes agent uid, shared with the livesync-bridge service"; + }; + + # Created here rather than by the service so they exist before anything + # tries to use them: + # - vaultDir before podman-hermes-agent starts, because a bind-mount + # source that does not exist is created by podman as root:root and the + # bridge then cannot write into its own vault; + # - appDir because WorkingDirectory applies to ExecStartPre as well, so a + # missing one fails the unit before preStart ever gets to create it. + systemd.tmpfiles.rules = [ + "d ${vaultDir} 0770 hermes hermes -" + "d ${appDir} 0750 hermes hermes -" + "d ${stateDir}/deno 0750 hermes hermes -" + ]; + + # The bridge's peer config, rendered by sops because it carries three + # secrets inline (CouchDB password + both passphrases) and the file format + # has no include mechanism. + # + # ⚠️ sops substitutes placeholders into the ALREADY-RENDERED json, so a + # secret containing a double quote or a backslash produces an invalid config + # and the bridge logs "Could not parse configuration!" and then sits there + # with zero peers — it does not exit. Keep all three values alphanumeric. + sops.templates."livesync-bridge.json" = { + owner = "hermes"; + content = builtins.toJSON { + peers = [ + { + type = "couchdb"; + name = "luna-remote"; + group = peerGroup; + url = "http://jupiter.orbit.sol:5984"; + inherit database; + username = "obsidian"; + password = config.sops.placeholder.couchdb_luna_password; + passphrase = config.sops.placeholder.obsidian_luna_passphrase; + # The plugin derives path obfuscation from the same passphrase it + # uses for content, so this is the same secret. Split into its own + # field because the bridge takes them separately — if paths come + # back as garbage while contents decode fine, this is the field that + # is wrong. + obfuscatePassphrase = config.sops.placeholder.obsidian_luna_passphrase; + # Reads the chunking tweaks the plugin stored in the remote, instead + # of guessing sizes that then disagree with every other client. + useRemoteTweaks = true; + baseDir = ""; + } + { + type = "storage"; + name = "luna-vault"; + group = peerGroup; + baseDir = vaultDir; + # Catch up on anything that changed while the service was down. + scanOfflineChanges = true; + useChokidar = true; + } + ]; + }; + }; + + systemd.services.livesync-bridge = { + description = "Obsidian LiveSync bridge (CouchDB <-> ${vaultDir})"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "tailscaled.service" ]; + wants = [ "network-online.target" ]; + + environment = { + # Persistent module + npm cache. Without a fixed DENO_DIR the service + # re-downloads its whole dependency tree on every start. + DENO_DIR = "${stateDir}/deno"; + # main.ts reads this instead of ./dat/config.json, which keeps the + # secret out of the copied source tree entirely. + LSB_CONFIG = config.sops.templates."livesync-bridge.json".path; + LSB_HEALTH_FILE = "${stateDir}/health.json"; + HOME = stateDir; + }; + + # Copy the pinned source out of the store and install its locked deps. + # It cannot run from /nix/store directly: deno.jsonc sets + # `nodeModulesDir: manual` with byonm, so `deno install` must write a + # node_modules/ next to the sources. + # + # The copy target is a FIXED path on purpose. Deno keys localStorage — + # which is where the bridge records per-file sync state (Peer.ts:119) — by + # the main module's origin, and stores it under + # DENO_DIR/location_data/. VERIFIED by running the same + # source from two paths against one DENO_DIR: two separate origin dirs + # appear. Running straight from /nix/store would therefore change the + # origin on every input bump and silently reset the bridge to a full + # rescan of both peers. + # + # Guarded by a stamp file so this is a no-op on ordinary restarts; only a + # flake input bump pays for the re-install (which needs network). + preStart = '' + set -eu + stamp=${stateDir}/.src + if [ "$(cat "$stamp" 2>/dev/null || true)" != "${inputs.livesync-bridge}" ]; then + # Contents only — appDir is this unit's WorkingDirectory, and + # deleting the cwd out from under deno breaks the install below. + find ${appDir} -mindepth 1 -delete + cp -r ${inputs.livesync-bridge}/. ${appDir}/ + chmod -R u+w ${appDir} + ${pkgs.deno}/bin/deno install --frozen + printf '%s' "${inputs.livesync-bridge}" > "$stamp" + fi + ''; + + serviceConfig = { + User = "hermes"; + Group = "hermes"; + StateDirectory = "livesync-bridge"; + WorkingDirectory = appDir; + # `deno task run` is `deno run -A main.ts`; invoked directly so the + # task runner is not in the supervision path. + ExecStart = "${pkgs.deno}/bin/deno run -A main.ts"; + # main.ts installs an unhandledrejection guard, but a genuinely dead + # process should still come back rather than trip the start limit. + Restart = "always"; + RestartSec = 30; + # Group-writable output, so the two identities stay interchangeable if + # the uid sharing above is ever unpicked. + UMask = "0007"; + }; + }; +} diff --git a/hosts/mars/secrets.nix b/hosts/mars/secrets.nix index 3251fff..dd87e49 100644 --- a/hosts/mars/secrets.nix +++ b/hosts/mars/secrets.nix @@ -65,4 +65,36 @@ # 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 = { }; } diff --git a/secrets/mars.yaml b/secrets/mars.yaml index 4ffacdd..c82817a 100644 --- a/secrets/mars.yaml +++ b/secrets/mars.yaml @@ -6,6 +6,8 @@ telegram_bot_token: ENC[AES256_GCM,data:WX+KFtoqFodkoWNwd7EXUrUJakZ9oaMZgg4OnCeL hermes_dashboard_oidc_client_secret: ENC[AES256_GCM,data:IMPNTPMKO+b7eyV4hyGfnvH1/i+W4IPDNjncoyB1oIV8WaB6nOJn0sSEuTUCKB94K+Y7bsVQU0zpbKdIYOdGqgmPzwMCsScxMt4SewTmiiqWxv6SQFf4EzMxgXqjMvH8PWDzLcI2C2tI/KcVS251iqRViOTFe1/tkm+mV8sJmEI=,iv:F/rOUDmJZoGPS9fObAni5ntyOqbbhMWDPdHGLTexwlA=,tag:ALf98DmB0JziGspZMiLCiw==,type:str] gitea_luna_token: ENC[AES256_GCM,data:0ypW9oVFs1mXYPhPareMFRdkSYcvHSCm+fQOd7/76lJEXi217r9dmg==,iv:j3TPm/iLk6pB6CmDePFBOlnhxWSbmLKvOhz06SM1T7k=,tag:ydErvC2mZ1RRnwNffiHkkg==,type:str] gitea_hermes_webhook_secret: ENC[AES256_GCM,data:lV78H0xAehPxusSO/QruOYkt7fkMJrW+ScZL4UWYvgnBGn/D+1XHYPyHCqe2sEEWSlIaAgWMMoZzoVJ1Z1NFVQ==,iv:GmTZxoH2iiL/vTVgPfziXIFYD+Rl3cbh9hqXvWps+iw=,tag:jtXEUOVFfKrpTRK7S9PZMA==,type:str] +couchdb_luna_password: ENC[AES256_GCM,data:V91is2h7UskI1rtwMzQyduNXoDPTYNwa4sw9K9WU+wE=,iv:k976ImKR19+CvGOVsHsrqMaFFtSQxVi6zABCJuQ5AWE=,tag:W2SN4SET/+o4+Wt6BOJ1LA==,type:str] +obsidian_luna_passphrase: ENC[AES256_GCM,data:fqHtP3g4J40ddYL9lzCixrisdC/DEJJermE=,iv:FU6BGNcBnyP8Rz3dNBk0+K0aAQTDW6/0aVaFm1rBFkk=,tag:Mg1aKqh++2rmU8ROaVIgDw==,type:str] sops: age: - enc: | @@ -26,7 +28,7 @@ sops: oyJ7PS3lW+PxH5AZkeeU7gXO/pz2oDku0aDOds7kaD3n0+qSWicQ+Q== -----END AGE ENCRYPTED FILE----- recipient: age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk - lastmodified: "2026-08-23T05:55:49Z" - mac: ENC[AES256_GCM,data:a3vCmrQMCS25tNWrzTeiGmOHf4Fn356PO3uNa2HvS21EBCKTc6YWBj9KmpORdz+6t03JJe/4eiGdghGaLhRr+JXyQnaT54gSV+FhC3dH6blind746XN3h+Z9rxiva6apvcAGUZ9k01Js5IXN9efEMhcI6w0U4oVuVqtvShvg8A8=,iv:9kF3cJ1vyy2H3eH10DVCYmWeXv2MH4AFDiF8cOajlw4=,tag:zhombLVpL8M1TUtYur/gYQ==,type:str] + lastmodified: "2026-08-25T21:51:24Z" + mac: ENC[AES256_GCM,data:dz249hf3w8Tn0JStFOhhpdCZFMx2yxmNABx1CbeIQ/JlICAU82e4fg8AzJQY9EMOEs3Zx6L61yieljD4A/HLip5rDVlAXqqLeklW60eb7BHuSO79YfFgot+rS05g8WqFkRJYWzmkXhMbErCI133n72XEdeqMUU/m0djOUZlVRLs=,iv:d7G6TJRLfmXvQ2BUG9Hi83lOB9anhmb9qneLeVSCBhc=,tag:F6X/0z00PjiYum5kf8YApA==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3 -- 2.54.0