Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1f3e5fc3 | ||
|
|
b4917c0daa | ||
|
|
dc83e8c156 | ||
|
|
60bef752cb | ||
|
|
a1cd6ae6f1 | ||
|
|
99501ce7d2 | ||
|
|
7d63ba95df |
@@ -13,6 +13,7 @@ keys:
|
||||
- &jupiter age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt
|
||||
- &neptun age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp
|
||||
- &terra age1rfcmu6zh40v4260l9hnf8ajs9vly0s06rx3ey76eu78dp9t7getqyhmkut
|
||||
- &mars age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk
|
||||
# mercury (rpi) uses a dedicated age key (SD image, no ssh-host-key delivery);
|
||||
# the private key is dropped on its boot partition after flashing.
|
||||
- &mercury age1cpty7zrgnn6l97upq00w5wa8zcvnkxkdt2jvhlj97jh83exure4slha43t
|
||||
@@ -28,6 +29,9 @@ creation_rules:
|
||||
- path_regex: secrets/terra\.yaml$
|
||||
key_groups:
|
||||
- age: [ *admin, *terra ]
|
||||
- path_regex: secrets/mars\.yaml$
|
||||
key_groups:
|
||||
- age: [ *admin, *mars ]
|
||||
- path_regex: secrets/mercury\.yaml$
|
||||
key_groups:
|
||||
- age: [ *admin, *mercury ]
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services),
|
||||
`neptun` (netcup VPS: public reverse proxy, Authentik, headscale),
|
||||
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop).
|
||||
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site,
|
||||
single-purpose: Hermes Agent only).
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -26,6 +27,9 @@ hosts/
|
||||
vm.nix # VirtualBox test image (jupiter-vbox)
|
||||
neptun/ # netcup public reverse proxy + tailnet node
|
||||
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
||||
mars/ # on-site, single-purpose: Hermes Agent only
|
||||
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
||||
hermes-agent.nix # Hermes Agent (moved here from jupiter)
|
||||
secrets/ # age-encrypted sops files, one per host
|
||||
scripts/ # deploy, edit_secrets
|
||||
```
|
||||
@@ -84,6 +88,42 @@ an installer, partitions via disko, installs.
|
||||
Manual alternative (USB ISO): boot installer, `disko` the disk, then
|
||||
`nixos-install --flake .#jupiter`.
|
||||
|
||||
## First install on mars
|
||||
|
||||
mars is an older x86_64 box (unknown provenance, "got from work"), on-site,
|
||||
running Hermes Agent only (see `hosts/mars/hermes-agent.nix` — moved there
|
||||
from jupiter). Its age recipient, host key
|
||||
(`~/.config/homelab/mars/ssh_host_ed25519_key`), and `secrets/mars.yaml` are
|
||||
already set up, with `darman_password`/`samba_password`/`opencode_go_api_key`/
|
||||
`telegram_bot_token`/`hermes_dashboard_oidc_client_secret` carried over from
|
||||
jupiter's old instance. Two things are still placeholders and MUST be filled
|
||||
in before installing:
|
||||
|
||||
1. **OS disk id** in `hosts/mars/disk-config.nix` (`ls -l /dev/disk/by-id`
|
||||
once you have console/installer access on the box) — same `REPLACE-ME` in
|
||||
`hosts/mars/configuration.nix`'s comment refers to the same disk, but only
|
||||
`disk-config.nix`'s `device` actually needs editing (grub's own device list
|
||||
comes from disko, see that file's comment).
|
||||
2. **`tailscale_authkey`** in `secrets/mars.yaml` — generate a fresh one
|
||||
(see "Bootstrap the tailnet" under neptun below) rather than reusing an
|
||||
old key; reusable pre-auth keys still expire.
|
||||
|
||||
Boot mode is assumed **legacy BIOS** (grub, not systemd-boot) — unconfirmed;
|
||||
check `[ -d /sys/firmware/efi ]` once you're at the machine and see
|
||||
`hosts/mars/disk-config.nix`'s header comment if it turns out to be UEFI.
|
||||
|
||||
Otherwise the flow is identical to the ZimaBlade steps above:
|
||||
```
|
||||
nix run github:nix-community/nixos-anywhere -- \
|
||||
--flake .#mars \
|
||||
--extra-files /tmp/extra \
|
||||
--generate-hardware-config nixos-generate-config ./hosts/mars/hardware-configuration.nix \
|
||||
--target-host root@<mars-ip>
|
||||
```
|
||||
(stage the host key into `/tmp/extra/etc/ssh/` first, same as step 4 there).
|
||||
Manual alternative (USB ISO): boot installer, `disko` the disk, then
|
||||
`nixos-install --flake .#mars`.
|
||||
|
||||
## First install on terra — no-USB findiso reinstall (replacing CachyOS)
|
||||
|
||||
terra is a Ryzen 9 5900X / Radeon RX 6800 XT desktop, currently running
|
||||
@@ -294,6 +334,20 @@ another way in.
|
||||
sudo tailscale logout && sudo systemctl restart tailscaled-autoconnect
|
||||
```
|
||||
|
||||
### mars
|
||||
|
||||
- **Confirm the OIDC redirect still resolves.** hermes-agent.nix reuses
|
||||
jupiter's old Authentik application (slug `hermes`, redirect
|
||||
`https://hermes.mgaction.town/auth/callback`) unchanged — nothing to
|
||||
reconfigure in Authentik, just verify `neptun`'s `hermes.mgaction.town`
|
||||
vhost (now pointed at `mars.orbit.sol:9119`) actually reaches the
|
||||
dashboard once mars is up and joined the tailnet.
|
||||
- **Carrying forward old chat history/memories:** mars starts with a fresh
|
||||
Hermes state dir (`/var/lib/hermes/.hermes`). jupiter's old instance data
|
||||
is backed up at `/mnt/data/AppData/hermes.bak-2026-08-21` — rsync it over
|
||||
(via the `/mnt/jupiter` samba mount) before the first switch if you want
|
||||
it preserved instead of starting clean.
|
||||
|
||||
### mercury (Raspberry Pi 3B+)
|
||||
|
||||
- `./deploy flash mercury /dev/sdX` writes the dedicated age key to the root
|
||||
|
||||
+13
@@ -53,6 +53,19 @@
|
||||
|
||||
environment.systemPackages = with pkgs; [ git btop tmux curl wget zsh-powerlevel10k lsd ];
|
||||
|
||||
# ---- home-manager (user-level config for darman, all hosts) ----
|
||||
# Requires home-manager.nixosModules.home-manager in the host's own
|
||||
# `modules` list (flake.nix) — this only sets values for options that
|
||||
# module declares, it doesn't import it, so every nixosSystem using
|
||||
# common.nix needs that line too (mirrors terra's original setup).
|
||||
home-manager.useGlobalPkgs = true;
|
||||
home-manager.useUserPackages = true;
|
||||
# Protects activation if a plain (non-symlink) ~/.zshrc etc. already
|
||||
# exists from before home-manager managed it — e.g. a host where the
|
||||
# zsh-newuser-install wizard's option (0) was used to silence itself.
|
||||
home-manager.backupFileExtension = "hm-bak";
|
||||
home-manager.users.darman.imports = [ ./home/common.nix ];
|
||||
|
||||
# ---- zsh / oh-my-zsh / powerlevel10k ----
|
||||
programs.zsh = {
|
||||
enable = true;
|
||||
|
||||
@@ -7,6 +7,7 @@ import qs.widgets.notifications
|
||||
import qs.widgets.osd
|
||||
import qs.widgets.sidebar
|
||||
import qs.widgets.systray
|
||||
import qs.widgets.vitals
|
||||
|
||||
Scope {
|
||||
// Left sidebar in the Slant (V6) style — toggle with SUPER CTRL S.
|
||||
@@ -25,4 +26,7 @@ Scope {
|
||||
|
||||
Notifications {}
|
||||
VolumeOsd {}
|
||||
|
||||
// Host vitals HUD — toggle with SUPER CTRL V.
|
||||
Vitals {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
|
||||
// Horizontal meter in the Slant language: a chamfered track whose fill is a
|
||||
// clipped copy of the SAME hexagon, so empty and full always share one
|
||||
// silhouette (the trick the sidebar's volume meter uses vertically).
|
||||
Item {
|
||||
id: bar
|
||||
|
||||
property real value: 0 // 0..1
|
||||
property real warn: 0.85 // fraction at which the fill goes hot
|
||||
property bool unknown: false // no reading — draw an empty, dimmed track
|
||||
|
||||
readonly property real frac: bar.unknown ? 0 : Math.max(0, Math.min(1, bar.value))
|
||||
readonly property bool hot: !bar.unknown && bar.frac >= bar.warn
|
||||
|
||||
implicitWidth: 200
|
||||
implicitHeight: 13
|
||||
|
||||
readonly property int chamfer: 5
|
||||
|
||||
// Track.
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
strokeWidth: 1
|
||||
strokeColor: bar.unknown ? "#3A3D42" : "#7A7B7D"
|
||||
fillColor: "#292C30"
|
||||
|
||||
startX: bar.chamfer
|
||||
startY: 0
|
||||
PathLine { x: bar.width; y: 0 }
|
||||
PathLine { x: bar.width; y: bar.height - bar.chamfer }
|
||||
PathLine { x: bar.width - bar.chamfer; y: bar.height }
|
||||
PathLine { x: 0; y: bar.height }
|
||||
PathLine { x: 0; y: bar.chamfer }
|
||||
PathLine { x: bar.chamfer; y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// Fill — revealed from the left.
|
||||
Item {
|
||||
id: fillClip
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 2
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 2
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 2
|
||||
clip: true
|
||||
|
||||
width: bar.frac * (bar.width - 4)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: 220
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Shape {
|
||||
id: fill
|
||||
|
||||
width: bar.width - 4
|
||||
height: bar.height - 4
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
readonly property int chamfer: bar.chamfer - 2
|
||||
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: bar.hot ? "#FF6B4A" : "#FFD063"
|
||||
|
||||
Behavior on fillColor {
|
||||
ColorAnimation {
|
||||
duration: 200
|
||||
}
|
||||
}
|
||||
|
||||
startX: fill.chamfer
|
||||
startY: 0
|
||||
PathLine { x: fill.width; y: 0 }
|
||||
PathLine { x: fill.width; y: fill.height - fill.chamfer }
|
||||
PathLine { x: fill.width - fill.chamfer; y: fill.height }
|
||||
PathLine { x: 0; y: fill.height }
|
||||
PathLine { x: 0; y: fill.chamfer }
|
||||
PathLine { x: fill.chamfer; y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
|
||||
// Host vitals HUD — the "Slant" language (chamfered panel, trapezoid bevels,
|
||||
// outward corner chunks, floating cap, slanted dividers) carried over from the
|
||||
// sidebar and launcher V6, wrapped around this box's own node_exporter feed.
|
||||
// Toggled with SUPER CTRL V.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
property bool active: false
|
||||
|
||||
function toggle() {
|
||||
root.active = !root.active;
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "vitals"
|
||||
description: "Toggle the host vitals panel"
|
||||
onPressed: root.toggle()
|
||||
}
|
||||
|
||||
VitalsData {
|
||||
id: vitals
|
||||
active: root.active
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: root.active
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
// Ignore the bars' exclusive zones so the backdrop covers the screen.
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible)
|
||||
keys.forceActiveFocus();
|
||||
}
|
||||
|
||||
// Dim backdrop — click anywhere to dismiss.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#0A0A0C"
|
||||
opacity: 0.5
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.active = false
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: keys
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
root.active = false;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: frame
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: 620
|
||||
height: content.implicitHeight + 2 * 26
|
||||
|
||||
readonly property int chamfer: 18
|
||||
readonly property int bevel: 4 // inward thickness of the bevel borders
|
||||
readonly property int chunkThick: 8 // outward thickness of the heavy chunks
|
||||
readonly property int chunkSlant: 12 // slant of their end pieces
|
||||
readonly property int capSize: 10 // floating triangle cap
|
||||
readonly property int smallChamfer: 6
|
||||
|
||||
// Chamfered panel — top-left / bottom-right cut, accent edge.
|
||||
Shape {
|
||||
id: panelShape
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
fillColor: "#0F1012"
|
||||
strokeColor: "#FFD063"
|
||||
strokeWidth: 2
|
||||
|
||||
startX: frame.chamfer
|
||||
startY: 0
|
||||
PathLine { x: panelShape.width - frame.smallChamfer; y: 0 }
|
||||
PathLine { x: panelShape.width; y: frame.smallChamfer }
|
||||
PathLine { x: panelShape.width; y: panelShape.height - frame.chamfer }
|
||||
PathLine { x: panelShape.width - frame.chamfer; y: panelShape.height }
|
||||
PathLine { x: frame.smallChamfer; y: panelShape.height }
|
||||
PathLine { x: 0; y: panelShape.height - frame.smallChamfer }
|
||||
PathLine { x: 0; y: frame.chamfer }
|
||||
PathLine { x: frame.chamfer; y: 0 }
|
||||
}
|
||||
|
||||
// Thick top-left bevel accent.
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
|
||||
startX: 0
|
||||
startY: frame.chamfer
|
||||
PathLine { x: frame.chamfer; y: 0 }
|
||||
PathLine { x: frame.chamfer + 2 * frame.bevel; y: 0 }
|
||||
PathLine { x: 0; y: frame.chamfer + 2 * frame.bevel }
|
||||
PathLine { x: 0; y: frame.chamfer }
|
||||
}
|
||||
|
||||
// Thick bottom-right bevel accent.
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
|
||||
startX: panelShape.width
|
||||
startY: panelShape.height - frame.chamfer
|
||||
PathLine { x: panelShape.width - frame.chamfer; y: panelShape.height }
|
||||
PathLine { x: panelShape.width - frame.chamfer - 2 * frame.bevel; y: panelShape.height }
|
||||
PathLine { x: panelShape.width; y: panelShape.height - frame.chamfer - 2 * frame.bevel }
|
||||
PathLine { x: panelShape.width; y: panelShape.height - frame.chamfer }
|
||||
}
|
||||
|
||||
// Floating triangle cap in the bottom-right notch.
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
|
||||
startX: panelShape.width
|
||||
startY: panelShape.height
|
||||
PathLine { x: panelShape.width; y: panelShape.height - frame.capSize }
|
||||
PathLine { x: panelShape.width - frame.capSize; y: panelShape.height }
|
||||
PathLine { x: panelShape.width; y: panelShape.height }
|
||||
}
|
||||
|
||||
// Heavy outward chunk wrapping the bottom-left corner.
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
|
||||
startX: panelShape.width / 3
|
||||
startY: panelShape.height
|
||||
PathLine { x: panelShape.width / 3 - frame.chunkSlant; y: panelShape.height + frame.chunkThick }
|
||||
PathLine { x: frame.smallChamfer; y: panelShape.height + frame.chunkThick }
|
||||
PathLine { x: -frame.chunkThick; y: panelShape.height - frame.smallChamfer }
|
||||
PathLine { x: -frame.chunkThick; y: panelShape.height - panelShape.height / 7 + frame.chunkSlant }
|
||||
PathLine { x: 0; y: panelShape.height - panelShape.height / 7 }
|
||||
PathLine { x: 0; y: panelShape.height - frame.smallChamfer }
|
||||
PathLine { x: frame.smallChamfer; y: panelShape.height }
|
||||
PathLine { x: panelShape.width / 3; y: panelShape.height }
|
||||
}
|
||||
|
||||
// Heavy outward chunk wrapping the top-right corner.
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
|
||||
startX: panelShape.width
|
||||
startY: panelShape.height / 3
|
||||
PathLine { x: panelShape.width + frame.chunkThick; y: panelShape.height / 3 - frame.chunkSlant }
|
||||
PathLine { x: panelShape.width + frame.chunkThick; y: frame.smallChamfer }
|
||||
PathLine { x: panelShape.width - frame.smallChamfer; y: -frame.chunkThick }
|
||||
PathLine { x: panelShape.width - panelShape.width / 5 + frame.chunkSlant; y: -frame.chunkThick }
|
||||
PathLine { x: panelShape.width - panelShape.width / 5; y: 0 }
|
||||
PathLine { x: panelShape.width - frame.smallChamfer; y: 0 }
|
||||
PathLine { x: panelShape.width; y: frame.smallChamfer }
|
||||
PathLine { x: panelShape.width; y: panelShape.height / 3 }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content ────────────────────────────────────────────────────
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 26
|
||||
anchors.leftMargin: 30
|
||||
anchors.rightMargin: 26
|
||||
spacing: 12
|
||||
|
||||
// Header — slash trio, host, uptime.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Shape {
|
||||
id: slashes
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
startX: 6
|
||||
startY: 0
|
||||
PathLine { x: 10; y: 0 }
|
||||
PathLine { x: 4; y: slashes.height }
|
||||
PathLine { x: 0; y: slashes.height }
|
||||
PathLine { x: 6; y: 0 }
|
||||
}
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
startX: 15
|
||||
startY: 0
|
||||
PathLine { x: 19; y: 0 }
|
||||
PathLine { x: 13; y: slashes.height }
|
||||
PathLine { x: 9; y: slashes.height }
|
||||
PathLine { x: 15; y: 0 }
|
||||
}
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
startX: 24
|
||||
startY: 0
|
||||
PathLine { x: 28; y: 0 }
|
||||
PathLine { x: 22; y: slashes.height }
|
||||
PathLine { x: 18; y: slashes.height }
|
||||
PathLine { x: 24; y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: (vitals.host || "vitals").toUpperCase()
|
||||
color: "#EEEEEE"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 20
|
||||
font.letterSpacing: 2
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "UP"
|
||||
color: "#7A7B7D"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 11
|
||||
}
|
||||
|
||||
Text {
|
||||
text: vitals.fmtUptime(vitals.uptime)
|
||||
color: "#FFD063"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 16
|
||||
}
|
||||
}
|
||||
|
||||
Slant {}
|
||||
|
||||
// Unreachable exporter — say so rather than drawing zeroes.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: vitals.failed
|
||||
text: "NODE_EXPORTER UNREACHABLE ON :" + vitals.port
|
||||
color: "#FF6B4A"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 13
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: !vitals.failed
|
||||
spacing: 12
|
||||
|
||||
Metric {
|
||||
label: "CPU"
|
||||
value: vitals.cpu
|
||||
unknown: !vitals.ratesReady
|
||||
warn: 0.9
|
||||
readout: vitals.ratesReady ? Math.round(vitals.cpu * 100) + "%" : "--"
|
||||
detail: "LOAD " + vitals.load1.toFixed(2) + " / " + vitals.load5.toFixed(2) + " / " + vitals.load15.toFixed(2)
|
||||
aside: vitals.cpuThreads + "T " + vitals.fmtTemp(vitals.cpuTemp)
|
||||
asideHot: vitals.cpuTemp >= 85
|
||||
}
|
||||
|
||||
Metric {
|
||||
label: "MEM"
|
||||
value: vitals.memTotal > 0 ? vitals.memUsed / vitals.memTotal : 0
|
||||
unknown: !vitals.ready
|
||||
readout: vitals.memTotal > 0 ? Math.round(vitals.memUsed / vitals.memTotal * 100) + "%" : "--"
|
||||
detail: vitals.fmtBytes(vitals.memUsed) + " / " + vitals.fmtBytes(vitals.memTotal)
|
||||
aside: ""
|
||||
}
|
||||
|
||||
// No GPU-busy counter exists in node_exporter, so the bar
|
||||
// tracks power draw against the card's cap — a real
|
||||
// reading, labelled for what it is rather than faked as
|
||||
// utilisation.
|
||||
Metric {
|
||||
label: "GPU"
|
||||
visible: isFinite(vitals.gpuTemp)
|
||||
value: isFinite(vitals.gpuPower) && vitals.gpuPowerCap > 0 ? vitals.gpuPower / vitals.gpuPowerCap : 0
|
||||
unknown: !isFinite(vitals.gpuPower)
|
||||
warn: 0.9
|
||||
readout: isFinite(vitals.gpuPower) ? Math.round(vitals.gpuPower) + "W" : "--"
|
||||
detail: (vitals.gpuPowerCap > 0 ? "CAP " + Math.round(vitals.gpuPowerCap) + "W" : "") + (isFinite(vitals.gpuClock) ? " SCLK " + Math.round(vitals.gpuClock) + " MHZ" : "")
|
||||
aside: vitals.fmtTemp(vitals.gpuTemp) + (isFinite(vitals.gpuHotspot) ? " / " + vitals.fmtTemp(vitals.gpuHotspot) : "")
|
||||
asideHot: vitals.gpuHotspot >= 95
|
||||
}
|
||||
}
|
||||
|
||||
Slant {
|
||||
visible: !vitals.failed
|
||||
}
|
||||
|
||||
// Disks.
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: !vitals.failed
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: vitals.disks
|
||||
|
||||
delegate: RowLayout {
|
||||
id: diskRow
|
||||
required property var modelData
|
||||
|
||||
readonly property real frac: diskRow.modelData.size > 0 ? diskRow.modelData.used / diskRow.modelData.size : 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 96
|
||||
text: diskRow.modelData.mount
|
||||
color: "#7A7B7D"
|
||||
font.pointSize: 9
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
VitalBar {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 11
|
||||
value: diskRow.frac
|
||||
warn: 0.9
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 48
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: Math.round(diskRow.frac * 100) + "%"
|
||||
color: diskRow.frac >= 0.9 ? "#FF6B4A" : "#EEEEEE"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 13
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 104
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: vitals.fmtBytes(diskRow.modelData.size - diskRow.modelData.used) + " FREE"
|
||||
color: "#7A7B7D"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Slant {
|
||||
visible: !vitals.failed
|
||||
}
|
||||
|
||||
// Network.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: !vitals.failed
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 96
|
||||
text: vitals.netIface || "NET"
|
||||
color: "#7A7B7D"
|
||||
font.pointSize: 9
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "RX"
|
||||
color: "#7A7B7D"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 11
|
||||
}
|
||||
|
||||
Text {
|
||||
text: vitals.fmtRate(vitals.netRx)
|
||||
color: "#FFD063"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 14
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "TX"
|
||||
color: "#7A7B7D"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 11
|
||||
}
|
||||
|
||||
Text {
|
||||
text: vitals.fmtRate(vitals.netTx)
|
||||
color: "#FFD063"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 14
|
||||
|
||||
// Right-align the TX readout against the panel edge the
|
||||
// disk rows' "FREE" column already lines up with.
|
||||
Layout.preferredWidth: 104
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local pieces ───────────────────────────────────────────────────────
|
||||
|
||||
// A labelled bar with a readout, a sub-line and a right-hand aside.
|
||||
component Metric: ColumnLayout {
|
||||
id: metric
|
||||
|
||||
property string label: ""
|
||||
property string readout: ""
|
||||
property string detail: ""
|
||||
property string aside: ""
|
||||
property bool asideHot: false
|
||||
property real value: 0
|
||||
property real warn: 0.85
|
||||
property bool unknown: false
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 3
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 46
|
||||
text: metric.label
|
||||
color: "#FFD063"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 15
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
VitalBar {
|
||||
Layout.fillWidth: true
|
||||
value: metric.value
|
||||
warn: metric.warn
|
||||
unknown: metric.unknown
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 54
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: metric.readout
|
||||
color: "#EEEEEE"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 16
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 58
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: metric.detail
|
||||
color: "#7A7B7D"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 11
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: metric.aside.length > 0
|
||||
text: metric.aside
|
||||
color: metric.asideHot ? "#FF6B4A" : "#7A7B7D"
|
||||
font.family: "Digital-7 Mono"
|
||||
font.pointSize: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slanted divider — the launcher/sidebar motif.
|
||||
component Slant: Item {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 3
|
||||
|
||||
Shape {
|
||||
id: divider
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: "#FFD063"
|
||||
startX: 6
|
||||
startY: 0
|
||||
PathLine { x: divider.width; y: 0 }
|
||||
PathLine { x: divider.width - 6; y: divider.height }
|
||||
PathLine { x: 0; y: divider.height }
|
||||
PathLine { x: 6; y: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
// Vitals source: scrapes this host's own node_exporter over loopback.
|
||||
//
|
||||
// The exporter comes from services/monitoring/node-exporter.nix — the same
|
||||
// collection layer the homelab dashboard scrapes over the tailnet — so what
|
||||
// this panel shows and what the dashboard graphs can never drift apart.
|
||||
// :9100 is firewalled to tailscale0 for everyone else, but loopback is always
|
||||
// reachable, so no extra hole is opened for this.
|
||||
//
|
||||
// CPU busy and network throughput are counter DELTAS: the first sample after
|
||||
// `active` flips on only primes the counters, and `ratesReady` stays false
|
||||
// until a second one gives them an interval to divide by.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Poll only while the panel is on screen — no cost when hidden.
|
||||
property bool active: false
|
||||
property int interval: 2000
|
||||
property int port: 9100
|
||||
|
||||
// ── Readings ───────────────────────────────────────────────────────────
|
||||
property bool ready: false // one successful scrape happened
|
||||
property bool ratesReady: false // two — so deltas are meaningful
|
||||
property bool failed: false // exporter unreachable / no metrics
|
||||
|
||||
property string host: ""
|
||||
property real uptime: 0 // seconds
|
||||
|
||||
property real cpu: 0 // 0..1 busy
|
||||
property int cpuThreads: 0
|
||||
property real load1: 0
|
||||
property real load5: 0
|
||||
property real load15: 0
|
||||
property real cpuTemp: NaN // °C
|
||||
|
||||
property real memUsed: 0 // bytes
|
||||
property real memTotal: 0
|
||||
|
||||
property real gpuTemp: NaN // edge
|
||||
property real gpuHotspot: NaN // junction
|
||||
property real gpuPower: NaN // W
|
||||
property real gpuPowerCap: NaN
|
||||
property real gpuClock: NaN // MHz, shader clock
|
||||
property real nvmeTemp: NaN
|
||||
|
||||
property var disks: [] // [{ mount, used, size }]
|
||||
|
||||
property string netIface: ""
|
||||
property real netRx: 0 // bytes/s
|
||||
property real netTx: 0
|
||||
|
||||
// ── Delta state ────────────────────────────────────────────────────────
|
||||
property double _prevTime: 0
|
||||
property double _prevIdle: 0
|
||||
property double _prevTotal: 0
|
||||
property double _prevRx: 0
|
||||
property double _prevTx: 0
|
||||
|
||||
onActiveChanged: {
|
||||
if (!root.active) {
|
||||
// Drop the counters so reopening the panel doesn't average a rate
|
||||
// across however long it sat hidden.
|
||||
root._prevTime = 0;
|
||||
root.ratesReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Local filesystems worth showing; everything else (tmpfs, ramfs, and the
|
||||
// cifs mount of jupiter, which is another host's disk, not terra's) is out.
|
||||
readonly property var _fsTypes: ["ext4", "btrfs", "xfs", "vfat", "f2fs"]
|
||||
|
||||
// Virtual/overlay interfaces that would drown out the real NIC.
|
||||
readonly property var _skipIface: ["lo", "docker", "podman", "veth", "br-", "virbr", "cni"]
|
||||
|
||||
function _label(s, key) {
|
||||
const m = s.match(new RegExp(key + '="([^"]*)"'));
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
function _parse(text) {
|
||||
const lines = text.split("\n");
|
||||
|
||||
let idle = 0, total = 0;
|
||||
const seenCpu = {};
|
||||
let memTotal = 0, memAvail = 0, bootTime = 0;
|
||||
let l1 = 0, l5 = 0, l15 = 0;
|
||||
let host = "";
|
||||
|
||||
// hwmon is keyed by an opaque chip id; node_hwmon_chip_names maps it to
|
||||
// the driver (amdgpu/k10temp/nvme) but is NOT guaranteed to be emitted
|
||||
// before the readings, so collect raw and resolve after the loop.
|
||||
const chipName = {};
|
||||
const tempRaw = {}, powerRaw = {}, freqRaw = {};
|
||||
|
||||
const fsSize = {}, fsAvail = {};
|
||||
const rxByIface = {}, txByIface = {};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length === 0 || line.charCodeAt(0) === 35 /* '#' */)
|
||||
continue;
|
||||
|
||||
const sp = line.lastIndexOf(" ");
|
||||
if (sp < 0)
|
||||
continue;
|
||||
|
||||
const key = line.substring(0, sp);
|
||||
const val = parseFloat(line.substring(sp + 1));
|
||||
if (!isFinite(val))
|
||||
continue;
|
||||
|
||||
if (key.startsWith("node_cpu_seconds_total{")) {
|
||||
total += val;
|
||||
const mode = root._label(key, "mode");
|
||||
if (mode === "idle")
|
||||
idle += val;
|
||||
seenCpu[root._label(key, "cpu")] = true;
|
||||
} else if (key === "node_memory_MemTotal_bytes") {
|
||||
memTotal = val;
|
||||
} else if (key === "node_memory_MemAvailable_bytes") {
|
||||
memAvail = val;
|
||||
} else if (key === "node_load1") {
|
||||
l1 = val;
|
||||
} else if (key === "node_load5") {
|
||||
l5 = val;
|
||||
} else if (key === "node_load15") {
|
||||
l15 = val;
|
||||
} else if (key === "node_boot_time_seconds") {
|
||||
bootTime = val;
|
||||
} else if (key.startsWith("node_uname_info{")) {
|
||||
host = root._label(key, "nodename");
|
||||
} else if (key.startsWith("node_hwmon_chip_names{")) {
|
||||
chipName[root._label(key, "chip")] = root._label(key, "chip_name");
|
||||
} else if (key.startsWith("node_hwmon_temp_celsius{")) {
|
||||
const c = root._label(key, "chip");
|
||||
(tempRaw[c] = tempRaw[c] || {})[root._label(key, "sensor")] = val;
|
||||
} else if (key.startsWith("node_hwmon_power_average_watt{")) {
|
||||
powerRaw[root._label(key, "chip")] = val;
|
||||
} else if (key.startsWith("node_hwmon_power_cap_watt{")) {
|
||||
const c = root._label(key, "chip");
|
||||
(freqRaw[c] = freqRaw[c] || {})["cap"] = val;
|
||||
} else if (key.startsWith("node_hwmon_freq_freq_mhz{")) {
|
||||
const c = root._label(key, "chip");
|
||||
(freqRaw[c] = freqRaw[c] || {})[root._label(key, "sensor")] = val;
|
||||
} else if (key.startsWith("node_filesystem_size_bytes{")) {
|
||||
const mp = root._label(key, "mountpoint");
|
||||
if (root._fsTypes.indexOf(root._label(key, "fstype")) >= 0)
|
||||
fsSize[mp] = val;
|
||||
} else if (key.startsWith("node_filesystem_avail_bytes{")) {
|
||||
fsAvail[root._label(key, "mountpoint")] = val;
|
||||
} else if (key.startsWith("node_network_receive_bytes_total{")) {
|
||||
rxByIface[root._label(key, "device")] = val;
|
||||
} else if (key.startsWith("node_network_transmit_bytes_total{")) {
|
||||
txByIface[root._label(key, "device")] = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (memTotal <= 0) {
|
||||
// Reachable but not serving node metrics — treat as a failure
|
||||
// rather than rendering a panel full of zeroes.
|
||||
root.failed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve hwmon chips by driver name.
|
||||
const byDriver = {};
|
||||
for (const chip in tempRaw)
|
||||
byDriver[chipName[chip] || chip] = { temp: tempRaw[chip], chip: chip };
|
||||
|
||||
const cpuChip = byDriver["k10temp"] || byDriver["coretemp"] || byDriver["zenpower"];
|
||||
root.cpuTemp = cpuChip ? (cpuChip.temp["temp1"] ?? NaN) : NaN;
|
||||
|
||||
const gpu = byDriver["amdgpu"];
|
||||
if (gpu) {
|
||||
root.gpuTemp = gpu.temp["temp1"] ?? NaN; // edge
|
||||
root.gpuHotspot = gpu.temp["temp2"] ?? NaN; // junction
|
||||
root.gpuPower = powerRaw[gpu.chip] ?? NaN;
|
||||
root.gpuPowerCap = freqRaw[gpu.chip] ? (freqRaw[gpu.chip]["cap"] ?? NaN) : NaN;
|
||||
root.gpuClock = freqRaw[gpu.chip] ? (freqRaw[gpu.chip]["sclk"] ?? NaN) : NaN;
|
||||
} else {
|
||||
root.gpuTemp = NaN;
|
||||
root.gpuHotspot = NaN;
|
||||
root.gpuPower = NaN;
|
||||
root.gpuPowerCap = NaN;
|
||||
root.gpuClock = NaN;
|
||||
}
|
||||
|
||||
const nvme = byDriver["nvme"];
|
||||
root.nvmeTemp = nvme ? (nvme.temp["temp1"] ?? NaN) : NaN;
|
||||
|
||||
// Filesystems. /nix/store is the same device as / on every host here,
|
||||
// so listing it twice would just be noise.
|
||||
const mounts = [];
|
||||
for (const mp in fsSize) {
|
||||
if (mp === "/nix/store")
|
||||
continue;
|
||||
const size = fsSize[mp];
|
||||
const avail = fsAvail[mp];
|
||||
if (!(size > 0) || avail === undefined)
|
||||
continue;
|
||||
mounts.push({ mount: mp, used: size - avail, size: size });
|
||||
}
|
||||
mounts.sort((a, b) => a.mount === "/" ? -1 : b.mount === "/" ? 1 : a.mount.localeCompare(b.mount));
|
||||
root.disks = mounts;
|
||||
|
||||
// Busiest real interface.
|
||||
let iface = "", best = -1;
|
||||
for (const dev in rxByIface) {
|
||||
let skip = false;
|
||||
for (let s = 0; s < root._skipIface.length; s++) {
|
||||
if (dev === root._skipIface[s] || dev.indexOf(root._skipIface[s]) === 0) {
|
||||
skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (skip)
|
||||
continue;
|
||||
if (rxByIface[dev] > best) {
|
||||
best = rxByIface[dev];
|
||||
iface = dev;
|
||||
}
|
||||
}
|
||||
root.netIface = iface;
|
||||
|
||||
const rx = iface ? (rxByIface[iface] ?? 0) : 0;
|
||||
const tx = iface ? (txByIface[iface] ?? 0) : 0;
|
||||
|
||||
// Rates.
|
||||
const now = Date.now() / 1000;
|
||||
const dt = now - root._prevTime;
|
||||
if (root._prevTime > 0 && dt > 0) {
|
||||
const dTotal = total - root._prevTotal;
|
||||
if (dTotal > 0)
|
||||
root.cpu = Math.max(0, Math.min(1, 1 - (idle - root._prevIdle) / dTotal));
|
||||
root.netRx = Math.max(0, (rx - root._prevRx) / dt);
|
||||
root.netTx = Math.max(0, (tx - root._prevTx) / dt);
|
||||
root.ratesReady = true;
|
||||
}
|
||||
root._prevTime = now;
|
||||
root._prevIdle = idle;
|
||||
root._prevTotal = total;
|
||||
root._prevRx = rx;
|
||||
root._prevTx = tx;
|
||||
|
||||
root.cpuThreads = Object.keys(seenCpu).length;
|
||||
root.memTotal = memTotal;
|
||||
root.memUsed = memTotal - memAvail;
|
||||
root.load1 = l1;
|
||||
root.load5 = l5;
|
||||
root.load15 = l15;
|
||||
root.host = host;
|
||||
root.uptime = bootTime > 0 ? (Date.now() / 1000 - bootTime) : 0;
|
||||
|
||||
root.failed = false;
|
||||
root.ready = true;
|
||||
}
|
||||
|
||||
// ── Formatting helpers, shared with the panel ──────────────────────────
|
||||
function fmtBytes(b) {
|
||||
if (!isFinite(b))
|
||||
return "--";
|
||||
const u = ["B", "K", "M", "G", "T"];
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < u.length - 1) {
|
||||
b /= 1024;
|
||||
i++;
|
||||
}
|
||||
return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1)) + u[i];
|
||||
}
|
||||
|
||||
function fmtRate(b) {
|
||||
return root.ratesReady ? root.fmtBytes(b) + "/S" : "--";
|
||||
}
|
||||
|
||||
function fmtTemp(c) {
|
||||
return isFinite(c) ? Math.round(c) + "°" : "--";
|
||||
}
|
||||
|
||||
function fmtUptime(s) {
|
||||
if (!(s > 0))
|
||||
return "--";
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor(s % 86400 / 3600);
|
||||
const m = Math.floor(s % 3600 / 60);
|
||||
return d > 0 ? d + "D " + h + "H" : h > 0 ? h + "H " + m + "M" : m + "M";
|
||||
}
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────────
|
||||
Process {
|
||||
id: scrape
|
||||
|
||||
// Filtered at the source: the full endpoint is ~1400 lines and only
|
||||
// these families are drawn.
|
||||
command: ["sh", "-c", "curl -s --max-time 2 http://127.0.0.1:" + root.port + "/metrics | grep -E '^node_(cpu_seconds_total|memory_MemTotal_bytes|memory_MemAvailable_bytes|load1|load5|load15|boot_time_seconds|uname_info|hwmon_chip_names|hwmon_temp_celsius|hwmon_power_average_watt|hwmon_power_cap_watt|hwmon_freq_freq_mhz|filesystem_avail_bytes|filesystem_size_bytes|network_receive_bytes_total|network_transmit_bytes_total)[ {]'"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (this.text.length === 0)
|
||||
root.failed = true;
|
||||
else
|
||||
root._parse(this.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: root.interval
|
||||
running: root.active
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
if (!scrape.running)
|
||||
scrape.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+25
-182
@@ -114,27 +114,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": [
|
||||
@@ -156,29 +135,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hermes-agent": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"npm-lockfile-fix": "npm-lockfile-fix",
|
||||
"pyproject-build-systems": "pyproject-build-systems_2",
|
||||
"pyproject-nix": "pyproject-nix_2",
|
||||
"uv2nix": "uv2nix_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787007062,
|
||||
"narHash": "sha256-C1SJX0VqlxrLknGhFKj7+DKpfJ1eI4udPE67kW+dT+M=",
|
||||
"owner": "NousResearch",
|
||||
"repo": "hermes-agent",
|
||||
"rev": "55e34fb7d0cd8fd1e16be82a1ceafee05fca63d3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NousResearch",
|
||||
"repo": "hermes-agent",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
@@ -186,11 +142,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786924861,
|
||||
"narHash": "sha256-hftabkb+73OcGzvwFAjCiQorAhprs9TnU1+FkGO5CIw=",
|
||||
"lastModified": 1787377438,
|
||||
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "09ae1b85a6db412d841d60f924b23f881f0d0a38",
|
||||
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -242,9 +198,9 @@
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-build-systems": "pyproject-build-systems_3",
|
||||
"pyproject-nix": "pyproject-nix_3",
|
||||
"uv2nix": "uv2nix_3"
|
||||
"pyproject-build-systems": "pyproject-build-systems_2",
|
||||
"pyproject-nix": "pyproject-nix_2",
|
||||
"uv2nix": "uv2nix_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768002052,
|
||||
@@ -283,11 +239,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1783265394,
|
||||
"narHash": "sha256-cq4YfNFGYzp0NItZP8tEC7xUI8OSgY4fj75AU/NSaPM=",
|
||||
"lastModified": 1786747096,
|
||||
"narHash": "sha256-9QqhmaLVsPhKdMSBaWKjDqeGRn8G4ov4cVuZ6JFwXbo=",
|
||||
"owner": "numtide",
|
||||
"repo": "nix-vm-test",
|
||||
"rev": "1a587212d2ac8b669c6c32499015f996506b6ba5",
|
||||
"rev": "c8781a0ea2d8417506fff7722eae5a6316461212",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -314,11 +270,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786520020,
|
||||
"narHash": "sha256-9b0hAKM8UtNsm4lD3aySuXPjPpSibh6yfYEcNvr4fU0=",
|
||||
"lastModified": 1787124618,
|
||||
"narHash": "sha256-aKf1k2hvYgaxP9oxDPRiv9npEJLODC9eKxk7nR69lzQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-anywhere",
|
||||
"rev": "4cf3b82df8422f82657ae98c3b8374a5fd74f9a1",
|
||||
"rev": "ad8fa24e11eef167fd72d49fafefa3f840312d71",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -335,11 +291,11 @@
|
||||
"nixos-unstable": "nixos-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786617555,
|
||||
"narHash": "sha256-r3brKkBWm34wltszIeoZw2VCFUEoS2nNAe3W2Zgzc9k=",
|
||||
"lastModified": 1787222173,
|
||||
"narHash": "sha256-acp6QJnWVnLvnanC59CMkiDC/i0ZhdFKiB7prru9SHw=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-images",
|
||||
"rev": "f01878e82602ad3d0a2e74a14440521924e57bc5",
|
||||
"rev": "e17386d9193d6d5a90f1b4b6a8a5cd2620d34b56",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -398,11 +354,11 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1786963906,
|
||||
"narHash": "sha256-3tkeMWSvHPo3tYljfXbPC/TgknikU1GvdVr/DkdfvE0=",
|
||||
"lastModified": 1787209939,
|
||||
"narHash": "sha256-WvvHR4kSQLAbtouMC/ruZ5UpLwlUcY3K4FAllMN+yGk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f4b6996c4e8b9ee06ce147ec344c885f51071b14",
|
||||
"rev": "391b592eb44808b3bd0cb80bb71b63a5a118b8bb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -414,27 +370,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1785318670,
|
||||
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
||||
"lastModified": 1787204541,
|
||||
"narHash": "sha256-OURZPknrTjQrlNyxPdqzyqmU/81Wes1CUP/Ft1Rv/YI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1786943417,
|
||||
"narHash": "sha256-b4qgjdFtlz5TAZ1Hi7DFJeqX3sjaDUnrwr9OO+O1rM0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0dd31db7e6dbf9ce05697c4545f6fe01accec994",
|
||||
"rev": "5880666fd9eb563038431edb35c2d0aa595884e6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -444,27 +384,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"npm-lockfile-fix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775903712,
|
||||
"narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=",
|
||||
"owner": "jeslie0",
|
||||
"repo": "npm-lockfile-fix",
|
||||
"rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "jeslie0",
|
||||
"repo": "npm-lockfile-fix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-build-systems": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
@@ -495,35 +414,6 @@
|
||||
}
|
||||
},
|
||||
"pyproject-build-systems_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": [
|
||||
"hermes-agent",
|
||||
"pyproject-nix"
|
||||
],
|
||||
"uv2nix": [
|
||||
"hermes-agent",
|
||||
"uv2nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785115949,
|
||||
"narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "build-system-pkgs",
|
||||
"rev": "62c0d86027edb1c4f39a5facc09876348144f7c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "build-system-pkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-build-systems_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"mediamanager-nix",
|
||||
@@ -574,27 +464,6 @@
|
||||
}
|
||||
},
|
||||
"pyproject-nix_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784591072,
|
||||
"narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-nix_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"mediamanager-nix",
|
||||
@@ -619,14 +488,13 @@
|
||||
"inputs": {
|
||||
"authentik-nix": "authentik-nix",
|
||||
"disko": "disko",
|
||||
"hermes-agent": "hermes-agent",
|
||||
"home-manager": "home-manager",
|
||||
"hypr-chrome": "hypr-chrome",
|
||||
"mediamanager-nix": "mediamanager-nix",
|
||||
"nix-flatpak": "nix-flatpak",
|
||||
"nixos-anywhere": "nixos-anywhere",
|
||||
"nixos-images": "nixos-images",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"sops-nix": "sops-nix",
|
||||
"tome": "tome"
|
||||
@@ -691,11 +559,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785945821,
|
||||
"narHash": "sha256-NLSyTCW4K4ofhNBllt3omPasm6QpralXH1DBZOc91Dw=",
|
||||
"lastModified": 1786901030,
|
||||
"narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "ae7910970dddc408fe6ab1c8e4b277bb21d72dc0",
|
||||
"rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -730,31 +598,6 @@
|
||||
}
|
||||
},
|
||||
"uv2nix_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": [
|
||||
"hermes-agent",
|
||||
"pyproject-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785277507,
|
||||
"narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"uv2nix_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"mediamanager-nix",
|
||||
|
||||
@@ -33,11 +33,6 @@
|
||||
};
|
||||
authentik-nix.url = "github:nix-community/authentik-nix";
|
||||
nix-flatpak.url = "github:gmodena/nix-flatpak";
|
||||
# Hermes Agent (see hosts/jupiter/hermes-agent.nix) — Tier 2 platform
|
||||
# per its own docs (best-effort Nix support, can break on any upstream
|
||||
# commit), so pinned like everything else via flake.lock rather than
|
||||
# followed loosely.
|
||||
hermes-agent.url = "github:NousResearch/hermes-agent";
|
||||
# Own Hyprland plugin (border + title bar), public repo, fetched over
|
||||
# https (no credentials needed, unlike tome below). `nixpkgs.follows` is
|
||||
# what makes its packaged build ABI-correct — Hyprland plugins are
|
||||
@@ -85,7 +80,7 @@
|
||||
modules = [
|
||||
disko.nixosModules.disko
|
||||
sops-nix.nixosModules.sops
|
||||
inputs.hermes-agent.nixosModules.default
|
||||
home-manager.nixosModules.home-manager
|
||||
./hosts/jupiter/configuration.nix
|
||||
];
|
||||
};
|
||||
@@ -97,6 +92,7 @@
|
||||
modules = [
|
||||
disko.nixosModules.disko
|
||||
sops-nix.nixosModules.sops
|
||||
home-manager.nixosModules.home-manager
|
||||
./hosts/neptun/configuration.nix
|
||||
];
|
||||
};
|
||||
@@ -115,6 +111,19 @@
|
||||
];
|
||||
};
|
||||
|
||||
# mars — on-site x86_64 box, single-purpose: Hermes Agent only.
|
||||
# See hosts/mars/*.
|
||||
mars = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = { inherit inputs; };
|
||||
modules = [
|
||||
disko.nixosModules.disko
|
||||
sops-nix.nixosModules.sops
|
||||
home-manager.nixosModules.home-manager
|
||||
./hosts/mars/configuration.nix
|
||||
];
|
||||
};
|
||||
|
||||
# mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP. Boots from an SD image:
|
||||
# nix build .#nixosConfigurations.mercury.config.system.build.sdImage
|
||||
# (aarch64 build — needs binfmt/qemu on this x86 host, or a remote/aarch64
|
||||
@@ -125,6 +134,7 @@
|
||||
modules = [
|
||||
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
||||
sops-nix.nixosModules.sops
|
||||
home-manager.nixosModules.home-manager
|
||||
./hosts/mercury/configuration.nix
|
||||
];
|
||||
};
|
||||
@@ -137,6 +147,7 @@
|
||||
inherit system; # x86_64-linux, fast to build/boot with KVM
|
||||
modules = [
|
||||
(nixpkgs + "/nixos/modules/virtualisation/qemu-vm.nix")
|
||||
home-manager.nixosModules.home-manager
|
||||
./common.nix
|
||||
./services/network/unbound.nix
|
||||
./services/network/pihole.nix
|
||||
@@ -163,7 +174,10 @@
|
||||
jupiter-vbox = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = { inherit inputs; };
|
||||
modules = [ ./hosts/jupiter/vm.nix ];
|
||||
modules = [
|
||||
home-manager.nixosModules.home-manager
|
||||
./hosts/jupiter/vm.nix
|
||||
];
|
||||
};
|
||||
|
||||
# Custom kexec installer with our SSH key baked in, for headless install
|
||||
@@ -177,7 +191,7 @@
|
||||
nixos-images.nixosModules.kexec-installer
|
||||
({ ... }: {
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5K6AQ0wYYHbNGzC4PyunUQsXbaD0iu1eaadLtv+Xp darman@terra"
|
||||
];
|
||||
})
|
||||
];
|
||||
@@ -201,9 +215,10 @@
|
||||
services.openssh.enable = true;
|
||||
services.openssh.settings.PermitRootLogin = "prohibit-password";
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5K6AQ0wYYHbNGzC4PyunUQsXbaD0iu1eaadLtv+Xp darman@terra"
|
||||
];
|
||||
networking.hostName = "homelab-installer";
|
||||
console.keyMap = "de"; # matches common.nix's real hosts
|
||||
environment.systemPackages = [ pkgs.git ];
|
||||
|
||||
# findiso= is a SCRIPT-stage-1 feature (stage-1-init.sh) only. The
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Shared home-manager profile for darman, applied on every host via
|
||||
# common.nix. Host-specific extras (terra's desktop/dev tooling) layer on
|
||||
# top via their own home-manager.users.darman.imports entry, same pattern
|
||||
# used here — see hosts/terra/configuration.nix + hosts/terra/home.nix.
|
||||
{ ... }:
|
||||
{
|
||||
home.stateVersion = "26.05";
|
||||
programs.home-manager.enable = true;
|
||||
|
||||
# Matches terra's baseline (compinit, deduped/shared history, HISTFILE
|
||||
# under $HOME). home-manager owns ~/.zshrc + ~/.zshenv as real files, which
|
||||
# also means zsh's built-in zsh-newuser-install wizard never fires on
|
||||
# first interactive login (it only triggers when none of
|
||||
# .zshenv/.zprofile/.zshrc/.zlogin exist) — that used to happen on every
|
||||
# host except terra.
|
||||
programs.zsh.enable = true;
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
./hardware-configuration.nix
|
||||
./disk-config.nix # disko: OS-disk partitions + filesystems
|
||||
./secrets.nix # sops-nix: samba password, tailscale key, ...
|
||||
./hermes-agent.nix # Hermes Agent, isolated instance (see file for why)
|
||||
../../common.nix # shared base: user / ssh / nix / firewall
|
||||
../../services/network/samba.nix
|
||||
../../services/network/avahi.nix
|
||||
@@ -14,6 +13,7 @@
|
||||
../../services/containers.nix
|
||||
../../services/network/caddy.nix
|
||||
../../services/vpn/tailscale.nix
|
||||
../../services/monitoring/node-exporter.nix
|
||||
../../services/media/jellyfin.nix
|
||||
../../services/media/sabnzbd.nix
|
||||
../../services/media/prowlarr.nix
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Hermes Agent — jupiter's own instance (terra no longer runs one; see
|
||||
# 713d91d). Locked down given jupiter's much bigger blast radius
|
||||
# (irreplaceable immich photos on an unredundant RAID0, gitea/CI tokens, the
|
||||
# whole media stack):
|
||||
#
|
||||
# - Own dedicated "hermes" system user (module default: user/group "hermes",
|
||||
# createUser = true) — NOT darman. darman is in jupiter's "docker" group
|
||||
# (services/containers.nix: rootful podman with dockerCompat), which is
|
||||
# root-equivalent (`docker run -v /:/host --privileged ...`). Handing an
|
||||
# LLM-driven agent that identity would mean a container escape = root on
|
||||
# the whole NAS.
|
||||
# - container.enable = true, backend = "podman": the ENTIRE gateway process
|
||||
# runs inside a container (reusing jupiter's existing rootful podman
|
||||
# instead of also standing up a second Docker daemon), not just the shell
|
||||
# tool. Per upstream's own SECURITY.md this is "whole-process wrapping" —
|
||||
# shell, file tools, MCP subprocesses, and the code-exec tool are all
|
||||
# confined, unlike the lighter "terminal-backend"-only isolation.
|
||||
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
|
||||
# TELEGRAM_ALLOWED_USERS rather than relying solely on the adapter's
|
||||
# fail-closed default. Sharing terra's bot token would 409-conflict two
|
||||
# long-pollers on the same token.
|
||||
# - No container.extraVolumes into /mnt/data or the homelab repo — nothing
|
||||
# valuable is in reach if a command goes wrong or gets injected via
|
||||
# Telegram/tool output. stateDir/workingDirectory live on the array
|
||||
# (below) purely because coding-task state (repo clones, npm/pip caches
|
||||
# inside the container's writable layer) belongs off the 29G eMMC, same
|
||||
# reasoning as postgres/containers.storage in configuration.nix — NOT
|
||||
# because anything else on /mnt/data is exposed to the agent.
|
||||
{
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
addToSystemPackages = true; # `hermes` on darman's PATH for interactive
|
||||
# debugging over ssh — routes through to the
|
||||
# container, does not grant darman any group.
|
||||
|
||||
# Off the eMMC: stateDir bind-mounts into the container as /data, so this
|
||||
# is where any future scoped repo clone (container.extraVolumes) and the
|
||||
# container's own writable layer (npm/pip installs during coding tasks)
|
||||
# actually land. RequiresMountsFor below (mirrors podman/sabnzbd/gitea-runner
|
||||
# in configuration.nix) keeps the service from starting — and bind-mounting
|
||||
# the wrong, empty eMMC path — before the nofail array is up.
|
||||
stateDir = "/mnt/data/AppData/hermes";
|
||||
workingDirectory = "/mnt/data/AppData/hermes/workspaces";
|
||||
|
||||
# HERMES_TIMEZONE is the highest-priority source hermes_time.py checks
|
||||
# (ahead of config.yaml's `timezone` key) — the container has no host
|
||||
# /etc/localtime bind-mount, so it defaults to UTC otherwise. Not a
|
||||
# secret, so `environment` (plain .env) rather than sops.
|
||||
environment.HERMES_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
container = {
|
||||
enable = true;
|
||||
backend = "podman"; # jupiter already runs podman (services/containers.nix);
|
||||
# default "docker" would stand up a second daemon.
|
||||
};
|
||||
|
||||
# OpenCode Go provider account (same key originally used for terra's now-
|
||||
# removed instance, copied into secrets/jupiter.yaml — just an API key,
|
||||
# not a stateful identity like the Telegram bot token).
|
||||
settings.model = {
|
||||
provider = "opencode-go";
|
||||
base_url = "https://opencode.ai/zen/go/v1";
|
||||
default = "gpt-5.6-luna";
|
||||
api_mode = "codex_responses";
|
||||
};
|
||||
|
||||
settings.platforms.telegram = {
|
||||
enabled = true;
|
||||
home_channel = {
|
||||
platform = "telegram";
|
||||
chat_id = "15151223";
|
||||
name = "Erik Simon";
|
||||
user_id = "15151223";
|
||||
};
|
||||
};
|
||||
|
||||
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
|
||||
};
|
||||
|
||||
systemd.services.hermes-agent.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
}
|
||||
@@ -64,21 +64,4 @@
|
||||
sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd";
|
||||
sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd";
|
||||
|
||||
# Hermes Agent (hosts/jupiter/hermes-agent.nix) — a separate, isolated
|
||||
# instance from terra's, with its OWN Telegram bot token (sharing terra's
|
||||
# would 409-conflict two long-pollers on one token). opencode_go_api_key
|
||||
# is the same provider account as terra (hosts/terra/secrets.nix) — a
|
||||
# stateless API key, fine to duplicate across hosts. No owner override:
|
||||
# sops.templates renders via a root-run activation script, which the
|
||||
# hermes module's own activation script (also root) then reads — unlike
|
||||
# sabnzbd's preStart, this doesn't run as the service's own user.
|
||||
sops.secrets.opencode_go_api_key = { };
|
||||
sops.secrets.telegram_bot_token = { };
|
||||
sops.templates."hermes-agent.env".content = ''
|
||||
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
|
||||
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
|
||||
TELEGRAM_HOME_CHANNEL=15151223
|
||||
TELEGRAM_ALLOWED_USERS=15151223
|
||||
'';
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
# mars — on-site x86_64 box, single-purpose: runs Hermes Agent only.
|
||||
# See hermes-agent.nix for what that is and why it moved here from jupiter.
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
./disk-config.nix # disko: OS-disk partitions + filesystems
|
||||
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
|
||||
./hermes-agent.nix
|
||||
../../common.nix # shared base: user / ssh / nix / firewall
|
||||
../../services/containers.nix
|
||||
../../services/vpn/tailscale.nix
|
||||
../../services/monitoring/node-exporter.nix
|
||||
];
|
||||
|
||||
networking.hostName = "mars";
|
||||
networking.networkmanager.enable = true; # DHCP on-site, same as jupiter
|
||||
users.users.darman.extraGroups = [ "docker" ]; # merges with common.nix; podman debug access
|
||||
|
||||
# ---- Boot (UEFI, confirmed) ----
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
# jupiter's samba share (services/network/samba.nix) — mounted on demand so
|
||||
# mars doesn't stall boot/login when jupiter is off or unreachable. This is
|
||||
# also where Hermes's shared dropbox lives now (hermes-agent.nix). Modes are
|
||||
# tighter than terra's equivalent mount (0770 not 0755, gid=hermes not
|
||||
# gid=users) since the hermes-agent container (uid 986, gid 983 — no podman
|
||||
# userns remapping, see services/network/pihole.nix) needs group write into
|
||||
# it, not just darman.
|
||||
fileSystems."/mnt/jupiter" = {
|
||||
device = "//jupiter/data";
|
||||
fsType = "cifs";
|
||||
options = [
|
||||
"credentials=${config.sops.templates."jupiter-smb.credentials".path}"
|
||||
"uid=1000"
|
||||
"gid=983"
|
||||
"file_mode=0770"
|
||||
"dir_mode=0770"
|
||||
"nofail"
|
||||
"x-systemd.automount" # lazy-mount so boot doesn't stall if jupiter's down
|
||||
# NO idle-timeout here (unlike terra's equivalent mount): hermes-agent's
|
||||
# podman-hermes-agent.service RequiresMountsFor this path, so an idle
|
||||
# auto-unmount tears the container down with it — confirmed the hard
|
||||
# way, it killed the service ~60-70s after every start with no crash
|
||||
# or error, just "Unmounting /mnt/jupiter" right before the stop.
|
||||
"x-systemd.mount-timeout=10s"
|
||||
"_netdev"
|
||||
];
|
||||
};
|
||||
|
||||
system.stateVersion = "26.05"; # set at install time; do NOT bump on upgrades
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{ ... }:
|
||||
|
||||
# Declarative OS-disk layout (disko). UEFI: GPT with an ESP + ext4 root,
|
||||
# same pattern as jupiter/terra (confirmed UEFI-capable, not the legacy-BIOS
|
||||
# guess this scaffold started with).
|
||||
#
|
||||
# ⚠️ This disk is WIPED on install. Set `device` below to the real OS disk
|
||||
# ONLY (by-id) — `ls -l /dev/disk/by-id` once you have console access.
|
||||
{
|
||||
disko.devices.disk.os = {
|
||||
type = "disk";
|
||||
device = "/dev/disk/by-id/ata-Samsung_SSD_840_EVO_120GB_S1D5NSAFB10834Z";
|
||||
content = {
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
ESP = {
|
||||
size = "512M";
|
||||
type = "EF00";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "vfat";
|
||||
mountpoint = "/boot";
|
||||
mountOptions = [ "umask=0077" ];
|
||||
};
|
||||
};
|
||||
root = {
|
||||
size = "100%";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Do not modify this file! It was generated by ‘nixos-generate-config’
|
||||
# and may be overwritten by future invocations. Please make changes
|
||||
# to /etc/nixos/configuration.nix instead.
|
||||
{ config, lib, pkgs, modulesPath, ... }:
|
||||
|
||||
{
|
||||
imports =
|
||||
[ (modulesPath + "/installer/scan/not-detected.nix")
|
||||
];
|
||||
|
||||
boot.initrd.availableKernelModules = [ "xhci_pci" "ehci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" ];
|
||||
boot.initrd.kernelModules = [ ];
|
||||
boot.kernelModules = [ "kvm-intel" ];
|
||||
boot.extraModulePackages = [ ];
|
||||
|
||||
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
# Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix,
|
||||
# see its git history / b5fa599 / 713d91d for the terra->jupiter->mars
|
||||
# lineage). mars is dedicated to this one service, on-site, with no big
|
||||
# data array of its own — unlike jupiter it has nothing under /mnt/data, so
|
||||
# state lives on the local OS disk and the shared dropbox rides jupiter's
|
||||
# samba share as a CIFS client instead of being served locally.
|
||||
#
|
||||
# Runs the OFFICIAL published image (docker.io/nousresearch/hermes-agent —
|
||||
# real and actively maintained, contrary to what the checked-out repo's own
|
||||
# README/docker-compose.yml suggested; verified directly on Docker Hub) as a
|
||||
# plain podman container. It never sets HERMES_MANAGED or writes .managed, so
|
||||
# Hermes fully self-manages config.yaml, profiles, memories and skills at
|
||||
# runtime — no redeploy needed except to bump the pinned digest below.
|
||||
#
|
||||
# Security posture:
|
||||
# - Reachable paths: its own local state dir, the small shared "dropbox"
|
||||
# (via the jupiter samba mount) for darman to hand files to Hermes, and
|
||||
# — new — a clone of THIS repo at ${workspaceDir}/homelab plus `git`/
|
||||
# `tea` (logged in as the `luna` gitea account, PR-tier only — see
|
||||
# services/dev/gitea.nix). Nothing else on jupiter's array or the host
|
||||
# is reachable if a command goes wrong or gets injected via
|
||||
# Telegram/tool output.
|
||||
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
|
||||
# TELEGRAM_ALLOWED_USERS.
|
||||
# - Runs as a rootful podman container (services/containers.nix) with its
|
||||
# OWN numeric uid/gid — not darman, who is in the "hermes" group for
|
||||
# host-level debugging only (`hermes ...` alias below, needs sudo since
|
||||
# the container itself runs under root's podman, not darman's rootless
|
||||
# one).
|
||||
# - git/tea access is direct CLI, not a narrow wrapper: darman explicitly
|
||||
# chose this over a purpose-built MCP server (tried first, scrapped —
|
||||
# see git history) in favor of simplicity. The backstop is entirely
|
||||
# server-side: gitea's branch protection on `master` (only darman can
|
||||
# push/merge/approve there) is what actually keeps a bad or injected
|
||||
# command from reaching the base branch, not anything client-side here.
|
||||
#
|
||||
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on
|
||||
# jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth
|
||||
# provider registered, and 0.0.0.0 (not loopback) is required so neptun's
|
||||
# Caddy can reach it over tailscale0 — reachability itself stays LAN-closed
|
||||
# (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a
|
||||
# trustedInterface, services/vpn/tailscale.nix). Public route: neptun's
|
||||
# hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this
|
||||
# over the tailnet. mars runs no Caddy of its own (single-purpose box), so
|
||||
# there is no LAN vhost — reach the dashboard directly via mars's tailnet
|
||||
# name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging.
|
||||
#
|
||||
# Uses upstream's generic self-hosted OIDC plugin, same Authentik
|
||||
# application as before (slug `hermes`) — the client ID/secret didn't need
|
||||
# to change since the public redirect URI (hermes.mgaction.town) didn't.
|
||||
#
|
||||
# Data migration: this starts with a FRESH state dir. jupiter's instance was
|
||||
# itself reset to fresh on 2026-08-21 (see its old hermes-agent.nix), so
|
||||
# there was nothing irreplaceable to carry forward; if that turns out to be
|
||||
# wrong, jupiter's old data is backed up at
|
||||
# /mnt/data/AppData/hermes.bak-2026-08-21 and can be rsynced into
|
||||
# ${hermesHome} below before the first switch on mars.
|
||||
let
|
||||
stateDir = "/var/lib/hermes";
|
||||
hermesHome = "${stateDir}/.hermes";
|
||||
# Shared drop-in folder: darman can put files here from any host. Lives on
|
||||
# jupiter's array (reachable at /mnt/jupiter, the samba mount below) rather
|
||||
# than locally, so it's the same physical location it always was — only
|
||||
# the container reading it moved. Mounted under /opt/data so it falls
|
||||
# inside Hermes's own sealed write-safe root (HERMES_WRITE_SAFE_ROOT=
|
||||
# /opt/data) rather than a path its own tooling would treat as untrusted.
|
||||
dropboxDir = "/mnt/jupiter/AppData/hermes-dropbox";
|
||||
|
||||
# Pinned by digest (captured 2026-08-21 via `podman image inspect
|
||||
# docker.io/nousresearch/hermes-agent:latest --format '{{.Digest}}'` on
|
||||
# jupiter) rather than floating `:latest`, so a redeploy is reproducible —
|
||||
# bumping Hermes is an explicit edit here, not silent drift on next pull.
|
||||
hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259";
|
||||
|
||||
# Kept identical to jupiter's instance purely so nothing else needs to
|
||||
# change if state ever gets migrated over.
|
||||
hermesUid = "986";
|
||||
hermesGid = "983";
|
||||
|
||||
# luna's own working copy of this repo (git+PR account provisioned in
|
||||
# services/dev/gitea.nix). Lives under hermesHome specifically so it falls
|
||||
# inside HERMES_WRITE_SAFE_ROOT=/opt/data — Hermes's own file-editing
|
||||
# tools can reach it the same way they reach anything else it manages,
|
||||
# without a separate bind mount or sandbox root.
|
||||
workspaceDir = "${hermesHome}/workspace";
|
||||
repoDir = "${workspaceDir}/homelab";
|
||||
giteaHost = "git.mgaction.town";
|
||||
giteaRepo = "darman/homelab";
|
||||
in
|
||||
{
|
||||
# Browsing convenience (ssh access to the bind-mounted local state) — does
|
||||
# NOT touch the container, which keeps using HERMES_UID/GID above
|
||||
# regardless of what's declared here.
|
||||
users.groups.hermes.gid = 983;
|
||||
users.users.darman.extraGroups = [ "hermes" ];
|
||||
|
||||
# `hermes <args>` on mars == `sudo podman exec -it hermes-agent hermes <args>`.
|
||||
# sudo is required: virtualisation.oci-containers runs rootful (system)
|
||||
# podman, a separate namespace from darman's own rootless `podman`/`docker`
|
||||
# — darman's "hermes"/"docker" group membership only grants filesystem
|
||||
# access to the bind-mounted state dir, not to root's container socket.
|
||||
programs.zsh.shellAliases.hermes = "sudo podman exec -it hermes-agent hermes";
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${stateDir} 0750 root hermes -"
|
||||
];
|
||||
|
||||
# podman requires the bind-mount source to already exist (no auto-create),
|
||||
# and the dropbox lives on the CIFS mount below — mkdir there works fine
|
||||
# over cifs, no server-side (jupiter) config needed.
|
||||
#
|
||||
# Also provisions luna's git/tea access: writes a git credential-store file
|
||||
# and runs `tea logins add` INTO hermesHome (i.e. paths that appear at
|
||||
# /opt/data/... once the container is up), and clones this repo if it
|
||||
# isn't already there. All of this runs on the HOST as root, before the
|
||||
# container starts — the container's own entrypoint is what fixes
|
||||
# ownership to HERMES_UID/HERMES_GID on first boot (same mechanism
|
||||
# already relied on for the rest of hermesHome; nothing new here).
|
||||
#
|
||||
# Delete-then-add for the tea login (not a "does it exist" check): tea can
|
||||
# leave a login entry behind even when `add` reports failure (e.g. a token
|
||||
# missing a scope errors out AFTER the entry is written — observed
|
||||
# directly against the real instance during the first version of this
|
||||
# setup). Delete-then-add is idempotent either way and picks up a rotated
|
||||
# token for free.
|
||||
systemd.services.hermes-agent-prepare-dirs = {
|
||||
description = "Create Hermes state dirs + luna's git/tea access before the container starts";
|
||||
before = [ "podman-hermes-agent.service" ];
|
||||
wantedBy = [ "podman-hermes-agent.service" ];
|
||||
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
||||
path = [ pkgs.git pkgs.tea ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
script = ''
|
||||
mkdir -p ${hermesHome}
|
||||
mkdir -p ${dropboxDir}
|
||||
mkdir -p ${workspaceDir}
|
||||
|
||||
export HOME=${hermesHome}
|
||||
export GIT_CONFIG_GLOBAL=${hermesHome}/.gitconfig
|
||||
export XDG_CONFIG_HOME=${hermesHome}/.config
|
||||
token_file=${config.sops.secrets.gitea_luna_token.path}
|
||||
|
||||
# Never embed the token in the remote URL (would land in
|
||||
# repoDir/.git/config in plaintext) — the credential helper reads it
|
||||
# from this file instead.
|
||||
install -m 0600 /dev/null ${hermesHome}/.git-credentials
|
||||
printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \
|
||||
> ${hermesHome}/.git-credentials
|
||||
git config --global credential.helper "store --file=${hermesHome}/.git-credentials"
|
||||
git config --global user.name "luna"
|
||||
git config --global user.email "luna@${giteaHost}"
|
||||
|
||||
if [ ! -d ${repoDir}/.git ]; then
|
||||
git clone "https://${giteaHost}/${giteaRepo}.git" ${repoDir}
|
||||
fi
|
||||
|
||||
tea logins delete luna 2>/dev/null || true
|
||||
GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \
|
||||
--name luna --url "https://${giteaHost}" --no-version-check
|
||||
'';
|
||||
};
|
||||
|
||||
virtualisation.oci-containers.containers.hermes-agent = {
|
||||
image = hermesImage;
|
||||
autoStart = true;
|
||||
# Host networking: Hermes only long-polls Telegram outbound, no inbound
|
||||
# ports to publish (same reasoning as clonarr on jupiter).
|
||||
extraOptions = [ "--network=host" ];
|
||||
# Upstream's own documented single-mount pattern (docker/docker-compose.yml):
|
||||
# ~/.hermes:/opt/data.
|
||||
volumes = [
|
||||
"${hermesHome}:/opt/data"
|
||||
"${dropboxDir}:/opt/data/dropbox"
|
||||
|
||||
# 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
|
||||
# locations. /nix/store itself has to come along too since both
|
||||
# binaries are dynamically linked against paths inside it; the store
|
||||
# is read-only content-addressed build output, not a source of
|
||||
# secrets, so mounting the whole thing read-only costs nothing beyond
|
||||
# the two specific binaries actually being reachable.
|
||||
"/nix/store:/nix/store:ro"
|
||||
"${pkgs.git}/bin/git:/usr/local/bin/git:ro"
|
||||
"${pkgs.tea}/bin/tea:/usr/local/bin/tea:ro"
|
||||
];
|
||||
environment = {
|
||||
HERMES_UID = hermesUid;
|
||||
HERMES_GID = hermesGid;
|
||||
TZ = "Europe/Berlin";
|
||||
|
||||
# Point git/tea at the config the prepare-dirs oneshot wrote into
|
||||
# hermesHome (visible here as /opt/data/...) — the credential-store
|
||||
# helper, the luna gitea login, and (implicitly, via HOME not being
|
||||
# overridden) darman's Hermes state stays wherever it already was.
|
||||
GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig";
|
||||
XDG_CONFIG_HOME = "/opt/data/.config";
|
||||
# HERMES_TIMEZONE is the highest-priority source hermes_time.py checks
|
||||
# (ahead of config.yaml's `timezone` key) — the container has no host
|
||||
# /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in
|
||||
# 9403122 on jupiter; carried forward here).
|
||||
HERMES_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
# Dashboard + Authentik OIDC gate — see the file-level comment above.
|
||||
HERMES_DASHBOARD = "1";
|
||||
HERMES_DASHBOARD_HOST = "0.0.0.0"; # must be tailscale0-reachable, not just loopback
|
||||
HERMES_DASHBOARD_OIDC_ISSUER = "https://auth.mgaction.town/application/o/hermes/";
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6";
|
||||
# uvicorn's proxy_headers=True (web_server.py) only trusts
|
||||
# X-Forwarded-Proto from forwarded_allow_ips, which defaults to
|
||||
# 127.0.0.1 — neptun's Caddy reaches this over the tailnet (a real
|
||||
# routed IP), so without this the dashboard sees the raw scheme (http)
|
||||
# and builds an http:// redirect_uri that Authentik rejects against its
|
||||
# registered https:// one. Safe to trust any peer here: 9119 is already
|
||||
# scoped to loopback + tailscale0 only (no LAN firewall rule), so
|
||||
# nothing untrusted can reach this process to begin with.
|
||||
FORWARDED_ALLOW_IPS = "*";
|
||||
};
|
||||
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
|
||||
cmd = [ "gateway" "run" ];
|
||||
};
|
||||
|
||||
systemd.services.podman-hermes-agent = {
|
||||
after = [
|
||||
"hermes-agent-prepare-dirs.service"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
];
|
||||
requires = [ "hermes-agent-prepare-dirs.service" ];
|
||||
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{ config, ... }:
|
||||
|
||||
# sops-nix wiring for mars. Encrypted values in ../../secrets/mars.yaml,
|
||||
# decrypted with mars's own SSH host key (recipient in ../../.sops.yaml).
|
||||
# The host key is pre-generated on the laptop and shipped at install
|
||||
# (nixos-anywhere --extra-files -> /etc/ssh/ssh_host_ed25519_key).
|
||||
{
|
||||
sops.defaultSopsFile = ../../secrets/mars.yaml;
|
||||
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
||||
|
||||
sops.secrets.darman_password.neededForUsers = true;
|
||||
users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path;
|
||||
|
||||
sops.secrets.tailscale_authkey = { };
|
||||
|
||||
# Credentials file for the //jupiter/data cifs mount (see configuration.nix).
|
||||
# Same value as jupiter's own samba_password (services/network/samba.nix) —
|
||||
# mars authenticates as the same smb user, mirroring terra's setup.
|
||||
sops.secrets.samba_password = { };
|
||||
sops.templates."jupiter-smb.credentials".content = ''
|
||||
username=darman
|
||||
password=${config.sops.placeholder.samba_password}
|
||||
'';
|
||||
|
||||
# Hermes Agent (hermes-agent.nix) — moved here from jupiter (see that
|
||||
# host's git history); same Telegram bot token, opencode key, and
|
||||
# Authentik OIDC client secret, so no new bot/app to provision.
|
||||
sops.secrets.opencode_go_api_key = { };
|
||||
sops.secrets.telegram_bot_token = { };
|
||||
sops.secrets.hermes_dashboard_oidc_client_secret = { };
|
||||
sops.templates."hermes-agent.env".content = ''
|
||||
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
|
||||
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
|
||||
TELEGRAM_HOME_CHANNEL=15151223
|
||||
TELEGRAM_ALLOWED_USERS=15151223
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
|
||||
'';
|
||||
|
||||
# luna's own gitea push token (services/dev/gitea.nix provisions the
|
||||
# account + PR-tier repo access on jupiter; this is the per-user token
|
||||
# generated once via `gitea admin user generate-access-token --username
|
||||
# luna --scopes write:repository,read:user` on jupiter — read:user is
|
||||
# required, `tea logins add` fails without it). Read directly by
|
||||
# hermes-agent.nix's prepare-dirs oneshot (default root:root owner is
|
||||
# fine — that oneshot already runs as root) to set up a git
|
||||
# credential-store file and a `tea` login, both written into hermesHome
|
||||
# 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" ];
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
../../services/network/unbound.nix # local recursive resolver (127.0.0.1:5335)
|
||||
../../services/network/pihole.nix # DNS adblock + DHCP (declarative static leases)
|
||||
../../services/vpn/tailscale.nix # tailnet node (headscale on neptun)
|
||||
../../services/monitoring/node-exporter.nix
|
||||
];
|
||||
|
||||
networking.hostName = "mercury";
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
../../common.nix # shared base: user / ssh / nix / firewall
|
||||
../../services/network/caddy.nix
|
||||
../../services/vpn/tailscale.nix
|
||||
../../services/monitoring/node-exporter.nix
|
||||
../../services/identity/authentik.nix
|
||||
../../services/vpn/headscale.nix
|
||||
../../services/vpn/headplane.nix
|
||||
@@ -110,6 +111,13 @@
|
||||
reverse_proxy http://jupiter.orbit.sol:2283
|
||||
'';
|
||||
|
||||
# ---- Hermes dashboard ----
|
||||
# Authentik-gated (hosts/mars/hermes-agent.nix has the OIDC config and the
|
||||
# "create the Authentik app" instructions — moved here from jupiter).
|
||||
services.caddy.virtualHosts."hermes.mgaction.town".extraConfig = ''
|
||||
reverse_proxy http://mars.orbit.sol:9119
|
||||
'';
|
||||
|
||||
# ---- Gitea WebUI ----
|
||||
# Gitea's web UI and HTTPS clones (services/dev/gitea.nix, HTTP_PORT 3000).
|
||||
# Its SSH side is the separate :2222 forward further down.
|
||||
|
||||
@@ -15,6 +15,7 @@ in
|
||||
../../common.nix
|
||||
../../services/containers.nix
|
||||
../../services/vpn/tailscale.nix
|
||||
../../services/monitoring/node-exporter.nix
|
||||
../../services/desktop/desktop-hyprland.nix
|
||||
../../services/desktop/desktop-apps.nix
|
||||
../../services/desktop/librechat.nix
|
||||
@@ -43,11 +44,12 @@ in
|
||||
programs.nix-ld.enable = true;
|
||||
|
||||
# ---- home-manager (user-level config for darman) ----
|
||||
home-manager.useGlobalPkgs = true;
|
||||
home-manager.useUserPackages = true;
|
||||
home-manager.backupFileExtension = "hm-bak";
|
||||
# Base settings (useGlobalPkgs/useUserPackages/backupFileExtension) and the
|
||||
# shared zsh baseline now live in common.nix + home/common.nix, applied to
|
||||
# every host. This just layers terra's desktop/dev-specific profile on top
|
||||
# — home-manager.users.darman.imports merges additively across modules.
|
||||
home-manager.extraSpecialArgs = { inherit unstable inputs; };
|
||||
home-manager.users.darman = import ./home.nix;
|
||||
home-manager.users.darman.imports = [ ./home.nix ];
|
||||
|
||||
# ---- Boot (UEFI) ----
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
|
||||
@@ -3,9 +3,11 @@ let
|
||||
tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; };
|
||||
in
|
||||
{
|
||||
# home.stateVersion, programs.home-manager.enable, programs.zsh.enable all
|
||||
# come from home/common.nix (shared across every host) via
|
||||
# configuration.nix's home-manager.users.darman.imports.
|
||||
imports = [ ./home/hyprland.nix ./home/theme.nix ];
|
||||
|
||||
home.stateVersion = "26.05";
|
||||
home.keyboard.layout = "de";
|
||||
|
||||
programs.git = {
|
||||
@@ -15,9 +17,6 @@ in
|
||||
user.email = "mail@erik-s.dev";
|
||||
};
|
||||
};
|
||||
programs.home-manager.enable = true;
|
||||
|
||||
programs.zsh.enable = true;
|
||||
|
||||
# direnv + nix-direnv: lets per-repo devShells (e.g. ~/Data/Dev/repos/Tome's
|
||||
# flake.nix) auto-load in the shell AND in Rider via its "direnv
|
||||
|
||||
@@ -204,6 +204,8 @@ in
|
||||
(bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs"))
|
||||
# toggle the Slant sidebar
|
||||
(bind "SUPER + CTRL + S" (dsp.global "quickshell:sidebar"))
|
||||
# toggle the host vitals HUD
|
||||
(bind "SUPER + CTRL + V" (dsp.global "quickshell:vitals"))
|
||||
|
||||
(bind "SUPER + B" (dsp.exec "vivaldi"))
|
||||
(bind "SUPER + E" (dsp.exec "cosmic-files"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
samba_password: ENC[AES256_GCM,data:K3FtKC0CrOLMyfQokmxxlyUnDPo=,iv:9bTE/S/i05LJYldOHuBuz3+g8JdTuq0ysFFgXOmfti8=,tag:77NIxy7qpHRqCmbOR83AfA==,type:str]
|
||||
darman_password: ENC[AES256_GCM,data:DOHHlM4Qdw4WgkN+/M51n2LMjJqq5MS0FeGEH8Pz3297yUZ8jBDOKRS/Tek9stC05JKEQRtn/vVVbAY0nXs20MwrsDMo+IEFXx7Ms90vVyYIYe5O5/0aQkPq84vcwGN0RW1Rj5Y3s38vWA==,iv:DAqHbvOBq7FT7ALbmBXJ0HadEGEsL8V2e7R99H5ZH0s=,tag:9XpiRFIbaZjMn59uHCOA1g==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:lRoyOId9W0be8HFLjdq9zd5qGfvVTJMAiJrxms7SQNjiRSqCz5btcxYlwBKTE7L13KOvwCd+HX6/7VXxSFYg7RQC8r7wXaPPP9+eJLzZL4x3tt8YTxdx+A==,iv:rLJnoRT/HU3VOrOG5RXnFF2D/Lo9Pq6VqvZ9G6WIvRE=,tag:/iD6Vdn0JJAeEWVJThe/cg==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:+6W85d14sdDNv6pcfM9nqVR1sg68EzStT66jI0T6AKGkVw1+W+fRGulvrejCIkZHTz+YBcHhD63irxGc/CiS7P6U2hsLWjrnHqkKvSc0Wxzss6S1b9Rbnw==,iv:dhDmL0T1poPTYbWXD4FHgliCzspEHYCqwuyxC0uOXro=,tag:QX8uedKt6fX0xVVIKiRAjA==,type:str]
|
||||
mediamanager_token_secret: ENC[AES256_GCM,data:g75vj1E6B029O076yV3DS/1z99Tq6wMhEVx+ULYDjHsplyA+vqVRvBlviC64V47IMqKd9k2eTGBJ98Ptv3UIjbr446xZinz0c7PZgHU0XOX4EUcB1ORloMFZIv1zUv8VRjUISUQnn/vRk60e7u8eXrOrgXjxfnoBKrdKZmqxhAM=,iv:2t0XBExC9RvbTomezka+99/LtJl64zNwneA7WWc4ju0=,tag:SVCYuw0n6JevnvoQOIkjPw==,type:str]
|
||||
sabnzbd_api_key: ENC[AES256_GCM,data:6UW1u2Ikmnq34t4H4k/4C44SJeFHRlaPjWwUjEfH1GQ=,iv:sGsd8Sd2pfUhTUDg6PlRzfVYejRbF69jmDTIa2fvY4M=,tag:3fOLgU1K1gKHxQ3J1+3oRQ==,type:str]
|
||||
prowlarr_api_key: ENC[AES256_GCM,data:ab1QACaagI8ACJXFUy8r9X9lgYwRo1byUmhBPSRWwKk=,iv:EcuF6EN/4mWxlXi6R1qDzv4rOw6AT+OGSNQaaBwjJHg=,tag:hixHrbQWU6QQZNMM5rNDsg==,type:str]
|
||||
@@ -14,8 +14,6 @@ sabnzbd_web_password: ENC[AES256_GCM,data:9Lo=,iv:H0Kz8A534RxX+7/Aue8Q87gCzSY5e/
|
||||
sabnzbd_nzb_key: ENC[AES256_GCM,data:DNVenqhJ7wf5Ng0XRA1gJN95e+90e6D9NImOSHJv/Us=,iv:eqFn0stB5pqh0ls4/impD8gc/lOkORwEJzRP6m7u1XU=,tag:Zs8ogLBZEZLyMvFBqhfpIA==,type:str]
|
||||
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]
|
||||
opencode_go_api_key: ENC[AES256_GCM,data:7kgWiye0wHCxzKFsrzX2WQNDkSVpuvJN6w5Zw9tuyYj5ysDRnWDjCvQtWEJlBalq+Fz7HfT28uFLFtrjFornGEPPdQ==,iv:9Ue/nMpJozVy7oHvhvHwKNuMlsb3tXjwnpC3jok5IWs=,tag:K2UqXBnMH3lpnATa2A/Agg==,type:str]
|
||||
telegram_bot_token: ENC[AES256_GCM,data:wU3CgKqbO1twJMIAlVi6rzVP5IUu34l1JOBVnlvTzhGL+Teq/sodQ9nlZOkzfg==,iv:8WxWDkGitljLa8aiwiT8td/3WeEnZAvz38oVPF5TQ4I=,tag:x9ldeAJOsTUJqAE4YZMPNg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -36,7 +34,7 @@ sops:
|
||||
CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt
|
||||
lastmodified: "2026-08-19T19:44:22Z"
|
||||
mac: ENC[AES256_GCM,data:ZgHrvBYUeUDo7ZydN3K5CbIUXDvbvj1whnSWuzc+x5TlejqQH89zXRMKBuDD2DgzS+ET6PFHgN+0KoQchOI9SVGmdzz0b3mKpAMZc1BXYWOy5OcxF2xToA2Gub+QV3KQ1VsLSuR6bHcPKR6h4vAs1iwRKYAguQqo7LJMKAduuWs=,iv:EhUYS1iZOT9AbTk2A0EfLFjqIjLU7KYd1xHWwbwzUpU=,tag:Lh2yKA2AELEZk8axXdJJiA==,type:str]
|
||||
lastmodified: "2026-08-21T23:14:15Z"
|
||||
mac: ENC[AES256_GCM,data:7ts5oWyiPAUtF8OokDqzjZnoH0CCRwdsvF330SeCBnoyATXsWsXOvrLdTVBJf24MSMyJxCQSqBUpzKVVIlVOL1C4KjA+axr34M4oWJ/kEUReO1q9Lrl3/SuuV8PLji6/Z7pTU9tuhl4jsIPdzDsM9oZv6PbxXeex/d4fiw8Qex4=,iv:KX/xBM7HZ2NoCt4T8dhYA7o6h2eBAOdsegEplbxIAnM=,tag:KyddDKzAXV6jvMjnEV0H2Q==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
darman_password: ENC[AES256_GCM,data:3Kj3wfGDfS2vvTCaYC87Aq8ak2DDWqDP0YjxXk8V1CMkA2wnAP41aw++/soU0gpyMGDOY759WqRu0Wzup0Gbg4ywzQKoxbuz8Oi7mV/FxxraYsBlej0R4t+1484msrboUV4hdVdn4XdPuA==,iv:jqFcanbGXFgPNnFaZ/+TzfoUPZOJTNFcyZIGCKqC5is=,tag:bwbYiNwL79h5R30feyxpBw==,type:str]
|
||||
samba_password: ENC[AES256_GCM,data:4eBsiIGLdImuf7fdCItb8GIfR5A=,iv:+ryMF+7kJrDKw7qLWpP6asZzu85pFFJuOan1NMwIbr4=,tag:s8XslqPR0ptdVaOWDWRiyQ==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:An+OPDZF9kmemzoDhZPo7yMljksCz3yE/W9I1EAwtjXH3Iwe/L93Vr+WsWmw/mbvJFXMi2Vk20JIm6D/lm0zJe0qsd7Ooaj26h3xiAfG0PtoxUt+ABFXkQ==,iv:ShgYTnTb1VLOMYPJHjfs+LSebMI2fkKxU7wFzzFtiTo=,tag:9eSYCNKllY/xkhC1gC7hRw==,type:str]
|
||||
opencode_go_api_key: ENC[AES256_GCM,data:x7V6iRrP6UMvMAYh/25bcrE10MHhL9lasCYRHiQ3PIDI6aL+uXP0/YpfrRPY+60m5Yv+Bd7+9aWTWdAVu1laSNjJGg==,iv:EmEAig+fSMYX+g77UpkiQ0USxUYOfFWX4WjIj9NA9N8=,tag:Pr+EZW6uDTSGjng8iG2SZw==,type:str]
|
||||
telegram_bot_token: ENC[AES256_GCM,data:WX+KFtoqFodkoWNwd7EXUrUJakZ9oaMZgg4OnCeL/JVXcsdQesD1PLmKp6vK9g==,iv:m1oqKlcesvhMLtndyp/XxsUAy0YpEsSulPDK0V+Wh0A=,tag:zvLcxcQ+A4fQUht5GkL2Qw==,type:str]
|
||||
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:EgSgzXFlYHN1yAlpjBBjSxacVYO9mhe1TBtAjNMZDEPxkeizB5O8Bw==,iv:pKN6bz7mBV3HxqBdnJi6ah17bukhd+sXeItojngT0HE=,tag:1wCfPK+MJb+P/S/kq2czeQ==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5NkwwZlB3bEptRmhlUS9u
|
||||
NCtma3lERDAwdDNuTlF4clhTbVlBb1pFMENJCkxuUVp4SkdCTmZQNm5lYjNzZnlq
|
||||
RWZEcGVtNWM3b1R3SkZaWXI3NjVSNG8KLS0tIDRmUjcwa2hjeEFTNEl3QndQbzlp
|
||||
V0dFRWJjSldOcTVoNHVGbkgwMmRTdTgKdhINgxsZ5Y8qRF1yDQUOQAwfi8NTEFvw
|
||||
/+WJUFY4fuDW/2o9Cq+UMNT6YXEQQ3kyRmz/Qb/+rD8XwlM9mLGg6A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1cekcqyf7073fsytcjxaa9dr9zwkmn4vjg36rv2tgxdglzfv4jvxqvcj6z2
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqTmR3T0c1RjFvR3FOQUJK
|
||||
SUxwcENlQUxRTndKdURnV0p5L3A1Sk1odHcwCmx4VFc5ZWdrZ3JNc3JkMmQ1M3li
|
||||
WG5ZWG9uZmliKzYzam5oMjhFV0lOTkUKLS0tIGc1MkNSVE50NTl3STBZdlIrZCsx
|
||||
TGR0RmVubmYwVExKV0QvamJnYWgrWkkKAZuwoC4Q4JXKv3tNo5MaKKooUgkwZvs4
|
||||
oyJ7PS3lW+PxH5AZkeeU7gXO/pz2oDku0aDOds7kaD3n0+qSWicQ+Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk
|
||||
lastmodified: "2026-08-22T18:28:46Z"
|
||||
mac: ENC[AES256_GCM,data:Y/QbEoRG2pJ+tz919+kSEfCs6HsjTHmiaO5xWuDhuVXO71Sm+8vx2OQwCnEHWA1FnFoQgBJWJFlAA4yMiFyjtE3Ark9Uxxi07DXYfXJ/B64DBbrxJMQSKVfCxi8KlExbKyL87FSuUwmSeYgE2DIydmOGDv0P+Q0kn5GJ1T6lJOU=,iv:iX9OJMJK3xTsGh8ZLXzZWUj5mZg7jGR3GnvMeR2lXvA=,tag:CvsoVF1vdf4fQmLE3NR9hw==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
@@ -1,6 +1,6 @@
|
||||
darman_password: ENC[AES256_GCM,data:iZQERcXtyH+91yUc3r7U6jnFYrGQPFeCPk/9ZDfxOhPLlGMX3/iEZ+SzZ7a7rDKUeUAaQUsrqANLDLclRYm4Ngo09EkbDxBx5x2GpQwlqSAS45LHnTen9LTzisWghdy79Xnilq322eaB3g==,iv:ozx/BPLR8nZTKHZroKrrh2z6ZlVCuLydQ3aNY4XvcIg=,tag:CSrq5ZipYxtXTT8RintuHQ==,type:str]
|
||||
pihole_webpassword: ENC[AES256_GCM,data:5iOTqD0CcbOCnM1b4+RbajMTyAU=,iv:2ZRW7dshnPzWkuudrn6n92y4Z2n/6fdnBB7BO5/ypS4=,tag:8UZSqJM6dVECQgrF6v5Fuw==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:t/GuqomGLOXnGOOw2fNdbzfBMloz3WoJV+WBEvnFmxYpIBLdJwas4aJD6Jpc+SW+gsb1njb4JPxl11UMxuzBbno8DCIZv21DOc1vVbl4VcDI7kvrw3EkEg==,iv:0I/0sR/Uvbhcq2eqGPQEs1jCFhTkJrtQ/Op2Uvj2LPk=,tag:mrWn/yvy8GhvuqrScR7vrQ==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:swWBS6icqidKMBC6Fo8IyOWIswWzGpRJGhFfA1JPlZsvqEo46J/kLjC6wfU4eOhSBsWTYiiqtHDaX05SKr8gwSxA/ERwj/Swf8bNHST4rbKrI4Cq5QDfzA==,iv:UUdVgkFATla6pmErn2oT06PuQ/kv9L8g0nX2CCPaJhI=,tag:97gAUSfxHzemVljl8FTULw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -21,7 +21,7 @@ sops:
|
||||
x6FfYadcRfqvSX60l6+TGdzq6xDpxLIZOJ8q19qZsAvB0in50HW5gg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1cpty7zrgnn6l97upq00w5wa8zcvnkxkdt2jvhlj97jh83exure4slha43t
|
||||
lastmodified: "2026-07-24T23:31:02Z"
|
||||
mac: ENC[AES256_GCM,data:I3RHwPnGHFXAeRJ4ic2plfhHMz91488KvzN6V15evutEN++affTqJIvI/Hh5Q5LAn8Q8hNkCS9l5Jl/snz+qtlwsSscckcs7jpWSI9cgSYjgEX6DjKpS+95qJo+SabzLdkiY3BlTjzcFZg7CG/lB5MunWgENY/Ad4GGlmUFnMeo=,iv:bWRltxSAd+fiIfQIp+ieqmpob6Fzbrmk1wRJDz3CjSI=,tag:r6BViobJD3MsvLw7Yyj4Rg==,type:str]
|
||||
lastmodified: "2026-08-21T23:14:15Z"
|
||||
mac: ENC[AES256_GCM,data:zxV+szKjxb+7EV/hSyFeHUs/V2wZgIz8NO78/RDZeGoGtCjwDGiSIFsO1VQI5LZPW+O+pTnT2s4W5P0QuEjYrkPU5LRunW+Tv87XrDBqoR62vPvhRmt0wZXOQZu4oAn7LGpn24xo5QYKc2JowJWIVlyQs03UL3jQtgwfkG9u8k4=,iv:IZpZlUVqaKjO0aokwtF2hFAqC2D9H5bVMO6HezmYQ+Y=,tag:ij6pu3b6CQzctrJ87ifp5Q==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ darman_password: ENC[AES256_GCM,data:7G2Hgh13TxI6ugw2ebp9UtLTQ7HRC/hrCga0FmZo8h8
|
||||
authentik_secret_key: ENC[AES256_GCM,data:qLrAWBywlMqT6D3FYDqE9I/5Ep+zDVu9ns9TA4UVOBSQ1uFGEj01TU9norqPf12pi9/Qs32mVzm5BqDG259DxdGT9DZVQc03QVwiIQYtmWo=,iv:OVCIxIP1Xv+nHmYsrxaPgYWQiwzVPUe8pnbyMBVAuoI=,tag:+ywkwPqkY1Dkx1R5cUJ7PA==,type:str]
|
||||
authentik_bootstrap_email: ENC[AES256_GCM,data:OmqpKAiiFyS/rytnHYRZ,iv:VXiPV5VfduC/IW+E3gDlNAeE+hr+IZ9W7Ty6Npuu59Y=,tag:rjvTHnBxN/pMvpQC5W9S6Q==,type:str]
|
||||
authentik_bootstrap_password: ENC[AES256_GCM,data:QPCY0ni3jBQY5HyK+vRlyT4YTEo=,iv:41u1Jf+WYksPUY4pvKdHQA1RHW21GAfdVDRuQ7XtdYc=,tag:SYmzv26GbE1kwleXyKLG4Q==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:1uxBqERZ7U+Fr5vBaGGKajN5KsRzRpdxGBULQ6b5hSgpt2MIC3/Th4zHGNdOl5xYmH2STYv8IOOQrb5p8Xr/55laWQbOLPlfIpCNUbiVWk7uEX9+ctTSiw==,iv:Lm8vx3Oku+isW2WqSNGrkf8aqGoAwVsJQwqH3jOv06Y=,tag:opZeSMyf3bdrLnPM+yES5g==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:mKOC26CLzqsUrgr9C9AqTA+7KFPlFq4wIh81dZ8lapcY5b1RMp9XTaac5e1YXnFQtC+g8ROo3UR5NiUXepQvnaV2YQ0YwsUcrFN33UcqHgwy87YMrfKaoQ==,iv:xp/ljoD6gqVrZ0gzeYbF9wUOtsDgUIMKd/O1nH3NZpg=,tag:pb0QIiRoHEwfm3rCxO/urA==,type:str]
|
||||
headplane_cookie_secret: ENC[AES256_GCM,data:oXYRG4z16u6HS7zXoWrV2q/HL2o24n4UwVXnQvqBmbY=,iv:itAiy/w6ue4VzqO5xYnvSYN3uCLLmu52dvAAxZ2pCGc=,tag:MEFP171GpSQpAqUFsWY+VA==,type:str]
|
||||
headplane_oidc_client_secret: ENC[AES256_GCM,data:RiEESz1WHYH/smlJKmKFpPoF5HRYp8gHJoKqc49xRRuU9WnQCnmETie/68u37sSVniQmQZSOmXxdmvuuJgP5LMcPdkrQmQE+QX80RQhtTVvg2AhqRaK4V+qfQleD/aJjoIHojeM3UBUi4kYOhDJF0FFTP+qR4oitH6W8/2jxRsc=,iv:hXquOglPhRv+QTVRw5fkY7BWuwLtJxK5tNERGBOOmt8=,tag:2KczveRWoq7iTOdwjO+AUw==,type:str]
|
||||
headplane_headscale_api_key: ENC[AES256_GCM,data:KcwcprV100wfAkn+YM4+1oTfXkmyeAMAbXNpQKf2iIq0VxwqVep+fORrKmHiwDViciDoyxdMiT4scGlmd4vQRsWEHdDpkuH7MRHYu0hQepE3rSSQAMbK,iv:NEhmi2hiOE+uSIRZ4uXOK1UGpN+FQx/NpooWzTgwik8=,tag:KcPsD4gp51ERaFwgfA0BrQ==,type:str]
|
||||
@@ -28,7 +28,7 @@ sops:
|
||||
Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp
|
||||
lastmodified: "2026-07-24T23:31:37Z"
|
||||
mac: ENC[AES256_GCM,data:eYiDMFoicUBJbkJ0d5QCwq+YxaslrQDrSCUufzvZ9PkJJ7wWLgq/9ZwMYZfuTR0iWAQCHyhwOEStuLSUT1p51uLS2cSOQtWZfkBOEdUq9HPzzlOMFxyEh052Dw0fkBNswB9oYBdqOfMX8X4z320JlQNUaR/UQ977dkaLaDCMDt8=,iv:kW2D47/VaOPpbITaGnIwpX3Xrv/4JuDfL7QpQFT3OE8=,tag:v6eUKtQ1lfzDmdxQpMWwrw==,type:str]
|
||||
lastmodified: "2026-08-21T23:14:15Z"
|
||||
mac: ENC[AES256_GCM,data:O1wp0QLbfi7hn7+IoKkk+xqGYLX95y9KgVCsjyDjsv6eqiP+RPz5PmEqXapt926HREEPi4vcDW2FbaBMTqtsojbhWMTG0lYmV6zSQ4aDueL9/GmDqdOJDtQdbXhJH9gpAVhwGQzNoRDQ/5wjknDI8GSBVO077aSaaiGbreuuzBQ=,iv:yecrpLaDNFqt7XxHv/KDTXgjOs+5fk/6UXCSxPMuoJ0=,tag:m7co54lvtnZIYqSMX2c3kQ==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
tailscale_authkey: ENC[AES256_GCM,data:DgxbMrKTxC//cWd+ZckCbwZ9j/FgjYwNwadU5cWnnPz+nPEFhXLla2TuUE0pJKpcx9pKZCQmxcGtzQvIB+mbgxjgB0X2CRTp4Yy3WR+PBYMghKx36F8KBg==,iv:hJr6O41OTdMa4wkBXEsRUXgV4zWt21h1kx/CWArLU7o=,tag:IEpMIwKBHakvVYEFdEUe7A==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:B2J+PbFv6o3Dt4em0nCV/9a77VxZ2TFnRFj1QDaaUutDjbKtbO+WXID/kE2kVQhnD5xBmhU5inytytbiP8LYKIcNlklqO5c8hJCzAvFx8xpBfpjxcQrojQ==,iv:aPGqQG14LnUabgan1QYUL0Lf6cIJfMDOQS/XBekoxaM=,tag:A0oKUfwvHZvf5e/NDIWSZQ==,type:str]
|
||||
darman_password: ENC[AES256_GCM,data:G3ZM+NMxvKq5twblcBvyC+MiUX+X7nz+s1GqBHBkJQy1YgW4KN6rpbdj10F9jWxHph5dJ9j9fG6L7UlEg5W5zUYeLFdEx2xJGE1fxksch+6BB9FT6LKkhJ7MmbJmhNHYwJOZO3LM1f/oRw==,iv:abQTRe9kRyYj+TL0rtoxM6JFBaIBmxu7y77qt1h4eME=,tag:OgKrB3SUgSNJjmtJyrmh/Q==,type:str]
|
||||
samba_password: ENC[AES256_GCM,data:UkJLUa2hW1iZ++sfJAcg6G1RJMM=,iv:/HbZ9F+GxCydUP50PNBtJknPlmDWh1DAE26N9FJUyb0=,tag:z1cbwUaVdyfwEIU8LINEcw==,type:str]
|
||||
librechat_creds_key: ENC[AES256_GCM,data:e2Ptf41yHu0KxfzjW4DP04CSJfbtdsJ8bwrgJyv9up4/JSCbBzjPFmOi7jsHUL4jT0AGVuHG5w3y+YOil9EeNA==,iv:zneozNSkXsb4Vy/sq21b8HWCKpDkXVTxyLY2Zh0bwP0=,tag:6CxR5sIspbfzHlfdx+45Iw==,type:str]
|
||||
@@ -25,7 +25,7 @@ sops:
|
||||
sHjKfw8VrrmAR4pQf1dsY+wcyh4FsZxhP3Q+QIVq3eCIXS9PeJkGAg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1rfcmu6zh40v4260l9hnf8ajs9vly0s06rx3ey76eu78dp9t7getqyhmkut
|
||||
lastmodified: "2026-08-19T20:48:49Z"
|
||||
mac: ENC[AES256_GCM,data:YbsOhLmzynV7S86A9w1XqDEe1/soZPewSrsc+5eR49naQNHUrv2j40J271hMh7r2c+gBZkkHAvq/tQy4USaWPdVn44h6+fPYc11lSlfuIGeIDX6KuXcIw4PdrJr5NgHpQ6+l5XASO0vKOifdqUxENEL8Uu5QJflgg6dA+y6RU94=,iv:ZALmRHzSFx9wzgapZVd27/JRCdTYzVdQsqs0eO9+6ko=,tag:9Un6iiPTxy61yNlms+K8Uw==,type:str]
|
||||
lastmodified: "2026-08-21T23:14:15Z"
|
||||
mac: ENC[AES256_GCM,data:gD5C3hYFvIUP7zr4GK40LAtM2sskhGErEzdTxVKaRaPkKNYj+pDYd5uSrBxDHv6W2SAY0TtyN3GyNpD0z5b4yaDxiV8ETUOokkj8f9FlTaZk56S6I/21HvaGXW+Cfi1ioFtNGyJeAhSnNOJ6OE0AA9JfcftSJ8BdTe2LienjHwQ=,iv:LtKFum+f1kaE6/XT4Xj0ZEuFrhSXXsnILMgdebGYNDU=,tag:6DxwNjjgze2ldU1g3SdzzA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
||||
@@ -14,6 +14,12 @@ let
|
||||
# and whitelisted to push past branch protection. Add a repo here and
|
||||
# redeploy — no manual UI clicking needed.
|
||||
ciBotRepos = [ "darman/hypr-chrome" ];
|
||||
|
||||
# Repos where luna (Hermes Agent's own gitea identity — see below) gets PR-tier
|
||||
# access: Write collaborator (so she can push feature branches and open PRs)
|
||||
# but explicitly walled off `master`'s push/merge/approve whitelists so
|
||||
# nothing she does lands without darman clicking merge.
|
||||
lunaRepos = [ "darman/homelab" ];
|
||||
in
|
||||
{
|
||||
services.gitea = {
|
||||
@@ -166,4 +172,96 @@ in
|
||||
'') ciBotRepos}
|
||||
'';
|
||||
};
|
||||
|
||||
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A.,
|
||||
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot:
|
||||
# Hermes runs on mars, takes instructions over Telegram, and can be
|
||||
# prompt-injected via tool output — a dedicated account with its own
|
||||
# scoped, revocable token keeps that blast radius off darman's own
|
||||
# credentials, and the branch-protection whitelists below keep it off
|
||||
# `master` entirely regardless of what the token can technically do.
|
||||
# She gets Write collaborator access (needed to push a branch and open a
|
||||
# PR against the same repo — this instance has no fork workflow), but:
|
||||
# - enable_push + enable_push_whitelist(darman only): nobody but darman
|
||||
# can push straight to master; luna can only land on a side branch.
|
||||
# - enable_merge_whitelist(darman only): opening a PR is not the same
|
||||
# as merging one — only darman can click merge.
|
||||
# - required_approvals=1 + enable_approvals_whitelist(darman only):
|
||||
# an approval has to come from darman specifically, not luna
|
||||
# rubber-stamping her own PR from a second identity.
|
||||
# This is provisioning parity with ci-bot only (account + collaborator +
|
||||
# branch protection) — it does NOT wire a token into mars/hermes-agent.nix
|
||||
# yet; that's a separate step once luna actually has git tooling to call.
|
||||
#
|
||||
# luna's own push token (used by whatever git tooling gets wired into
|
||||
# hermes-agent.nix later) is generated once, the same way ci-bot's was:
|
||||
# su gitea -s /bin/sh -c \
|
||||
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
|
||||
# --username luna --scopes write:repository'
|
||||
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
|
||||
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
|
||||
# since luna isn't a CI workflow running inside gitea, she's an external
|
||||
# agent calling out to it.
|
||||
systemd.services.gitea-luna-provision = {
|
||||
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
|
||||
after = [ "gitea.service" ];
|
||||
requires = [ "gitea.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
|
||||
environment = {
|
||||
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = config.services.gitea.user;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
|
||||
admin_token="$(cat "$TOKEN_FILE")"
|
||||
auth=(-H "Authorization: token $admin_token")
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fs "$api/version" >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! curl -fs "''${auth[@]}" "$api/users/luna" >/dev/null 2>&1; then
|
||||
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
|
||||
--username luna \
|
||||
--email luna@${config.services.gitea.settings.server.DOMAIN} \
|
||||
--random-password --must-change-password=false
|
||||
fi
|
||||
|
||||
${lib.concatMapStringsSep "\n" (repo: ''
|
||||
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
||||
-X PUT "$api/repos/${repo}/collaborators/luna" \
|
||||
-d '{"permission":"write"}'
|
||||
|
||||
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
|
||||
|
||||
protect_body="$(jq -n '{
|
||||
enable_push: true,
|
||||
enable_push_whitelist: true,
|
||||
push_whitelist_usernames: ["darman"],
|
||||
enable_merge_whitelist: true,
|
||||
merge_whitelist_usernames: ["darman"],
|
||||
required_approvals: 1,
|
||||
enable_approvals_whitelist: true,
|
||||
approvals_whitelist_username: ["darman"]
|
||||
}')"
|
||||
|
||||
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$default_branch" >/dev/null 2>&1; then
|
||||
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
||||
-X PATCH "$api/repos/${repo}/branch_protections/$default_branch" \
|
||||
-d "$protect_body"
|
||||
else
|
||||
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
||||
-X POST "$api/repos/${repo}/branch_protections" \
|
||||
-d "$(echo "$protect_body" | jq --arg b "$default_branch" '. + {branch_name: $b}')"
|
||||
fi
|
||||
'') lunaRepos}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{ ... }:
|
||||
|
||||
# Prometheus node_exporter — host vitals (CPU/mem/disk/net/uptime) for the
|
||||
# homelab dashboard. No openFirewall needed: tailscale.nix already trusts
|
||||
# tailscale0, so :9100 is reachable over the tailnet and blocked on every
|
||||
# other interface (LAN, public) without any extra rule here.
|
||||
{
|
||||
services.prometheus.exporters.node.enable = true;
|
||||
}
|
||||
Reference in New Issue
Block a user