{ config, pkgs, inputs, ... }: # livesync-bridge (vrtmrz) — mirrors an Obsidian LiveSync vault out of CouchDB # on jupiter (services/dev/obsidian-livesync.nix) into real markdown files # here, since Obsidian itself is a GUI-only Electron app and luna needs files. # # ⚠️ THE WRITE-BACK PATH IS THE RISKY ONE: upstream has open bugs where a # write is logged as uploaded but the database is never updated (#50), only # lowercase filenames sync from storage (#23), and files over ~30KB silently # stall (#46) — all fail quietly with no error in the log. Don't treat this # directory as durable for anything luna can't regenerate, and verify her # edits actually reach your devices. (E2EE itself is fine — it hard-errors on # a missing passphrase rather than failing silently.) # # EXPECTED NOISE ON FIRST SYNC: a stack trace per historically-deleted file — # CouchDB replays deletion tombstones against a directory where the file # never existed. Harmless, caught and logged, and stops once the initial # catch-up ends. # # Talks to CouchDB over the tailnet (jupiter.orbit.sol:5984) directly — mars # is a tailnet node, so neptun's public vhost/TLS/allowlist don't apply here. let stateDir = "/var/lib/livesync-bridge"; appDir = "${stateDir}/app"; vaultDir = "${stateDir}/vault"; # The same uid/gid hermes-agent runs as (hermes-agent.nix), so both peers # share files without depending on umask — two uids in a shared group only # works while every file stays group-writable, and one 0644 file from the # agent would silently stall sync. hermesUid = 986; # `group` pairs the two peers — mismatched and the bridge starts but never # syncs. # # ⚠️ `database` must match the name entered in the Obsidian plugin exactly: # get it wrong and nothing errors, since the admin credential below lets # PouchDB just create the misnamed database and replicate an empty vault. peerGroup = "luna"; database = "luna_wiki"; in { # hermes-agent.nix declares the group (gid 983) but no user — the container # needs no host account, but this service does, so it's 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, not by the service, so they exist before anything needs # them: vaultDir before podman-hermes-agent starts (else podman creates it # as root:root), and appDir before ExecStartPre runs (WorkingDirectory # applies to it too). systemd.tmpfiles.rules = [ "d ${vaultDir} 0770 hermes hermes -" "d ${appDir} 0750 hermes hermes -" "d ${stateDir}/deno 0750 hermes hermes -" ]; # Rendered by sops (three inline secrets: CouchDB password + both # passphrases; the json format has no include mechanism). # # ⚠️ sops substitutes into the ALREADY-RENDERED json, so a secret with a # quote or backslash yields invalid config — the bridge then just sits with # zero peers logging "Could not parse configuration!" instead of exiting. # 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; # Same secret as the content passphrase — the plugin derives path # obfuscation from it too, but the bridge takes them as separate # fields. If paths come back as garbage while contents decode fine, # this is the field to check. 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; }; # Copies the pinned source out of the store and installs locked deps, # since deno.jsonc's `nodeModulesDir: manual` (byonm) needs to write # node_modules/ next to the sources — it can't run from /nix/store directly. # # The copy target is a FIXED path on purpose: Deno keys its localStorage # (where the bridge tracks per-file sync state) by the main module's # origin, so running straight from /nix/store would change that origin — # and reset the bridge to a full rescan of both peers — on every input bump. # # Guarded by a stamp file: a no-op on ordinary restarts, only a flake # input bump pays for the (networked) re-install. 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"; }; }; }