From 15ae1cf608efc2172bf67efe44af4229a2b80fa2 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 25 Aug 2026 23:25:37 +0200 Subject: [PATCH] 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" ]; +}