Compare commits

14 Commits
Author SHA1 Message Date
darman 45e08e35a2 Merge pull request 'Feat/obsidian livesync' (#5) from feat/obsidian-livesync into master
Reviewed-on: #5
2026-08-28 05:16:36 +02:00
darmanandClaude Opus 5 a8a1cffa3e mars: mirror luna's Obsidian vault to disk with livesync-bridge
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
2026-08-26 00:10:34 +02:00
darmanandClaude Opus 5 15ae1cf608 obsidian: self-hosted vault sync via CouchDB on jupiter
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLN5nkLBtCciD3ZnUwtw2b
2026-08-25 23:25:37 +02:00
darmanandClaude Opus 5 94061bd80a monitoring: create the victoriametrics state dir, and pin the scrape timeout
The bind onto /var/lib/private/victoriametrics needs its source to exist or
the mount fails -- and because it is `nofail`, quietly: RequiresMountsFor is
satisfied by /mnt/data itself, so the service would start anyway and write
the TSDB to the eMMC, which is the one thing the bind exists to prevent.
prowlarr.nix has no tmpfiles rule only because its directory predates the
module (migrated from ZimaOS); this is a fresh service, so it creates its
own, same as seerr.nix. Verified on jupiter: the mount is live on md127 and
nothing lands on the OS disk.

scrape_timeout was left implicit at the Prometheus default of 10s, which is
longer than the 5s interval -- VictoriaMetrics clamps it down rather than
erroring, so the config claimed 10s while the scraper used 5s. Say what
actually happens. Checked with `victoria-metrics -promscrape.config.dryRun`,
not just nix eval, which never builds the checked-config derivation.

Also comments: why the bind exists and why `nofail` is load-bearing (the
fileSystems block had none, unlike prowlarr.nix and seerr.nix), and why
mercury needs its own job -- scrape_interval is per-job and job_name must be
unique, so its `job` label will always differ from the other hosts'. Select
on `host` in dashboards or mercury drops out of them silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 04:58:12 +02:00
darman 15fc18ab50 Merge pull request 'jupiter: add VictoriaMetrics monitoring' (#1) from feat/mars-victoriametrics into master
Reviewed-on: #1
Reviewed-by: darman <mail@erik-s.dev>
2026-08-24 04:48:45 +02:00
luna eec17b77df monitoring: move metrics state to the data array 2026-08-24 02:03:51 +00:00
luna b6aec1e307 monitoring: scrape mars node exporter 2026-08-24 01:45:00 +00:00
luna dfe8504402 monitoring: move VictoriaMetrics to Jupiter 2026-08-24 01:42:07 +00:00
luna 5764e6c644 mars: tune VictoriaMetrics scrape targets 2026-08-24 01:30:37 +00:00
darmanandClaude Opus 5 b99337adb7 gitea: subscribe the review hook to pull_request_review
The hook registered with no events at all and delivered nothing.
"pull_request_review_comment" and "pull_request_review_rejected" are real
HookEventTypes and real X-GitHub-Event-Type values, but they are not
things gitea's hook API accepts. updateHookEvents
(routers/api/v1/utils/hook.go) matches a fixed list of api names and
silently ignores anything else, so every event flag stayed false, the POST
succeeded, and the hook sat there inert.

There is no narrower api name: HasEvent (models/webhook/webhook.go)
collapses approved, rejected and review-comment onto
HookEventPullRequestReview, so `pull_request_review` is a single switch for
all three. Approvals consequently cannot be excluded at the hook any more.
They now cross the wire as "pull_request_approved", which is not in the
route's event list, so Hermes ignores them on the event match -- before the
filter script and before any LLM call. Gitea's delivery log will show them
answered 200/ignored, which is intended.

That makes three namespaces for the same event rather than two, so the
tables in both nix files and the README now carry the api column, and the
README warns about the silent-ignore behaviour that hid this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 03:27:52 +02:00
luna fb1226f9b5 Merge master into feat/mars-victoriametrics 2026-08-24 01:17:42 +00:00
luna dc037312b3 Merge master into feat/mars-victoriametrics 2026-08-24 01:15:07 +00:00
luna 082cbaff2a mars: reduce VictoriaMetrics retention to 15 days 2026-08-24 01:12:50 +00:00
luna ea9be6fb8a mars: add VictoriaMetrics monitoring 2026-08-23 00:55:07 +00:00
16 changed files with 752 additions and 56 deletions
+110 -13
View File
@@ -42,13 +42,16 @@ run. Each service module opens its own firewall ports.
Jupiter's Gitea registers one webhook per Hermes route, straight at Hermes on
mars (`http://mars.orbit.sol:8644/webhooks/<route>`), with no relay in between:
| route | subscribed gitea events | wakes luna on |
| route | gitea hook event | wakes luna on |
| --- | --- | --- |
| `gitea-pr-comments` | `pull_request_comment` | a timeline comment on a PR |
| `gitea-pr-reviews` | `pull_request_review_comment`, `pull_request_review_rejected` | a review with a body, or changes requested |
| `gitea-pr-reviews` | `pull_request_review` | a review with a body, or changes requested |
Approvals are deliberately not subscribed: an approval is darman signing off,
not asking for work. Gitea's `addDefaultHeaders` signs every webhook type with
Approvals cannot be excluded at the hook — `pull_request_review` is one switch
for all three review types — so they are delivered and then dropped by the
Hermes route, which does not list `pull_request_approved`. Expect them in
gitea's delivery log answered 200/ignored; that is the design, not a failure.
Gitea's `addDefaultHeaders` signs every webhook type with
`X-Hub-Signature-256` in GitHub's exact format and sends `X-GitHub-Event`
unconditionally — which is exactly what Hermes validates against the route
secret and reads the event name from, so the two speak the same protocol
@@ -61,17 +64,24 @@ defaults to `external` and does NOT include tailnet addresses
gitea classifies it). `services/dev/gitea.nix` sets it accordingly; without
that, deliveries fail with `webhook can only call allowed HTTP servers`.
Gitea names webhook events twice, and the two namespaces collide. The hook's
`events` array takes the *subscription* name; `X-GitHub-Event`, which is what
each Hermes route matches its `events` against, carries a lossy *wire* name
from `HookEventType.Event()`:
Gitea spells the same event three ways, and two of the spellings collide. The
hook's `events` array takes an *api* name (`updateHookEvents` in
`routers/api/v1/utils/hook.go`), which is a coarser set than the internal
`HookEventType`; `X-GitHub-Event`, which is what each Hermes route matches its
`events` against, carries a lossy *wire* name from `HookEventType.Event()`:
| subscription | wire | what it is |
| HookEventType | wire (mars route) | api (gitea hook) |
| --- | --- | --- |
| `pull_request_comment` | `issue_comment` | comment on a PR |
| `pull_request_review_comment` | `pull_request_comment` | review with a body |
| `pull_request_review_rejected` | `pull_request_rejected` | changes requested |
| `pull_request_review_approved` | `pull_request_approved` | approval |
| `issue_comment` | `issue_comment` | `issue_comment` |
| `pull_request_comment` | `issue_comment` | `pull_request_comment` |
| `pull_request_review_comment` | `pull_request_comment` | `pull_request_review` |
| `pull_request_review_rejected` | `pull_request_rejected` | `pull_request_review` |
| `pull_request_review_approved` | `pull_request_approved` | `pull_request_review` |
Watch the api column: `updateHookEvents` **silently ignores strings it does not
recognise**, so a plausible-looking name that is a valid `HookEventType` but
not a valid api event leaves the hook registered with no events at all — no
error, no deliveries. Check a new hook's event list in the UI after adding it.
So `services/dev/gitea.nix` and `hosts/mars/hermes-agent.nix` deliberately name
the same event differently, and neither is a typo. `X-GitHub-Event-Type`
@@ -417,6 +427,93 @@ another way in.
(via the `/mnt/jupiter` samba mount) before the first switch if you want
it preserved instead of starting clean.
### Obsidian vaults (jupiter CouchDB + mars bridge)
CouchDB itself is fully declarative (`services/dev/obsidian-livesync.nix`), but
three things are runtime state it cannot own.
**1. Each vault's database is created by the plugin.** Point Self-hosted
LiveSync at `https://notes.mgaction.town` (URI field) with the database name in
its own field — *not* as a path on the URI. Turn on End-to-End Encryption and
Obfuscate Properties **before the first sync**; both are remote-format
decisions and changing them later means converting or rebuilding the database.
The passphrase lives in the HomeLab Proton Pass vault, never in sops — it is
what keeps a publicly reachable database from being a readable one.
Database names must start with a lowercase letter (`a-z0-9_$()+-` after that).
An illegal name is rejected by neptun's matcher rather than CouchDB, and shows
up in Obsidian as a connection failure with **no error message at all**.
**2. luna's vault credentials on mars.** `hosts/mars/secrets.nix` needs two
values before mars will activate: `couchdb_luna_password` and
`obsidian_luna_passphrase`.
```
sops --set '["couchdb_luna_password"] "<password>"' secrets/mars.yaml
sops --set '["obsidian_luna_passphrase"] "<passphrase>"' secrets/mars.yaml
```
Keep both alphanumeric. sops substitutes into already-rendered JSON, so a `"`
or `\` in either produces an invalid `config.json`; the bridge logs
`Could not parse configuration!` and then runs on with **zero peers** instead
of exiting, which looks exactly like a bridge that is simply idle.
As set up today these are the `obsidian` admin password and the same
passphrase as the personal vault, which means mars — the box running an
autonomous agent — can decrypt and read every vault database. Optional
hardening, either half independently:
```
# password comes straight out of sops; never echo it
LUNA_PW=$(sops --decrypt --extract '["couchdb_luna_password"]' secrets/mars.yaml)
ADMIN=obsidian # prompts for the admin password
curl -u "$ADMIN" -X PUT http://jupiter.orbit.sol:5984/_users/org.couchdb.user:luna \
-H 'Content-Type: application/json' \
-d "{\"name\":\"luna\",\"type\":\"user\",\"roles\":[],\"password\":\"$LUNA_PW\"}"
curl -u "$ADMIN" -X PUT http://jupiter.orbit.sol:5984/luna_wiki/_security \
-H 'Content-Type: application/json' \
-d '{"admins":{"names":[],"roles":[]},"members":{"names":["luna"],"roles":[]}}'
unset LUNA_PW
```
then set `username` in `hosts/mars/livesync-bridge.nix` to `luna` and put that
account's password in `couchdb_luna_password`. Run it against jupiter over the
tailnet — `/_users` is blocked on the public vhost on purpose. A vault-specific
passphrase is the other half, changed in the plugin and mirrored into sops.
**3. The database name must match.** `database` in
`hosts/mars/livesync-bridge.nix` has to be exactly the name entered in the
plugin. A mismatch does not error — with an admin credential PouchDB simply
creates the misnamed database and replicates an empty vault into it.
Order matters: set the vault up from Obsidian first so the database exists and
carries the plugin's own tweaks, then deploy mars. Afterwards:
```
systemctl status livesync-bridge # on mars
cat /var/lib/livesync-bridge/health.json # per-peer ok/backendUp/detail
ls /var/lib/livesync-bridge/vault # her notes, as real markdown
```
The vault is mounted into the agent container at `/opt/data/vault`, inside
`HERMES_WRITE_SAFE_ROOT`, so luna can write as well as read.
A note luna writes reaches CouchDB as soon as the bridge sees it, but whether
it then reaches your devices depends on that vault's **Sync Mode** in the
plugin. Only "LiveSync (real-time)" pulls continuously; the periodic/on-save
presets need their timer or a manual **Replicate**. A file that appears only
after clicking Replicate is the client waiting, not the bridge failing — the
database already had it. Check the bridge's own side in the journal:
```
journalctl -u livesync-bridge | grep -- '--> luna-remote'
```
⚠️ **Verify her writes actually land before trusting this.** Upstream has three
open issues on the storage→CouchDB direction (#50, #23, #46) and all fail
silently — the log reports the upload and the database never updates. Create a
note as luna, confirm it appears on a phone, and re-check after any input bump.
### mercury (Raspberry Pi 3B+)
- `./deploy flash mercury /dev/sdX` writes the dedicated age key to the root
Generated
+17
View File
@@ -176,6 +176,22 @@
"url": "https://git.mgaction.town/darman/hypr-chrome.git"
}
},
"livesync-bridge": {
"flake": false,
"locked": {
"lastModified": 1787571662,
"narHash": "sha256-btLnQNbFzCPaSVcY9rtiPdYeXrZjoK9AYvfA9+ovsIc=",
"owner": "vrtmrz",
"repo": "livesync-bridge",
"rev": "c3760beaa0851214da4860903445d7f6420ca025",
"type": "github"
},
"original": {
"owner": "vrtmrz",
"repo": "livesync-bridge",
"type": "github"
}
},
"media-manager": {
"flake": false,
"locked": {
@@ -490,6 +506,7 @@
"disko": "disko",
"home-manager": "home-manager",
"hypr-chrome": "hypr-chrome",
"livesync-bridge": "livesync-bridge",
"mediamanager-nix": "mediamanager-nix",
"nix-flatpak": "nix-flatpak",
"nixos-anywhere": "nixos-anywhere",
+12
View File
@@ -31,6 +31,18 @@
url = "github:strangeglyph/mediamanager-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# livesync-bridge — headless CouchDB <-> filesystem sync for Obsidian
# LiveSync, used on mars to give luna a real directory of markdown
# (hosts/mars/livesync-bridge.nix). Not a flake and not in nixpkgs, so it
# comes in as plain source pinned by flake.lock; the service copies it out
# and runs it under deno. Pinning matters more than usual here — this is a
# small third-party project with open bugs on the storage->couchdb path,
# so an unreviewed bump could quietly change how the agent's notes are
# written back.
livesync-bridge = {
url = "github:vrtmrz/livesync-bridge";
flake = false;
};
authentik-nix.url = "github:nix-community/authentik-nix";
nix-flatpak.url = "github:gmodena/nix-flatpak";
# Own Hyprland plugin (border + title bar), public repo, fetched over
+2
View File
@@ -14,6 +14,7 @@
../../services/network/caddy.nix
../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/monitoring/victoriametrics.nix
../../services/media/jellyfin.nix
../../services/media/sabnzbd.nix
../../services/media/prowlarr.nix
@@ -23,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
+17
View File
@@ -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}
'';
};
}
+1
View File
@@ -8,6 +8,7 @@
./disk-config.nix # disko: OS-disk partitions + filesystems
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
./hermes-agent.nix
./livesync-bridge.nix
../../common.nix # shared base: user / ssh / nix / firewall
../../services/containers.nix
../../services/vpn/tailscale.nix
+8 -3
View File
@@ -27,9 +27,14 @@ this route subscribes to map back to a review type here:
pull_request_comment pull_request_review_comment review with a body
pull_request_rejected pull_request_review_rejected changes requested
Approvals (wire pull_request_approved) are not subscribed, so
pull_request_review_approved is not in ALLOWED_REVIEW_TYPES: an approval is
darman signing off, not asking for work. Add both to widen it.
Approvals DO reach the gitea hook: its api-level `pull_request_review` event
is a single switch for all three review types and cannot be narrowed (HasEvent
in models/webhook/webhook.go collapses them onto it). They get dropped one
step earlier than this script instead -- "pull_request_approved" is not in the
route's event list, so Hermes ignores those deliveries on the event match,
before the script runs. That is why pull_request_review_approved is absent
from ALLOWED_REVIEW_TYPES below: an approval is darman signing off, not asking
for work. Widening means adding it in both places.
"""
import json
import sys
+31 -14
View File
@@ -278,6 +278,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
@@ -367,24 +374,34 @@ in
# the file (mtime-gated) on the next delivery — no container restart, and no
# `podman exec` quoting chain between nix and the prompt text.
#
# Events are WIRE names (X-GitHub-Event), not subscription names. Gitea uses
# the same strings in two namespaces and they collide — from
# HookEventType.Event() in modules/webhook/type.go:
# Events are WIRE names (X-GitHub-Event). Gitea spells the same events three
# different ways and two of the spellings collide — from
# HookEventType.Event() in modules/webhook/type.go, and updateHookEvents in
# routers/api/v1/utils/hook.go for the api column:
#
# subscription name wire name what it is
# --------------------------- ---------------------- ------------------
# issue_comment issue_comment comment on an issue
# pull_request_comment issue_comment comment on a PR
# pull_request_review_comment pull_request_comment review with a body
# pull_request_review_rejected pull_request_rejected changes requested
# pull_request_review_approved pull_request_approved approval
# HookEventType wire name (here) api name (gitea.nix)
# --------------------------- ---------------------- --------------------
# issue_comment issue_comment issue_comment
# pull_request_comment issue_comment pull_request_comment
# pull_request_review_comment pull_request_comment pull_request_review
# pull_request_review_rejected pull_request_rejected pull_request_review
# pull_request_review_approved pull_request_approved pull_request_review
#
# The hooks' `events` arrays in services/dev/gitea.nix take the SUBSCRIPTION
# name; Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So
# Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So
# "pull_request_comment" HERE means a review and "issue_comment" HERE means
# a comment — the exact inversion of how they read. X-GitHub-Event-Type
# carries the subscription name, but Hermes does not look at it. Both files
# therefore name the same event differently on purpose; neither is a typo.
# carries the HookEventType, but Hermes does not look at it. This file and
# services/dev/gitea.nix therefore name the same event differently on
# purpose; neither is a typo.
#
# The api column is not a third alias but a coarser set: HasEvent
# (models/webhook/webhook.go) collapses all three review types onto
# pull_request_review, so the gitea hook cannot subscribe them separately.
# Approvals arrive here as a result and are dropped by NOT being in
# prReviewEvents — Hermes answers {"status": "ignored"} on the event match,
# before the filter script and before any LLM call. Widening to approvals is
# a mars-side change only: add "pull_request_approved" to prReviewEvents and
# "pull_request_review_approved" to the filter's ALLOWED_REVIEW_TYPES.
#
# issue_comment on the wire covers comments on plain issues too; the hook
# does not subscribe those, and the comment filter's is_pull check drops
+187
View File
@@ -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";
};
};
}
+32
View File
@@ -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 = { };
}
+56
View File
@@ -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).
+3 -2
View File
@@ -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
+4 -2
View File
@@ -6,6 +6,8 @@ telegram_bot_token: ENC[AES256_GCM,data:WX+KFtoqFodkoWNwd7EXUrUJakZ9oaMZgg4OnCeL
hermes_dashboard_oidc_client_secret: ENC[AES256_GCM,data:IMPNTPMKO+b7eyV4hyGfnvH1/i+W4IPDNjncoyB1oIV8WaB6nOJn0sSEuTUCKB94K+Y7bsVQU0zpbKdIYOdGqgmPzwMCsScxMt4SewTmiiqWxv6SQFf4EzMxgXqjMvH8PWDzLcI2C2tI/KcVS251iqRViOTFe1/tkm+mV8sJmEI=,iv:F/rOUDmJZoGPS9fObAni5ntyOqbbhMWDPdHGLTexwlA=,tag:ALf98DmB0JziGspZMiLCiw==,type:str]
gitea_luna_token: ENC[AES256_GCM,data:0ypW9oVFs1mXYPhPareMFRdkSYcvHSCm+fQOd7/76lJEXi217r9dmg==,iv:j3TPm/iLk6pB6CmDePFBOlnhxWSbmLKvOhz06SM1T7k=,tag:ydErvC2mZ1RRnwNffiHkkg==,type:str]
gitea_hermes_webhook_secret: ENC[AES256_GCM,data:lV78H0xAehPxusSO/QruOYkt7fkMJrW+ScZL4UWYvgnBGn/D+1XHYPyHCqe2sEEWSlIaAgWMMoZzoVJ1Z1NFVQ==,iv:GmTZxoH2iiL/vTVgPfziXIFYD+Rl3cbh9hqXvWps+iw=,tag:jtXEUOVFfKrpTRK7S9PZMA==,type:str]
couchdb_luna_password: ENC[AES256_GCM,data:V91is2h7UskI1rtwMzQyduNXoDPTYNwa4sw9K9WU+wE=,iv:k976ImKR19+CvGOVsHsrqMaFFtSQxVi6zABCJuQ5AWE=,tag:W2SN4SET/+o4+Wt6BOJ1LA==,type:str]
obsidian_luna_passphrase: ENC[AES256_GCM,data:fqHtP3g4J40ddYL9lzCixrisdC/DEJJermE=,iv:FU6BGNcBnyP8Rz3dNBk0+K0aAQTDW6/0aVaFm1rBFkk=,tag:Mg1aKqh++2rmU8ROaVIgDw==,type:str]
sops:
age:
- enc: |
@@ -26,7 +28,7 @@ sops:
oyJ7PS3lW+PxH5AZkeeU7gXO/pz2oDku0aDOds7kaD3n0+qSWicQ+Q==
-----END AGE ENCRYPTED FILE-----
recipient: age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk
lastmodified: "2026-08-23T05:55:49Z"
mac: ENC[AES256_GCM,data:a3vCmrQMCS25tNWrzTeiGmOHf4Fn356PO3uNa2HvS21EBCKTc6YWBj9KmpORdz+6t03JJe/4eiGdghGaLhRr+JXyQnaT54gSV+FhC3dH6blind746XN3h+Z9rxiva6apvcAGUZ9k01Js5IXN9efEMhcI6w0U4oVuVqtvShvg8A8=,iv:9kF3cJ1vyy2H3eH10DVCYmWeXv2MH4AFDiF8cOajlw4=,tag:zhombLVpL8M1TUtYur/gYQ==,type:str]
lastmodified: "2026-08-25T21:51:24Z"
mac: ENC[AES256_GCM,data:dz249hf3w8Tn0JStFOhhpdCZFMx2yxmNABx1CbeIQ/JlICAU82e4fg8AzJQY9EMOEs3Zx6L61yieljD4A/HLip5rDVlAXqqLeklW60eb7BHuSO79YfFgot+rS05g8WqFkRJYWzmkXhMbErCI133n72XEdeqMUU/m0djOUZlVRLs=,iv:d7G6TJRLfmXvQ2BUG9Hi83lOB9anhmb9qneLeVSCBhc=,tag:F6X/0z00PjiYum5kf8YApA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+28 -22
View File
@@ -25,30 +25,36 @@ let
# dispatches on (http://mars.orbit.sol:8644/webhooks/<route>), so it must
# match a key in the route config that hosts/mars/hermes-agent.nix writes.
#
# `events` are SUBSCRIPTION names, and gitea reuses these strings in a
# second, colliding namespace on the wire — see the long comment on the
# route unit in hosts/mars/hermes-agent.nix. "pull_request_comment" HERE
# means a timeline comment on a pull request; the same string in
# X-GitHub-Event means a review. The two files therefore name the same
# event differently on purpose, and neither is a typo:
# `events` are the strings gitea's HOOK API accepts. That set is coarser
# than gitea's internal HookEventType set, and both collide on spelling with
# the wire names Hermes matches on — three namespaces, one of which is a
# trap. From routers/api/v1/utils/hook.go (updateHookEvents),
# models/webhook/webhook.go (HasEvent) and modules/webhook/type.go (Event()):
#
# here (subscription) there (route events)
# ---------------------------- --------------------
# pull_request_comment issue_comment
# pull_request_review_comment pull_request_comment
# pull_request_review_rejected pull_request_rejected
# api event (here) delivers wire name (mars route)
# -------------------- ------------------- ----------------------
# pull_request_comment comment on a PR issue_comment
# pull_request_review review with a body pull_request_comment
# changes requested pull_request_rejected
# approval pull_request_approved
#
# Hermes would drop everything else anyway (each route matches on
# X-GitHub-Event before any LLM call, and then runs a filter script), so
# subscribing narrowly here is defence in depth rather than the only gate:
# it keeps traffic that can never be acted on from crossing the wire and
# reaching the agent's process at all.
# So this file and hosts/mars/hermes-agent.nix name the same event
# differently on purpose, and neither is a typo.
#
# Approvals (pull_request_review_approved) are deliberately absent: an
# approval is darman signing off, not asking for work, and waking an agent
# run on every LGTM is pure cost. Adding it means adding it BOTH here and
# to prReviewEvents/ALLOWED_REVIEW_TYPES on mars — as "pull_request_approved"
# there, per the table above.
# THE TRAP: updateHookEvents silently ignores strings it does not recognise,
# so a plausible-looking but non-API name leaves the hook registered with no
# events at all, delivering nothing and reporting no error. That is exactly
# what "pull_request_review_comment" did here — a real HookEventType, and a
# real value of X-GitHub-Event-Type, but not an API event name.
#
# There is no narrower name for reviews: HasEvent collapses approved,
# rejected and review-comment onto HookEventPullRequestReview, so
# `pull_request_review` is a single switch for all three. Approvals
# therefore cannot be excluded here. They are dropped on the mars side
# instead — the route's event list has no "pull_request_approved", so Hermes
# answers {"status": "ignored"} without running the filter or spending a
# token. Expect approvals in gitea's delivery log, answered 200 and ignored;
# that is the design, not a failure.
giteaHermesHooks = [
{
name = "PR comments Hermes";
@@ -58,7 +64,7 @@ let
{
name = "PR reviews Hermes";
route = "gitea-pr-reviews";
events = [ "pull_request_review_comment" "pull_request_review_rejected" ];
events = [ "pull_request_review" ];
}
];
in
+128
View File
@@ -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" ];
}
+116
View File
@@ -0,0 +1,116 @@
{ ... }:
# VictoriaMetrics single-node store for the homelab dashboard on Jupiter. It
# listens on all interfaces, but tailscale.nix makes tailscale0 the only trusted ingress;
# the host firewall therefore keeps :8428 off the LAN and public interfaces.
#
# The scrape targets are the node_exporter instances enabled by
# services/monitoring/node-exporter.nix on every real host. MagicDNS names use
# the tailnet's orbit.sol suffix (see services/vpn/headscale.nix).
{
services.victoriametrics = {
enable = true;
retentionPeriod = "15d";
listenAddress = ":8428";
prometheusConfig = {
global.scrape_interval = "5s";
# Explicit, and equal to the interval on purpose. The Prometheus default
# is 10s, and VictoriaMetrics silently clamps scrape_timeout down to
# scrape_interval rather than erroring — so leaving it implicit means the
# config says 10s while the scraper uses 5s. Say what actually happens.
global.scrape_timeout = "5s";
scrape_configs = [
{
job_name = "node-exporter";
static_configs = [
{
targets = [ "127.0.0.1:9100" ];
labels.host = "jupiter";
}
{
targets = [ "mars.orbit.sol:9100" ];
labels.host = "mars";
}
{
targets = [ "neptun.orbit.sol:9100" ];
labels.host = "neptun";
}
{
targets = [ "terra.orbit.sol:9100" ];
labels.host = "terra";
}
];
}
# mercury is a Pi scraped over the tailnet, so it gets its own job at a
# slower cadence: at the 5s global it would time out (see above) and
# the series would show gaps rather than late samples.
#
# A separate cadence REQUIRES a separate job — scrape_interval is a
# per-job setting and job_name has to be unique — which means mercury's
# `job` label differs from every other host's. Select on `host` (set on
# every target below) rather than job="node-exporter" in dashboards and
# alerts, or mercury drops out of them silently.
{
job_name = "node-exporter-mercury";
scrape_interval = "15s";
scrape_timeout = "10s";
static_configs = [
{
targets = [ "mercury.orbit.sol:9100" ];
labels.host = "mercury";
}
];
}
{
job_name = "victoriametrics";
static_configs = [
{
targets = [ "127.0.0.1:8428" ];
labels.host = "jupiter";
}
];
}
];
};
};
# Start after Tailscale has had a chance to establish MagicDNS. This is only
# ordering, not a hard dependency: VictoriaMetrics still starts locally if
# another host or the tailnet is temporarily unavailable.
systemd.services.victoriametrics.after = [ "tailscaled-autoconnect.service" ];
# Keep the TSDB off jupiter's 29G eMMC. The module hardcodes
# -storageDataPath=/var/lib/<stateDir> and runs DynamicUser, so without this
# the data lands on the OS disk — a continuous small-write workload aimed at
# the one disk here with no headroom and finite write endurance. Same
# bind-onto-/var/lib/private pattern as prowlarr.nix and seerr.nix; see
# prowlarr.nix for why the mount targets the private path and not the public
# /var/lib/victoriametrics.
#
# `nofail` is NOT optional — again see prowlarr.nix: without it this bind is
# RequiredBy local-fs.target, so an unassembled array drops jupiter into an
# emergency shell that a headless box cannot be rescued from.
fileSystems."/var/lib/private/victoriametrics" = {
device = "/mnt/data/AppData/victoriametrics";
fsType = "none";
options = [ "bind" "nofail" ];
};
# The bind above needs its SOURCE to exist or the mount fails — and because
# it is `nofail` that failure is quiet: RequiresMountsFor below is satisfied
# by /mnt/data itself, so VictoriaMetrics would start regardless and write to
# the eMMC, which is the exact thing the bind exists to prevent. prowlarr.nix
# gets away without this only because its directory predates the module
# (migrated from ZimaOS). This is a fresh service, so it creates its own,
# same as seerr.nix. 0755 darman:users matches the other AppData dirs, which
# matters because /mnt/data/AppData itself is drwx--x--- darman:users.
systemd.tmpfiles.rules = [
"d /mnt/data/AppData/victoriametrics 0755 darman users -"
];
# The service path is under /var/lib/private, so systemd would otherwise
# derive its mount dependency from the eMMC-backed path alone.
systemd.services.victoriametrics.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
}