Comments had drifted into multi-paragraph narrative (git commit lineage, debugging stories, restated code) in several hot spots (scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix). Trim every comment to its load-bearing "why" — gotchas, safety warnings, and non-obvious rationale survive verbatim in substance, just tightened to 1-2 sentences; historical narrative and anything already covered in CLAUDE.md is cut. No code/logic changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
166 lines
7.0 KiB
Nix
166 lines
7.0 KiB
Nix
{ 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";
|
|
};
|
|
};
|
|
}
|