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
188 lines
8.5 KiB
Nix
188 lines
8.5 KiB
Nix
{ 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 '<vault>/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/<sha of that origin>. 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";
|
|
};
|
|
};
|
|
}
|