Merge branch 'master' into feat/quickshell-dense-bar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
./disk-config.nix # disko: OS-disk partitions + filesystems
|
||||
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
|
||||
./hermes-agent.nix
|
||||
./livesync-bridge.nix
|
||||
./luna-sites.nix # luna's LAN web apps: http://mars.sol/<name>/
|
||||
../../common.nix # shared base: user / ssh / nix / firewall
|
||||
../../services/containers.nix
|
||||
|
||||
@@ -330,6 +330,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
|
||||
|
||||
@@ -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 '<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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 = { };
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user