Compare commits

3 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
23 changed files with 552 additions and 1223 deletions
-3
View File
@@ -14,6 +14,3 @@ keys.txt
# local env (PATH etc.)
.env
# local visual-verification output
.artifacts/
+87
View File
@@ -427,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
-3
View File
@@ -10,9 +10,6 @@ import qs.widgets.systray
import qs.widgets.vitals
Scope {
// Dense multi-monitor status rail; visual core is headlessly renderable.
DenseBar {}
// Left sidebar in the Slant (V6) style — toggle with SUPER CTRL S.
SideBar {}
BarBottom {}
@@ -1,27 +0,0 @@
import QtQuick
import qs.widgets.bar
DenseBarContent {
width: 1920
height: 164
now: new Date(2026, 7, 27, 21, 47, 0)
hostName: "TERRA"
telemetryReady: true
ratesReady: true
cpuFraction: 0.62
cpuThreads: 16
memoryFraction: 0.78
memoryUsedText: "24.9G"
temperatureCelsius: 54
networkInterface: "enp7s0"
networkRxText: "842.6M/S"
networkTxText: "116.2M/S"
storageFraction: 0.69
storageFreeText: "1.82T FREE"
workspaceIds: [1, 2, 3, 4]
activeWorkspaceId: 1
trayCount: 3
audioFraction: 0.50
audioMuted: false
}
-155
View File
@@ -1,155 +0,0 @@
import QtQuick
import QtQuick.Shapes
Item {
id: root
implicitWidth: 720
implicitHeight: 120
readonly property color voidColor: "#0a0a0a"
readonly property color inkColor: "#dedede"
readonly property color mutedColor: "#858585"
readonly property color accentColor: "#e8722a"
Rectangle {
anchors.fill: parent
color: root.voidColor
}
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.voidColor
strokeColor: Qt.rgba(0.87, 0.87, 0.87, 0.28)
strokeWidth: 1
startX: 1
startY: 1
PathLine { x: root.width - 14; y: 1 }
PathLine { x: root.width - 1; y: 14 }
PathLine { x: root.width - 1; y: root.height - 9 }
PathLine { x: root.width - 9; y: root.height - 1 }
PathLine { x: 17; y: root.height - 1 }
PathLine { x: 1; y: root.height - 17 }
PathLine { x: 1; y: 1 }
}
ShapePath {
fillColor: root.accentColor
strokeWidth: 0
startX: 1
startY: 1
PathLine { x: 92; y: 1 }
PathLine { x: 92; y: 3 }
PathLine { x: 1; y: 3 }
PathLine { x: 1; y: 1 }
}
}
Rectangle {
x: 14
y: 12
width: 29
height: 14
color: root.accentColor
Text {
anchors.centerIn: parent
text: "001"
color: root.voidColor
font.pixelSize: 8
font.bold: true
}
}
Text {
x: 52
y: 13
text: "HEADLESS RENDER ARRAY"
color: root.inkColor
font.family: "monospace"
font.pixelSize: 9
font.bold: true
font.letterSpacing: 1.4
}
Rectangle {
x: 14
y: 34
width: root.width - 28
height: 1
color: root.inkColor
opacity: 0.18
}
Text {
x: 16
y: 48
text: "ORBITAL"
color: root.inkColor
font.family: "sans-serif-condensed"
font.pixelSize: 28
font.bold: true
font.letterSpacing: 2
}
Text {
x: 18
y: 80
text: "QML // GRABTOIMAGE // SOFTWARE RHI"
color: root.accentColor
font.family: "monospace"
font.pixelSize: 7
font.letterSpacing: 1.4
}
Row {
x: 250
y: 56
spacing: 4
Repeater {
model: 16
Rectangle {
required property int index
width: 20
height: 12
color: index < 11 ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.06)
border.width: 1
border.color: index < 11 ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.2)
}
}
}
Text {
x: 250
y: 78
text: "RENDER PIPELINE"
color: root.mutedColor
font.family: "monospace"
font.pixelSize: 7
font.letterSpacing: 1.1
}
Text {
x: 585
y: 77
text: "68.75%"
color: root.inkColor
font.family: "monospace"
font.pixelSize: 15
font.bold: true
}
Rectangle {
x: 14
y: root.height - 9
width: root.width - 28
height: 3
color: root.accentColor
opacity: 0.6
}
}
@@ -1,86 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import Quickshell.Services.Pipewire
import Quickshell.Services.SystemTray
import QtQuick
import qs.widgets.vitals
// Production/layer-shell adapter. All visual composition lives in the
// Item-rooted DenseBarContent so the exact bar can be rendered headlessly.
Scope {
id: root
readonly property PwNode sink: Pipewire.defaultAudioSink
readonly property real volume: sink?.audio?.volume ?? 0
readonly property bool muted: sink?.audio?.muted ?? false
readonly property var workspaceIds: Hyprland.workspaces.values
.filter(workspace => workspace.id > 0)
.map(workspace => workspace.id)
readonly property int activeWorkspaceId: Hyprland.focusedWorkspace?.id ?? 1
readonly property var rootDisk: vitals.disks.length > 0 ? vitals.disks[0] : null
PwObjectTracker {
objects: [root.sink]
}
VitalsData {
id: vitals
active: true
}
Variants {
model: Quickshell.screens
PanelWindow {
id: window
required property var modelData
screen: modelData
color: "transparent"
implicitHeight: 164
anchors {
top: true
left: true
right: true
}
DenseBarContent {
anchors.fill: parent
hostName: vitals.host || "LOCAL"
telemetryReady: vitals.ready && !vitals.failed
ratesReady: vitals.ratesReady && !vitals.failed
cpuFraction: vitals.cpu
cpuThreads: vitals.cpuThreads
memoryFraction: vitals.memTotal > 0 ? vitals.memUsed / vitals.memTotal : 0
memoryUsedText: vitals.fmtBytes(vitals.memUsed)
temperatureCelsius: vitals.cpuTemp
networkInterface: vitals.netIface || "NET"
networkRxText: vitals.fmtRate(vitals.netRx)
networkTxText: vitals.fmtRate(vitals.netTx)
storageFraction: root.rootDisk && root.rootDisk.size > 0
? root.rootDisk.used / root.rootDisk.size : 0
storageFreeText: root.rootDisk
? vitals.fmtBytes(root.rootDisk.size - root.rootDisk.used) + " FREE"
: "-- FREE"
workspaceIds: root.workspaceIds.length > 0 ? root.workspaceIds : [1, 2, 3, 4]
activeWorkspaceId: root.activeWorkspaceId
trayCount: SystemTray.items.values.length
audioFraction: Math.max(0, Math.min(1, root.volume))
audioMuted: root.muted
onWorkspaceActivated: workspaceId => {
const workspace = Hyprland.workspaces.values.find(item => item.id === workspaceId);
if (workspace)
workspace.activate();
else
Hyprland.dispatch("workspace " + workspaceId);
}
}
}
}
}
@@ -1,799 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
// Renderable visual core for the desktop telemetry rail. Runtime services stay
// in DenseBar.qml so this Item can be exercised without Wayland or Hyprland.
Item {
id: root
implicitWidth: 1920
implicitHeight: 164
readonly property color voidColor: "#0a0a0a"
readonly property color inkColor: "#dedede"
readonly property color mutedColor: "#858585"
readonly property color accentColor: "#e8722a"
readonly property color hairColor: Qt.rgba(0.87, 0.87, 0.87, 0.28)
readonly property bool compact: width <= 1400
readonly property string displayFont: "DepartureMono Nerd Font"
readonly property string microFont: "DejaVu Sans Mono"
property bool autoClock: true
property date now: new Date()
property string hostName: "LOCAL"
property bool telemetryReady: false
property bool ratesReady: false
property real cpuFraction: 0
property int cpuThreads: 0
property real memoryFraction: 0
property string memoryUsedText: "--"
property real temperatureCelsius: NaN
property string networkInterface: "NET"
property string networkRxText: "--"
property string networkTxText: "--"
property real storageFraction: 0
property string storageFreeText: "-- FREE"
property var workspaceIds: [1, 2, 3, 4]
property int activeWorkspaceId: 1
property int trayCount: 0
property real audioFraction: 0
property bool audioMuted: false
signal workspaceActivated(int workspaceId)
function pct(value, ready) {
return ready ? Math.round(Math.max(0, Math.min(1, value)) * 100) : 0;
}
function two(value) {
return value < 10 ? "0" + value : String(value);
}
function timeText(value) {
return root.two(value.getHours()) + ":" + root.two(value.getMinutes());
}
function dateText(value) {
const days = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
return days[value.getDay()] + " // " + root.two(value.getDate()) + " " + months[value.getMonth()];
}
Timer {
interval: 30000
running: root.autoClock
repeat: true
triggeredOnStart: true
onTriggered: root.now = new Date()
}
Rectangle {
anchors.fill: parent
color: root.voidColor
}
// Faint drafting grid; no gradient and deliberately subordinate to data.
Repeater {
model: Math.ceil(root.width / 40)
Rectangle {
required property int index
x: index * 40
width: 1
height: root.height
color: root.inkColor
opacity: 0.018
}
}
Repeater {
model: Math.ceil(root.height / 40)
Rectangle {
required property int index
y: index * 40
width: root.width
height: 1
color: root.inkColor
opacity: 0.018
}
}
Item {
id: array
x: 12
y: 8
width: root.width - 24
height: 92
readonly property real identityWidth: root.compact ? 250 : 320
readonly property real radarWidth: root.compact ? 130 : 164
readonly property real stateWidth: root.compact ? 210 : 250
readonly property real flexWidth: Math.max(250, (width - identityWidth - radarWidth - stateWidth - 32) / 2)
Row {
anchors.fill: parent
spacing: 8
TelemetryPanel {
width: array.identityWidth
height: array.height
panelId: "001"
title: "COMMAND LAYER"
Text {
x: 12
y: 34
text: root.hostName.toUpperCase()
color: root.inkColor
font.family: root.displayFont
font.pixelSize: root.compact ? 20 : 25
font.bold: true
font.letterSpacing: 2
elide: Text.ElideRight
width: parent.width - 92
}
Text {
x: 13
y: 63
text: "STATUS ARRAY // " + (root.telemetryReady ? "LIVE" : "STANDBY")
color: root.accentColor
font.family: root.microFont
font.pixelSize: 7
font.letterSpacing: 1.4
}
Rectangle {
x: parent.width - 75
y: 36
width: 1
height: 43
color: root.hairColor
}
Rectangle {
x: parent.width - 62
y: 42
width: 5
height: 5
color: root.accentColor
opacity: root.telemetryReady ? 1 : 0.35
}
Text {
x: parent.width - 50
y: 38
text: root.telemetryReady ? "ONLINE" : "LOCAL"
color: root.accentColor
font.family: root.microFont
font.pixelSize: 7
}
Text {
x: parent.width - 63
y: 57
text: "NODE // " + (root.hostName || "--").toUpperCase().slice(0, 7)
color: root.mutedColor
font.family: root.microFont
font.pixelSize: 6
}
Shape {
x: parent.width - 63
y: 71
width: 48
height: 10
ShapePath {
fillColor: "transparent"
strokeColor: root.accentColor
strokeWidth: 1
startX: 0; startY: 6
PathLine { x: 11; y: 6 }
PathLine { x: 16; y: 1 }
PathLine { x: 22; y: 9 }
PathLine { x: 27; y: 6 }
PathLine { x: 48; y: 6 }
}
}
}
TelemetryPanel {
width: array.flexWidth
height: array.height
panelId: "02"
title: "RESOURCE LATTICE"
meta: root.telemetryReady ? "REALTIME" : "FALLBACK"
Row {
x: 11
y: 32
width: parent.width - 22
height: 51
spacing: root.compact ? 7 : 12
MetricBlock {
width: (parent.width - parent.spacing * (root.compact ? 1 : 2)) / (root.compact ? 2 : 3)
label: "CPU"
value: root.cpuFraction
ready: root.ratesReady
readout: root.ratesReady ? root.pct(root.cpuFraction, true) + "%" : "--"
detailLeft: "CORE:" + (root.cpuThreads || "--")
detailRight: root.ratesReady ? "BUSY" : "PRIME"
}
MetricBlock {
width: (parent.width - parent.spacing * (root.compact ? 1 : 2)) / (root.compact ? 2 : 3)
label: "MEM"
value: root.memoryFraction
ready: root.telemetryReady
readout: root.telemetryReady ? root.pct(root.memoryFraction, true) + "%" : "--"
detailLeft: "ALLOC"
detailRight: root.memoryUsedText
}
MetricBlock {
visible: !root.compact
width: (parent.width - parent.spacing * 2) / 3
label: "THERM"
value: isFinite(root.temperatureCelsius) ? root.temperatureCelsius / 100 : 0
ready: isFinite(root.temperatureCelsius)
readout: isFinite(root.temperatureCelsius) ? Math.round(root.temperatureCelsius) + "°C" : "--"
detailLeft: "ZONE:01"
detailRight: isFinite(root.temperatureCelsius) && root.temperatureCelsius >= 85 ? "HOT" : "NOMINAL"
}
}
}
TelemetryPanel {
width: array.radarWidth
height: array.height
panelId: "03"
title: "SCAN"
RadarGauge {
anchors.horizontalCenter: parent.horizontalCenter
y: 28
size: 57
level: root.cpuFraction
}
}
TelemetryPanel {
width: array.flexWidth
height: array.height
panelId: "04"
title: "CARRIER UPLINK"
meta: root.networkInterface.toUpperCase()
NetworkTrace {
x: 12
y: 43
width: parent.width - (root.compact ? 117 : 145)
height: 33
level: root.ratesReady ? root.cpuFraction : 0.25
}
Column {
anchors.right: parent.right
anchors.rightMargin: 14
y: 34
width: root.compact ? 92 : 116
spacing: 1
Text {
width: parent.width
text: root.networkRxText
horizontalAlignment: Text.AlignRight
color: root.accentColor
font.family: root.displayFont
font.pixelSize: root.compact ? 11 : 13
font.bold: true
elide: Text.ElideLeft
}
MicroText { width: parent.width; text: "RX // DOWN"; horizontalAlignment: Text.AlignRight }
Text {
width: parent.width
text: root.networkTxText
horizontalAlignment: Text.AlignRight
color: root.inkColor
font.family: root.displayFont
font.pixelSize: root.compact ? 11 : 13
font.bold: true
elide: Text.ElideLeft
}
MicroText { width: parent.width; text: "TX // UP"; horizontalAlignment: Text.AlignRight }
}
}
TelemetryPanel {
width: array.stateWidth
height: array.height
panelId: "05"
title: "SYSTEM STATE"
Row {
x: 10
y: 35
spacing: 6
StateCell { code: "N"; active: root.ratesReady; label: "NET" }
StateCell { code: root.audioMuted ? "M" : "A"; active: !root.audioMuted; label: "AUD" }
StateCell { visible: !root.compact; code: String(root.trayCount); active: root.trayCount > 0; label: "TRAY" }
}
Rectangle {
x: root.compact ? 101 : 151
y: 33
width: 1
height: 46
color: root.hairColor
}
Column {
anchors.right: parent.right
anchors.rightMargin: 12
y: 34
width: root.compact ? 91 : 82
spacing: 2
Text {
width: parent.width
text: root.timeText(root.now)
horizontalAlignment: Text.AlignRight
color: root.inkColor
font.family: root.displayFont
font.pixelSize: root.compact ? 20 : 22
font.bold: true
font.letterSpacing: 1
}
Text {
width: parent.width
text: root.dateText(root.now)
horizontalAlignment: Text.AlignRight
color: root.accentColor
font.family: root.microFont
font.pixelSize: 6
elide: Text.ElideLeft
}
MicroText { width: parent.width; text: "LOCAL TIME"; horizontalAlignment: Text.AlignRight }
}
}
}
}
TelemetryPanel {
id: rail
x: 12
y: 108
width: root.width - 24
height: 34
showHeader: false
chamfer: 10
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
RailSection {
width: root.compact ? 252 : 310
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "06 // DESKTOP"
color: root.accentColor
font.family: root.microFont
font.pixelSize: 7
font.letterSpacing: 1
}
Row {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Repeater {
model: root.workspaceIds.slice(0, root.compact ? 4 : 6)
Rectangle {
required property var modelData
width: 24
height: 17
color: Number(modelData) === root.activeWorkspaceId ? Qt.rgba(0.91, 0.45, 0.16, 0.18) : "transparent"
border.width: 1
border.color: Number(modelData) === root.activeWorkspaceId ? root.accentColor : root.hairColor
Text {
anchors.centerIn: parent
text: root.two(Number(parent.modelData))
color: Number(parent.modelData) === root.activeWorkspaceId ? root.accentColor : root.mutedColor
font.family: root.microFont
font.pixelSize: 7
}
MouseArea {
anchors.fill: parent
onClicked: root.workspaceActivated(Number(parent.modelData))
}
}
}
}
}
RailSection {
width: root.compact ? 260 : 410
MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: "PROCESS" }
Text {
x: 76
anchors.verticalCenter: parent.verticalCenter
text: "COMPOSITOR // NODE_EXPORTER // SHELL"
color: root.telemetryReady ? root.inkColor : root.mutedColor
font.family: root.microFont
font.pixelSize: 7
elide: Text.ElideRight
width: parent.width - 84
}
}
RailSection {
width: root.compact ? 300 : 385
MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: "VOL // ROOT" }
Text {
x: 93
anchors.verticalCenter: parent.verticalCenter
text: root.storageFreeText
color: root.inkColor
font.family: root.microFont
font.pixelSize: 8
}
SegmentMeter {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: root.compact ? 95 : 170
height: 7
segments: root.compact ? 10 : 16
value: root.storageFraction
ready: root.telemetryReady
}
}
RailSection {
visible: !root.compact
width: 350
MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: "AUDIO BUS" }
Text {
x: 89
anchors.verticalCenter: parent.verticalCenter
text: root.audioMuted ? "MUTED" : Math.round(root.audioFraction * 100) + "%"
color: root.audioMuted ? root.mutedColor : root.accentColor
font.family: root.microFont
font.pixelSize: 8
}
SegmentMeter {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 155
height: 7
segments: 16
value: root.audioFraction
ready: true
}
}
RailSection {
width: Math.max(150, rail.width - (root.compact ? 812 : 1455) - 20)
borderVisible: false
MicroText { x: 9; anchors.verticalCenter: parent.verticalCenter; text: root.compact ? "SYS // " + root.timeText(root.now) : "EVENTS" }
Text {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: root.telemetryReady ? "WARN:00 // ERR:00" : "TELEMETRY WAIT"
color: root.telemetryReady ? root.mutedColor : root.accentColor
font.family: root.microFont
font.pixelSize: 7
elide: Text.ElideLeft
width: parent.width - 64
horizontalAlignment: Text.AlignRight
}
}
}
}
// Bottom routing trace and caution hatch.
Shape {
x: 12
y: 150
width: root.width - 24
height: 12
ShapePath {
fillColor: "transparent"
strokeColor: Qt.rgba(0.91, 0.45, 0.16, 0.55)
strokeWidth: 1
startX: 0; startY: 1
PathLine { x: 220; y: 1 }
PathLine { x: 232; y: 11 }
PathLine { x: 330; y: 11 }
}
}
Row {
x: 360
y: 151
width: root.width - 372
height: 3
spacing: 5
clip: true
Repeater {
model: Math.ceil(parent.width / 10)
Rectangle {
required property int index
width: 5
height: 3
color: index % 2 === 0 ? root.accentColor : "transparent"
opacity: 0.58
}
}
}
component MicroText: Text {
color: root.mutedColor
font.family: root.microFont
font.pixelSize: 6
font.letterSpacing: 0.7
}
component RailSection: Item {
property bool borderVisible: true
height: rail.height
Rectangle {
visible: parent.borderVisible
anchors.right: parent.right
width: 1
height: parent.height
color: root.hairColor
opacity: 0.65
}
}
component TelemetryPanel: Item {
id: panel
property string panelId: ""
property string title: ""
property string meta: ""
property bool showHeader: true
property int chamfer: 13
Shape {
id: panelShape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.voidColor
strokeColor: root.hairColor
strokeWidth: 1
startX: 1; startY: 1
PathLine { x: panelShape.width - panel.chamfer; y: 1 }
PathLine { x: panelShape.width - 1; y: panel.chamfer }
PathLine { x: panelShape.width - 1; y: panelShape.height - 8 }
PathLine { x: panelShape.width - 8; y: panelShape.height - 1 }
PathLine { x: 16; y: panelShape.height - 1 }
PathLine { x: 1; y: panelShape.height - 16 }
PathLine { x: 1; y: 1 }
}
ShapePath {
fillColor: root.accentColor
strokeWidth: 0
startX: 1; startY: 1
PathLine { x: Math.min(49, panelShape.width / 3); y: 1 }
PathLine { x: Math.min(49, panelShape.width / 3); y: 3 }
PathLine { x: 1; y: 3 }
PathLine { x: 1; y: 1 }
}
}
Rectangle {
visible: panel.showHeader
x: 1; y: 20
width: parent.width - 2
height: 1
color: root.inkColor
opacity: 0.12
}
Rectangle {
visible: panel.showHeader
x: 9; y: 5
width: panel.panelId.length > 2 ? 29 : 24
height: 11
color: root.accentColor
Text {
anchors.centerIn: parent
text: panel.panelId
color: root.voidColor
font.family: root.microFont
font.pixelSize: 7
font.bold: true
}
}
Text {
visible: panel.showHeader
x: 40; y: 5
width: parent.width - 105
text: panel.title
color: root.inkColor
font.family: root.displayFont
font.pixelSize: 9
font.bold: true
font.letterSpacing: 1.1
elide: Text.ElideRight
}
MicroText {
visible: panel.showHeader && panel.meta.length > 0
anchors.right: parent.right
anchors.rightMargin: 12
y: 6
text: panel.meta
width: Math.min(80, parent.width / 4)
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
Row {
visible: panel.showHeader
anchors.right: parent.right
anchors.rightMargin: 10
y: 14
spacing: 2
Repeater {
model: 5
Rectangle { required property int index; width: 3; height: 1; color: root.accentColor }
}
}
}
component MetricBlock: Item {
id: metric
property string label: ""
property real value: 0
property bool ready: false
property string readout: "--"
property string detailLeft: ""
property string detailRight: ""
height: 51
MicroText { x: 0; y: 1; text: metric.label; font.pixelSize: 7 }
Text {
anchors.right: parent.right
y: -3
text: metric.readout
color: root.inkColor
font.family: root.displayFont
font.pixelSize: root.compact ? 14 : 17
font.bold: true
}
SegmentMeter {
x: 0; y: 19
width: parent.width
height: 9
segments: 10
value: metric.value
ready: metric.ready
}
MicroText { x: 0; y: 34; text: metric.detailLeft }
MicroText { anchors.right: parent.right; y: 34; text: metric.detailRight; horizontalAlignment: Text.AlignRight }
}
component SegmentMeter: Row {
id: meter
property int segments: 10
property real value: 0
property bool ready: false
spacing: 2
Repeater {
model: meter.segments
Rectangle {
required property int index
width: Math.max(2, (meter.width - (meter.segments - 1) * meter.spacing) / meter.segments)
height: meter.height
readonly property bool on: meter.ready && index < Math.round(Math.max(0, Math.min(1, meter.value)) * meter.segments)
color: on ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.06)
border.width: 1
border.color: on ? root.accentColor : Qt.rgba(0.87, 0.87, 0.87, 0.18)
}
}
}
component StateCell: Item {
id: state
property string code: "--"
property string label: ""
property bool active: false
width: root.compact ? 39 : 43
height: 42
Shape {
anchors.fill: parent
ShapePath {
fillColor: state.active ? Qt.rgba(0.91, 0.45, 0.16, 0.18) : "transparent"
strokeColor: state.active ? root.accentColor : root.hairColor
strokeWidth: 1
startX: 1; startY: 1
PathLine { x: parent.width - 7; y: 1 }
PathLine { x: parent.width - 1; y: 7 }
PathLine { x: parent.width - 1; y: parent.height - 1 }
PathLine { x: 7; y: parent.height - 1 }
PathLine { x: 1; y: parent.height - 7 }
PathLine { x: 1; y: 1 }
}
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
y: 7
text: state.code
color: state.active ? root.accentColor : root.inkColor
font.family: root.displayFont
font.pixelSize: 13
font.bold: true
}
MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 27; text: state.label }
}
component RadarGauge: Item {
id: radar
property real size: 58
property real level: 0
width: size
height: size
Repeater {
model: [1, 0.72, 0.36]
Rectangle {
required property int index
required property var modelData
anchors.centerIn: parent
width: radar.size * Number(modelData)
height: width
radius: width / 2
color: "transparent"
border.width: 1
border.color: index === 0 ? root.accentColor : root.hairColor
}
}
Rectangle { x: 0; y: parent.height / 2; width: parent.width; height: 1; color: root.hairColor }
Rectangle { x: parent.width / 2; y: 0; width: 1; height: parent.height; color: root.hairColor }
Rectangle { x: 12; y: 18; width: 4; height: 4; color: root.accentColor }
Rectangle { x: parent.width - 14; y: parent.height - 20; width: 4; height: 4; color: root.accentColor }
Rectangle { x: parent.width / 2; y: parent.height - 11; width: 4; height: 4; color: root.accentColor }
MicroText { anchors.right: parent.right; y: 3; text: "R:" + Math.round(radar.level * 99); color: root.accentColor }
}
component NetworkTrace: Item {
id: trace
property real level: 0
Rectangle { x: 0; y: parent.height - 1; width: parent.width; height: 1; color: root.hairColor }
Rectangle { x: 0; y: 0; width: 1; height: parent.height; color: root.hairColor }
Shape {
anchors.fill: parent
ShapePath {
fillColor: "transparent"
strokeColor: root.accentColor
strokeWidth: 1.2
startX: 0; startY: trace.height * 0.65
PathLine { x: trace.width * 0.10; y: trace.height * 0.65 }
PathLine { x: trace.width * 0.15; y: trace.height * (0.25 + trace.level * 0.15) }
PathLine { x: trace.width * 0.21; y: trace.height * 0.78 }
PathLine { x: trace.width * 0.31; y: trace.height * 0.56 }
PathLine { x: trace.width * 0.43; y: trace.height * 0.62 }
PathLine { x: trace.width * 0.49; y: trace.height * 0.18 }
PathLine { x: trace.width * 0.57; y: trace.height * 0.82 }
PathLine { x: trace.width * 0.68; y: trace.height * 0.58 }
PathLine { x: trace.width * 0.79; y: trace.height * 0.62 }
PathLine { x: trace.width * 0.85; y: trace.height * 0.34 }
PathLine { x: trace.width * 0.91; y: trace.height * 0.75 }
PathLine { x: trace.width; y: trace.height * 0.60 }
}
}
}
}
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
+1
View File
@@ -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
+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
+7
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
+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
+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" ];
}
-38
View File
@@ -1,38 +0,0 @@
# syntax=docker/dockerfile:1
FROM nixos/nix:2.30.2
ARG NIXPKGS_REV=e5bdc4a41d4c072fe1e3787eaa0320a384741d44
ARG QSMCP_REV=76b1b008f0352064121af9334065012e31d80fc2
ENV NIX_CONFIG="experimental-features = nix-command flakes" \
PATH="/nix/var/nix/profiles/default/bin:${PATH}"
RUN nix profile install --profile /nix/var/nix/profiles/default \
"github:NixOS/nixpkgs/${NIXPKGS_REV}#quickshell" \
"github:NixOS/nixpkgs/${NIXPKGS_REV}#nodejs_22"
RUN mkdir -p /opt/qsmcp \
&& git init /opt/qsmcp \
&& git -C /opt/qsmcp remote add origin https://github.com/fedsfarm/qsmcp.git \
&& git -C /opt/qsmcp fetch --depth 1 origin "${QSMCP_REV}" \
&& git -C /opt/qsmcp checkout --detach FETCH_HEAD \
&& test "$(git -C /opt/qsmcp rev-parse HEAD)" = "${QSMCP_REV}" \
&& cd /opt/qsmcp \
&& npm ci --omit=dev \
&& rm -rf /opt/qsmcp/.git
# qsmcp's CLI hard-codes a 1568px safety cap. Desktop bar previews need their
# true target width so responsive layout and text collisions are testable.
RUN node -e 'const fs=require("fs"); const p="/opt/qsmcp/src/index.ts"; const s=fs.readFileSync(p,"utf8"); const old="max_dimension: 1568"; if (!s.includes(old)) throw new Error("qsmcp max_dimension source changed"); fs.writeFileSync(p,s.replace(old,"max_dimension: 4096"))'
ENV QSMCP_SHELL_ROOT=/workspace/quickshell \
QSMCP_QS_BIN=/nix/var/nix/profiles/default/bin/qs \
QT_QUICK_BACKEND=software
COPY entrypoint.sh /usr/local/bin/quickshell-preview
RUN chmod +x /usr/local/bin/quickshell-preview
RUN mkdir -p /workspace/quickshell
WORKDIR /workspace/quickshell
ENTRYPOINT ["/usr/local/bin/quickshell-preview"]
-39
View File
@@ -1,39 +0,0 @@
# Headless Quickshell previews
This directory provides a reproducible Docker wrapper around
[qsmcp](https://github.com/fedsfarm/qsmcp). It renders a Quickshell `Item` to a
PNG with Qt's `grabToImage()` without connecting to the host Wayland session.
Both nixpkgs and qsmcp are pinned in `Dockerfile`. The runner uses
`docker create` plus `docker cp` rather than bind mounts, so it also works with
rootless/remote daemons whose mount namespace cannot see the checkout.
## Smoke test
Start Docker, then run from the repository root:
```sh
./tools/quickshell-preview/render.sh
```
The default output is:
```text
/tmp/homelab-quickshell-preview/smoke.png
```
## Render another Item-rooted component
```sh
./tools/quickshell-preview/render.sh \
tests/DenseBarHeadless.qml \
.artifacts/quickshell-preview/dense-bar-1920.png \
1920 164
```
The component path is relative to `dotfiles/quickshell`. Keep production
`PanelWindow` wrappers thin and put their visual content in an `Item` component
so it can be rendered through this offscreen path.
`PanelWindow`-rooted previews require a nested compositor and are intentionally
outside this first smoke-test image.
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /tmp/qsmcp-runtime /tmp/qsmcp-home /output
chmod 700 /tmp/qsmcp-runtime
export HOME=/tmp/qsmcp-home
export XDG_RUNTIME_DIR=/tmp/qsmcp-runtime
exec /nix/var/nix/profiles/default/bin/node \
/opt/qsmcp/src/index.ts render "$@"
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)"
tool_dir="$repo_root/tools/quickshell-preview"
shell_root="$repo_root/dotfiles/quickshell"
image="homelab-quickshell-preview:qsmcp-76b1b008"
component="${1:-tests/HeadlessSmoke.qml}"
output="${2:-${TMPDIR:-/tmp}/homelab-quickshell-preview/smoke.png}"
width="${3:-720}"
height="${4:-120}"
if ! docker info >/dev/null 2>&1; then
echo "error: Docker-compatible daemon is unavailable" >&2
exit 69
fi
case "$output" in
/*) ;;
*) output="$PWD/$output" ;;
esac
output_dir="$(dirname "$output")"
output_name="$(basename "$output")"
mkdir -p "$output_dir"
container_id=""
cleanup() {
if [[ -n "$container_id" ]]; then
docker rm --force "$container_id" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
docker build --tag "$image" --file "$tool_dir/Dockerfile" "$tool_dir"
container_id="$(docker create \
--network none \
--tmpfs /tmp:rw,noexec,nosuid,size=256m \
"$image" \
"$component" \
--width "$width" \
--height "$height" \
--dpr 1 \
--padding 0 \
--background solid \
--bg '#0a0a0a' \
--out "/output/$output_name")"
# docker cp streams files through the API, so this works with rootless and
# remote daemons whose filesystem cannot see the client's repository path.
docker cp "$shell_root/." "$container_id:/workspace/quickshell"
docker start --attach "$container_id"
docker cp "$container_id:/output/$output_name" "$output"
test -s "$output"
printf 'rendered=%s\n' "$output"