Feat/obsidian livesync #5
@@ -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}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ];
|
||||
}
|
||||
Reference in New Issue
Block a user