Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae6c81bf54 |
+1
-4
@@ -1,4 +1 @@
|
||||
AGENTS.md
|
||||
*.dec
|
||||
keys.txt
|
||||
.superpowers/
|
||||
AGENTS.md
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
keys:
|
||||
- &admin_finn age1a0v442ct2qfjcdhyktn25spw02yq4fl08plrrvuradwwxzsle4mq8thln4
|
||||
- &host_mibook age1n3uqtee5ymykrls6k04dznq50cwmj99hw7ny83yxjndt4y2ncf5qhjwa2p
|
||||
- &host_jupiter age15uxuaxhn2hr4ypmgll36fwnkdkvv6gynvlxuc2p8ek0enmxmr5rsvlq0r2
|
||||
|
||||
creation_rules:
|
||||
- path_regex: ^secrets/mibook\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin_finn
|
||||
- *host_mibook
|
||||
- path_regex: ^secrets/jupiter\.yaml$
|
||||
key_groups:
|
||||
- age:
|
||||
- *admin_finn
|
||||
- *host_jupiter
|
||||
# - path_regex: ^secrets/common\.yaml$ # TODO: only if/when a secret needs to be shared across both hosts
|
||||
# key_groups:
|
||||
# - age: [ *admin_finn, *host_mibook, *host_jupiter ]
|
||||
@@ -35,7 +35,6 @@ flake.nix
|
||||
│ ├── core.nix # system packages, locale, timezone
|
||||
│ ├── network.nix
|
||||
│ ├── nix.nix
|
||||
│ ├── sops.nix # sops-nix decryption config, sops/age/ssh-to-age on PATH
|
||||
│ └── users.nix
|
||||
├── modules/ # custom NixOS option modules (my.profiles.*, my.hardware.*, my.services.*)
|
||||
│ ├── environments/ # per-service/app profiles
|
||||
@@ -44,21 +43,10 @@ flake.nix
|
||||
└── machines/<name>/
|
||||
├── configuration.nix # machine-specific NixOS settings
|
||||
├── environments.nix # enables profiles via my.profiles.* / my.hardware.* options
|
||||
├── secrets.nix # sops.secrets declared for this host
|
||||
├── disks.nix
|
||||
└── hardware-configuration.nix
|
||||
```
|
||||
|
||||
### Secrets (sops-nix)
|
||||
|
||||
Secrets are encrypted with sops, decrypted per-host from that host's SSH host
|
||||
key. `.sops.yaml` defines who can decrypt what; `secrets/<host>.yaml` holds
|
||||
the encrypted values; `machines/<host>/secrets.nix` declares which secrets
|
||||
that host exposes and consumes them via `config.sops.secrets.<name>.path`.
|
||||
Always `git add` a secrets file before `nix build` — untracked files are
|
||||
invisible to flake evaluation. Full workflow (adding a secret, adding a host,
|
||||
rotating keys): see [`docs/secrets.md`](docs/secrets.md).
|
||||
|
||||
### Profile / Module Pattern
|
||||
|
||||
Every module under `modules/` follows the same structure:
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# Secrets (sops-nix)
|
||||
|
||||
Secrets are encrypted with [sops](https://github.com/getsops/sops) and decrypted
|
||||
at activation by [sops-nix](https://github.com/Mic92/sops-nix). Each host decrypts
|
||||
with its own SSH host key (converted to an age key in-memory — no separate host
|
||||
private key is ever stored on disk).
|
||||
|
||||
Tooling (`sops`, `age`, `ssh-to-age`) is available via `nix develop` in this repo,
|
||||
and installed system-wide on every machine via `machines/core/sops.nix`.
|
||||
|
||||
## Adding a secret to an existing host
|
||||
|
||||
```
|
||||
nix develop -c sops secrets/<host>.yaml
|
||||
```
|
||||
|
||||
This opens `$EDITOR` on the decrypted content. Add your key, save, quit — sops
|
||||
re-encrypts on write. Then declare it in `machines/<host>/secrets.nix`:
|
||||
|
||||
```nix
|
||||
sops.secrets.my-secret = {
|
||||
owner = "someuser"; # defaults to root
|
||||
group = "someuser";
|
||||
mode = "0400";
|
||||
restartUnits = [ "foo.service" ]; # if a running service needs to pick it up
|
||||
};
|
||||
```
|
||||
|
||||
Consume it in the owning module as `config.sops.secrets.my-secret.path`
|
||||
(resolves to `/run/secrets/my-secret`) — never inline the decrypted value into
|
||||
a Nix string, since that copies it into the world-readable `/nix/store`.
|
||||
|
||||
Then:
|
||||
|
||||
```
|
||||
nix build '.#nixosConfigurations.<host>.config.system.build.toplevel'
|
||||
sudo nixos-rebuild test --flake '.#<host>'
|
||||
# verify, then:
|
||||
sudo nixos-rebuild switch --flake '.#<host>'
|
||||
```
|
||||
|
||||
**The secrets file must be `git add`ed before `nix build`** — untracked files
|
||||
are invisible to flake evaluation, and `sops.validateSopsFiles` needs it in the
|
||||
Nix store.
|
||||
|
||||
## Adding a new host
|
||||
|
||||
1. On the new host: `ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub` to get its
|
||||
age public key.
|
||||
2. In `.sops.yaml`: add a `&host_<name>` anchor with that key, and a
|
||||
`creation_rules` entry for `secrets/<name>.yaml` including both the new
|
||||
host key and `*admin_finn`.
|
||||
3. Create `machines/<name>/secrets.nix`, add it to that machine's
|
||||
`configuration.nix` imports.
|
||||
4. If the host needs to read an existing shared file, run
|
||||
`sops updatekeys secrets/<file>.yaml` after step 2 so it's re-encrypted to
|
||||
include the new recipient.
|
||||
|
||||
## Rotating keys
|
||||
|
||||
```
|
||||
# after editing .sops.yaml (new/removed recipient):
|
||||
nix develop -c sops updatekeys secrets/<file>.yaml
|
||||
```
|
||||
|
||||
for every file the change affects. `updatekeys` requires you to already be
|
||||
able to decrypt the file — this is why `*admin_finn` is a recipient on every
|
||||
rule, as the standing fallback.
|
||||
|
||||
Reinstalling a host regenerates its SSH host key, which changes its age
|
||||
identity. Any file previously encrypted to that host must be `updatekeys`'d
|
||||
with the new key, or the host's next activation fails to decrypt.
|
||||
|
||||
## Where private keys live
|
||||
|
||||
- **Operator** (you): `~/.config/sops/age/keys.txt` — sops' default lookup
|
||||
path, so `SOPS_AGE_KEY_FILE` never needs to be set. Never committed. Back
|
||||
this up out-of-band (password manager, offline copy) — if it's lost, only
|
||||
the surviving host keys can still decrypt their own files, and any file that
|
||||
was only ever readable by an operator-only rule becomes permanently
|
||||
unreadable.
|
||||
- **Hosts**: `/etc/ssh/ssh_host_ed25519_key` — already managed by NixOS,
|
||||
converted to age in-memory at activation time. Nothing extra to back up.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.sops.yaml # age recipients + creation rules (which keys can decrypt which files)
|
||||
secrets/<host>.yaml # per-host encrypted secrets (default sopsFile for that host)
|
||||
machines/core/sops.nix # applied to every machine: decryption config, sops/age/ssh-to-age on PATH
|
||||
machines/<host>/secrets.nix # per-host: which secrets exist, owner/group/mode
|
||||
```
|
||||
@@ -1,164 +0,0 @@
|
||||
# Homepage Widgets Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `my.homepage.widgets` NixOS option so widgets can be declared in the homepage module and contributed from other modules, mirroring how `my.homepage.services` works today.
|
||||
|
||||
**Architecture:** Add a `my.homepage.widgets` option (typed `listOf attrs` since widget shapes vary by type) to the homepage module, import a `manual-widgets.nix` for ad-hoc definitions, merge both and pass to `services.homepage-dashboard.widgets`. Add starter widgets in `machines/jupiter/environments.nix` to confirm the wiring works end-to-end.
|
||||
|
||||
**Tech Stack:** Nix, NixOS module system, gethomepage.dev (`services.homepage-dashboard`)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Modify | `modules/environments/homepage/default.nix` | Add `my.homepage.widgets` option + wire to dashboard |
|
||||
| Create | `modules/environments/homepage/manual-widgets.nix` | Empty list for ad-hoc widgets (parallel to `manual-services.nix`) |
|
||||
| Modify | `machines/jupiter/environments.nix` | Declare starter widgets to verify the feature end-to-end |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `my.homepage.widgets` option to the homepage module
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/environments/homepage/default.nix`
|
||||
- Create: `modules/environments/homepage/manual-widgets.nix`
|
||||
|
||||
- [ ] **Step 1: Create `manual-widgets.nix`** — empty list, same pattern as `manual-services.nix`
|
||||
|
||||
```nix
|
||||
# modules/environments/homepage/manual-widgets.nix
|
||||
[ ]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `manualWidgets` import in the `let` block of `default.nix`**
|
||||
|
||||
Current `let` block (lines 7–42) ends with `homepageServices`. Add one line after `manualServices`:
|
||||
|
||||
```nix
|
||||
manualServices = import ./manual-services.nix;
|
||||
manualWidgets = import ./manual-widgets.nix;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `my.homepage.widgets` option**
|
||||
|
||||
After the closing brace of `options.my.homepage.services` (line 80), add:
|
||||
|
||||
```nix
|
||||
options.my.homepage.widgets = with lib; mkOption {
|
||||
type = types.listOf types.attrs;
|
||||
default = [ ];
|
||||
description = "Widget definitions passed directly to services.homepage-dashboard.widgets. Each entry is an attrset like { resources = { cpu = true; memory = true; }; }.";
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire widgets to the dashboard in the `config` block**
|
||||
|
||||
The `config` block currently sets `services = homepageServices ++ manualServices;`. Add `widgets`:
|
||||
|
||||
```nix
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.homepage-dashboard = {
|
||||
enable = true;
|
||||
listenPort = dashboardPort;
|
||||
allowedHosts = "${dashboardHost}:${toString dashboardPort},localhost:${toString dashboardPort},127.0.0.1:${toString dashboardPort},jupiter.solar.internal:${toString dashboardPort}";
|
||||
bookmarks = import ./bookmarks.nix;
|
||||
services = homepageServices ++ manualServices;
|
||||
widgets = config.my.homepage.widgets ++ manualWidgets;
|
||||
};
|
||||
# ... rest of config unchanged
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to check for eval errors (no switch yet)**
|
||||
|
||||
```bash
|
||||
nixos-rebuild build --flake /Users/finnmarkwitz/development/nixos#jupiter 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: build succeeds with no errors (widgets list is empty, so no visible change yet).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/finnmarkwitz/development/nixos
|
||||
git add modules/environments/homepage/default.nix modules/environments/homepage/manual-widgets.nix
|
||||
git commit -m "feat: add my.homepage.widgets option to homepage module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add starter widgets in `environments.nix`
|
||||
|
||||
**Files:**
|
||||
- Modify: `machines/jupiter/environments.nix`
|
||||
|
||||
- [ ] **Step 1: Add `my.homepage.widgets` to `environments.nix`**
|
||||
|
||||
Add the following after the `my.profiles` block (e.g. after line 39, before the `my.hardware` block):
|
||||
|
||||
```nix
|
||||
my.homepage.widgets = [
|
||||
{
|
||||
search = {
|
||||
provider = "google";
|
||||
target = "_blank";
|
||||
};
|
||||
}
|
||||
{
|
||||
resources = {
|
||||
cpu = true;
|
||||
memory = true;
|
||||
disk = "/";
|
||||
cacheInterval = 5000;
|
||||
};
|
||||
}
|
||||
{
|
||||
datetime = {
|
||||
text_size = "xl";
|
||||
format = {
|
||||
timeStyle = "short";
|
||||
dateStyle = "short";
|
||||
hour12 = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to confirm eval**
|
||||
|
||||
```bash
|
||||
nixos-rebuild build --flake /Users/finnmarkwitz/development/nixos#jupiter 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Switch and verify visually**
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake /Users/finnmarkwitz/development/nixos#jupiter
|
||||
```
|
||||
|
||||
Open `http://jupiter.solar.internal:8082` (or `http://localhost:8082`) in a browser.
|
||||
|
||||
Expected: search bar, CPU/memory/disk resource monitor, and date/time widget appear at the top of the dashboard.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/finnmarkwitz/development/nixos
|
||||
git add machines/jupiter/environments.nix
|
||||
git commit -m "feat: add starter homepage widgets (search, resources, datetime)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `nixos-rebuild build` passes with no eval errors
|
||||
- [ ] Dashboard at `:8082` shows widgets at top of page
|
||||
- [ ] Existing services and bookmarks are unchanged
|
||||
- [ ] Other modules can now contribute widgets via `my.homepage.widgets = [ ... ];` in their `config` block
|
||||
@@ -1,299 +0,0 @@
|
||||
# Immich NixOS Module Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
> **Note on nature:** Task 1 is repo work verifiable with `nix build` (no runtime tests exist for declarative config). Tasks 2–6 are a **manual migration runbook executed on jupiter by the operator** — they are destructive and cannot be run from the dev machine (mibook). Do not attempt to automate or execute Tasks 2–6 from an agent session; present them for the operator to run and confirm.
|
||||
|
||||
**Goal:** Replace jupiter's docker-compose Immich with the native `services.immich` NixOS module, preserving all data (albums, faces, shares, library).
|
||||
|
||||
**Architecture:** A standard `my.profiles.immich` module wraps `services.immich` (native Postgres+VectorChord over unix socket, Redis, server, machine-learning). Media stays at the default local `/var/lib/immich`. The existing docker Postgres dump is restored same-version (2.7.5 → 2.7.5, no schema/vector migration). GPU is exposed for VAAPI/QSV transcoding.
|
||||
|
||||
**Tech Stack:** NixOS (flake-parts), `services.immich` from nixpkgs 25.11, PostgreSQL, Intel QSV/VAAPI, docker (source only).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Machine: **jupiter** only. Do not enable on mibook.
|
||||
- Immich version: source docker == target nixpkgs == **2.7.5** (stable). No `package` override. Do NOT bump nixpkgs Immich during this work.
|
||||
- Media location: default `/var/lib/immich` (local disk). Do not point at the NAS.
|
||||
- Database: local PostgreSQL over **unix socket + peer auth** — no password, no sops secret.
|
||||
- HW accel: **video transcoding only**. ML stays on CPU (`machine-learning.enable = true`, no OpenVINO).
|
||||
- Access: LAN + VPN, `openFirewall = true`, port **2283**. No reverse proxy/TLS.
|
||||
- Rebuild command: `sudo nixos-rebuild switch --flake '.#jupiter'`.
|
||||
- Build-check command: `nix build '.#nixosConfigurations.jupiter.config.system.build.toplevel'`.
|
||||
- Format Nix with `nixfmt-rfc-style` before committing.
|
||||
- Do not delete docker DB or upload data until Task 6 sign-off.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `modules/environments/immich/default.nix` — the `my.profiles.immich` module (single responsibility: declare Immich).
|
||||
- **Modify** `modules/environments/default.nix` — add `./environments/immich` to the import list.
|
||||
- **Modify** `machines/jupiter/environments.nix` — set `immich.enable = true`.
|
||||
|
||||
No other files change. The DB/media migration touches only runtime state on jupiter, not the repo.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Author the `immich` profile module
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/environments/immich/default.nix`
|
||||
- Modify: `modules/environments/default.nix` (import list)
|
||||
- Modify: `machines/jupiter/environments.nix` (`my.profiles.immich.enable`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: NixOS option `my.profiles.immich.enable` (bool). When true, configures `services.immich`, adds `immich` user to `video`/`render` groups, and appends an entry to `my.homepage.services`.
|
||||
- Consumes: existing `my.homepage.services` aggregator; `config.networking.hostName`.
|
||||
|
||||
- [ ] **Step 1: Read a reference module to match repo style**
|
||||
|
||||
Read `modules/environments/jellyfin/default.nix` (same shape: `cfg`, `hostName`, `port`, `mkIf`, `my.homepage.services`). Match its formatting and header-comment convention.
|
||||
|
||||
- [ ] **Step 2: Create the module file**
|
||||
|
||||
Create `modules/environments/immich/default.nix`:
|
||||
|
||||
```nix
|
||||
# Immich self-hosted photo & video server
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.my.profiles.immich;
|
||||
hostName = config.networking.hostName;
|
||||
port = 2283;
|
||||
in
|
||||
{
|
||||
options.my.profiles.immich = with lib; {
|
||||
enable = mkEnableOption "Immich photo server";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.immich = {
|
||||
enable = true;
|
||||
host = "0.0.0.0";
|
||||
inherit port;
|
||||
openFirewall = true;
|
||||
mediaLocation = "/var/lib/immich";
|
||||
machine-learning.enable = true;
|
||||
accelerationDevices = [ "/dev/dri/renderD128" ];
|
||||
settings.server.externalDomain = "http://${hostName}:${toString port}";
|
||||
};
|
||||
|
||||
# The native module does not add GPU groups; required for VAAPI/QSV transcoding.
|
||||
users.users.immich.extraGroups = [
|
||||
"video"
|
||||
"render"
|
||||
];
|
||||
|
||||
my.homepage.services = [
|
||||
{
|
||||
group = "Media";
|
||||
name = "Immich";
|
||||
description = "Photo & video server";
|
||||
href = "http://${hostName}:${toString port}";
|
||||
icon = "immich.png";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the module in the environments import list**
|
||||
|
||||
Open `modules/environments/default.nix` and add `./environments/immich` (or `./immich`, matching the exact relative style already used in that file — check how `jellyfin` is listed and mirror it).
|
||||
|
||||
- [ ] **Step 4: Enable it on jupiter**
|
||||
|
||||
In `machines/jupiter/environments.nix`, inside the `my.profiles = { ... }` block, add:
|
||||
|
||||
```nix
|
||||
immich.enable = true;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Format**
|
||||
|
||||
Run: `nixfmt-rfc-style modules/environments/immich/default.nix`
|
||||
|
||||
- [ ] **Step 6: Build-check (this is the "test")**
|
||||
|
||||
Run: `nix build '.#nixosConfigurations.jupiter.config.system.build.toplevel'`
|
||||
Expected: builds successfully. If it fails on an unknown option (e.g. `accelerationDevices`, `settings.server.externalDomain`), reconcile against the module at `$(nix eval --raw '.#nixosConfigurations.jupiter.pkgs.path')/nixos/modules/services/web-apps/immich.nix` and fix.
|
||||
|
||||
- [ ] **Step 7: Confirm the option evaluates on**
|
||||
|
||||
Run: `nix eval '.#nixosConfigurations.jupiter.config.services.immich.enable'`
|
||||
Expected: `true`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add modules/environments/immich/default.nix modules/environments/default.nix machines/jupiter/environments.nix
|
||||
git commit -m "feat(jupiter): add native Immich profile module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Pre-flight & backup on jupiter (operator-run)
|
||||
|
||||
**Files:** none (runtime state on jupiter). Run all commands on jupiter.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `immich-db.sql` dump file and a known-good copy/snapshot of the docker upload folder; recorded `UPLOAD_LOCATION` path and DB container name.
|
||||
|
||||
- [ ] **Step 1: Record docker facts**
|
||||
|
||||
From the docker-compose dir on jupiter, note `UPLOAD_LOCATION`, the DB service/container name, and `POSTGRES_USER`/`POSTGRES_DB` from `.env`/compose. Confirm server version is **2.7.5** (web UI footer or `docker exec <server> immich --version`). If it is not 2.7.5, STOP — this plan assumes a same-version restore.
|
||||
|
||||
- [ ] **Step 2: Stop the docker stack (DB may stay up for the dump)**
|
||||
|
||||
Run: `docker compose stop immich-server immich-machine-learning` (leave the DB container running).
|
||||
|
||||
- [ ] **Step 3: Dump the database**
|
||||
|
||||
Run: `docker exec -t <db-container> pg_dumpall --clean --if-exists --username=<POSTGRES_USER> > ~/immich-db.sql`
|
||||
Expected: a non-trivial `immich-db.sql` (check it is not near-empty: `wc -l ~/immich-db.sql`).
|
||||
|
||||
- [ ] **Step 4: Stop the DB and record the media size**
|
||||
|
||||
Run: `docker compose down` then `du -sh <UPLOAD_LOCATION>` and note the size. Do NOT copy yet. Do NOT delete anything.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: First switch — let the module create empty state (operator-run)
|
||||
|
||||
**Files:** none at runtime (repo change already committed in Task 1). Run on jupiter after pulling the committed branch.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `immich-db.sql`, `UPLOAD_LOCATION` from Task 2.
|
||||
- Produces: an `immich` system user, an empty `immich` Postgres DB + role, and `/var/lib/immich` created with correct ownership, with services then stopped.
|
||||
|
||||
- [ ] **Step 1: Deploy the config**
|
||||
|
||||
On jupiter, check out the branch containing Task 1's commit and run:
|
||||
`sudo nixos-rebuild switch --flake '.#jupiter'`
|
||||
Expected: `immich-server`, `immich-machine-learning`, postgres, and redis units come up; UI reachable at `http://jupiter:2283` showing a fresh/empty instance.
|
||||
|
||||
- [ ] **Step 2: Stop immich so data can be swapped underneath**
|
||||
|
||||
Run: `sudo systemctl stop immich-server immich-machine-learning`
|
||||
Expected: both inactive. PostgreSQL and Redis stay running.
|
||||
|
||||
- [ ] **Step 3: Verify the DB and user exist**
|
||||
|
||||
Run: `sudo -u postgres psql -c '\l' | grep immich` and `sudo -u postgres psql -c '\du' | grep immich`
|
||||
Expected: an `immich` database and `immich` role are present.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Restore database and media (operator-run, destructive)
|
||||
|
||||
**Files:** none in repo. Run on jupiter. This overwrites the freshly-created empty DB.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `immich-db.sql`, `<UPLOAD_LOCATION>`, the running NixOS PostgreSQL.
|
||||
- Produces: the migrated DB and populated `/var/lib/immich`.
|
||||
|
||||
- [ ] **Step 1: Restore the dump into the NixOS Postgres**
|
||||
|
||||
`pg_dumpall` output includes role/DB creation. Load it as the `postgres` superuser over the unix socket:
|
||||
Run: `sudo -u postgres psql -f ~/immich-db.sql`
|
||||
Expected: completes without fatal errors. Harmless "role already exists"/"database already exists" notices are OK because of `--clean --if-exists`. If the immich DB ends up owned by the wrong role, reassign: `sudo -u postgres psql -c 'ALTER DATABASE immich OWNER TO immich;'`.
|
||||
|
||||
- [ ] **Step 2: Sanity-check the restored data**
|
||||
|
||||
Run: `sudo -u postgres psql -d immich -c 'SELECT count(*) FROM assets;'`
|
||||
Expected: a count matching your library size (non-zero). If the table name differs by version, list tables with `\dt` and check an obviously-populated one.
|
||||
|
||||
- [ ] **Step 3: Move the media into the default location**
|
||||
|
||||
Immich's upload folder holds subdirs `library/ upload/ thumbs/ encoded-video/ profile/ backups/`. Move (not copy, if same filesystem) the contents of `<UPLOAD_LOCATION>` into `/var/lib/immich`:
|
||||
Run: `sudo rsync -aHAX --info=progress2 <UPLOAD_LOCATION>/ /var/lib/immich/`
|
||||
(Use `rsync` — safe if partially interrupted. Keep the source until Task 6 sign-off.)
|
||||
|
||||
- [ ] **Step 4: Fix ownership**
|
||||
|
||||
Run: `sudo chown -R immich:immich /var/lib/immich`
|
||||
Expected: everything under `/var/lib/immich` owned by `immich`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Start and verify (operator-run)
|
||||
|
||||
**Files:** none. Run on jupiter.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: migrated DB + media from Task 4.
|
||||
- Produces: a running, verified native Immich.
|
||||
|
||||
- [ ] **Step 1: Start the server and watch logs**
|
||||
|
||||
Run: `sudo systemctl start immich-server && journalctl -u immich-server -f`
|
||||
Expected: it connects to the DB, runs same-version startup checks (no destructive migration since 2.7.5==2.7.5), and reports listening on 2283. Leave the follow running through the next step.
|
||||
|
||||
- [ ] **Step 2: Start machine-learning**
|
||||
|
||||
Run: `sudo systemctl start immich-machine-learning`
|
||||
Expected: active, no crash loop in `journalctl -u immich-machine-learning`.
|
||||
|
||||
- [ ] **Step 3: Functional spot-check in the web UI**
|
||||
|
||||
At `http://jupiter:2283`: log in with an existing account; confirm the timeline loads; open an **album**; open the **People/faces** view; open a **shared link**; open one photo so a **thumbnail and its full original both load** (this proves DB↔file paths align after the media move).
|
||||
Expected: all present, images render.
|
||||
|
||||
- [ ] **Step 4: Confirm homepage dashboard tile**
|
||||
|
||||
Open the homepage dashboard; confirm the Immich tile appears under "Media" and links to `http://jupiter:2283`.
|
||||
|
||||
- [ ] **Step 5: Enable and verify hardware transcoding**
|
||||
|
||||
In Immich **Administration → Settings → Video Transcoding**, set hardware acceleration to **Quick Sync** (QSV) (or VAAPI). Trigger a transcode (upload/play a video that needs transcoding, or run the transcoding job). Then:
|
||||
Run: `journalctl -u immich-server | grep -iE 'qsv|vaapi|hwaccel|transcode'`
|
||||
Expected: log shows the hardware path in use, not a CPU-fallback error. Confirm `/dev/dri/renderD128` is accessible to the service (the `video`/`render` groups + `accelerationDevices` from Task 1 handle this).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Sign-off and cleanup (operator-run)
|
||||
|
||||
**Files:** none in repo. Merge the branch; then, only after a confidence window, remove docker.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a verified running instance (Task 5).
|
||||
|
||||
- [ ] **Step 1: Merge the feature branch**
|
||||
|
||||
Open a PR from `feat/immich-nixos-module` into `main` and merge it (repo convention: PRs via the Gitea remote).
|
||||
|
||||
- [ ] **Step 2: Confidence window**
|
||||
|
||||
Use Immich normally for a few days. Keep the docker `<UPLOAD_LOCATION>` source copy and `~/immich-db.sql` untouched as the rollback path.
|
||||
|
||||
- [ ] **Step 3: Rollback (only if needed, before cleanup)**
|
||||
|
||||
If something is wrong: `sudo systemctl stop immich-server immich-machine-learning`, set `immich.enable = false` (or check out the pre-migration commit), `sudo nixos-rebuild switch --flake '.#jupiter'`, then `docker compose up -d` in the old stack. Original docker DB + upload folder are intact until Step 4.
|
||||
|
||||
- [ ] **Step 4: Cleanup (after sign-off)**
|
||||
|
||||
Remove the docker Immich stack (`docker compose down --rmi all --volumes` in the old dir if the DB volume is dedicated — verify first), delete the now-duplicated `<UPLOAD_LOCATION>` source, and remove `~/immich-db.sql`. Optionally disable the `docker` profile on jupiter if Immich was its only consumer (check other services first — jupiter's `docker.enable` may still be needed).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Native `services.immich` → Task 1. ✓
|
||||
- Version target 2.7.5==stable, no override → Global Constraints + Task 2 Step 1. ✓
|
||||
- Media at default `/var/lib/immich` → Task 1 + Task 4 Step 3. ✓
|
||||
- DB migrate keep-everything → Tasks 2–4. ✓
|
||||
- HW transcoding only → Task 1 (`accelerationDevices`, groups) + Task 5 Step 5. ✓
|
||||
- LAN+VPN, port 2283, openFirewall, homepage tile → Task 1 + Task 5 Steps 3–4. ✓
|
||||
- Rollback path → Task 6 Step 3. ✓
|
||||
- Deferred (OpenVINO/NAS/proxy) → correctly absent. ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO; every command is concrete. Placeholders like `<db-container>`, `<UPLOAD_LOCATION>`, `<POSTGRES_USER>` are runtime values the operator reads in Task 2 Step 1 — intentional, not gaps.
|
||||
|
||||
**Type consistency:** Option name `my.profiles.immich.enable` and path `/var/lib/immich` used consistently across all tasks. Media subfolder list matches between Task 4 Step 3 and the spec.
|
||||
@@ -1,384 +0,0 @@
|
||||
# Hermes Agent on mibook Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
> **Note on nature:** Task 1 is repo-only work verifiable with `nix build` — no runtime tests exist for declarative config, matching this repo's existing convention (see the Immich plan). Tasks 2–5 are an **operator-run runbook**, not agent-automatable: Task 2 needs an interactive `claude setup-token` OAuth login, an Anthropic billing console check, and a Telegram BotFather chat; Task 3 requires typing real secret material into the `$EDITOR` sops opens; Task 4 runs `nixos-rebuild` on mibook, the operator's live daily-driver desktop. **Never paste secret values (tokens, passwords) into an agent session or chat** — Task 3 has the operator type them directly into the editor sops opens. Present Tasks 2–5 for the operator to run and confirm; do not attempt to execute them from an agent session.
|
||||
|
||||
**Goal:** Run Hermes Agent as a hardened NixOS systemd service on mibook, reachable via Telegram and Email, authenticated to Anthropic via a Claude subscription (`claude setup-token`) rather than a pay-per-token API key.
|
||||
|
||||
**Architecture:** A standard `my.profiles.hermes-agent` module wraps the upstream `services.hermes-agent` module (from `NousResearch/hermes-agent`'s `nixosModules.default`), imported from inside the profile module itself so both machines can evaluate it — only mibook ever turns it on (see Task 1 Step 3). Credentials (Anthropic token, Telegram bot token, email creds, both `*_ALLOWED_USERS` access-control keys) live in one sops secret (`hermes-env`) consumed via `environmentFiles`. No network exposure — Telegram long-polling and Email IMAP/SMTP are both outbound-initiated from mibook.
|
||||
|
||||
**Tech Stack:** NixOS (flake-parts), sops-nix, upstream `hermes-agent` flake (Tier 2/best-effort Nix support), Anthropic Claude (subscription auth).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-08-hermes-agent-nixos-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Machine: **mibook** only. Do not enable on jupiter.
|
||||
- Module source: official `github:NousResearch/hermes-agent` (`nixosModules.default`). Not the community `0xrsydn/nix-hermes-agent` wrapper.
|
||||
- Auth: `claude setup-token` output goes into `ANTHROPIC_TOKEN` in the `hermes-env` secret — **not** `ANTHROPIC_API_KEY`. Confirmed supported by the locked revision's source (Task 4 Step 2); no longer conditional.
|
||||
- Billing dependency: the setup-token/OAuth path only draws from **Claude Max "extra usage credits"** — confirm this pool is funded before Task 2's token generation, or auth will fail even with a valid token.
|
||||
- Module wiring: the upstream `hermes-agent.nixosModules.default` import lives **inside** `modules/environments/hermes-agent/default.nix` (via the `inputs` module arg), never in `machines/configuration.nix`'s per-machine module lists — wiring it there breaks jupiter's evaluation even with the profile disabled (see Task 1 Step 3's correction).
|
||||
- Access control: `TELEGRAM_ALLOWED_USERS` and `EMAIL_ALLOWED_USERS` are required in the `hermes-env` secret, not optional — see Task 2 Steps 3–4.
|
||||
- Secret persistence: credentials end up in plaintext at `/var/lib/hermes/.hermes/.env` (mode 0640), not sops-nix's `/run/secrets` tmpfs — rollback (Task 5 Step 3) accounts for this.
|
||||
- Secret: one sops secret named `hermes-env`, a full env-file blob (matches `environmentFiles` — list of paths, not a single-value secret).
|
||||
- No network exposure: do not touch `modules/services/webserver` or open any inbound port. Telegram + Email are both outbound-only.
|
||||
- Platforms: Telegram + Email only, first pass. No Discord/Slack/etc.
|
||||
- Rebuild command: `sudo nixos-rebuild switch --flake '.#mibook'` (use `nixos-rebuild test` first, per Task 4).
|
||||
- Build-check command: `nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'`.
|
||||
- Format Nix with `nixfmt-rfc-style` before committing.
|
||||
- Secret material never enters an agent session, chat, or the git history in plaintext — only via `sops secrets/mibook.yaml` and `$EDITOR`.
|
||||
- Base branch: `feat/sops-nix` (already pushed; do not merge or rebase it as part of this work).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `flake.nix` — add the `hermes-agent` flake input.
|
||||
- **Not modified** `machines/configuration.nix` — Task 1 originally wired the upstream module import here (mibook-only); this broke jupiter's evaluation and was corrected to import from inside the profile module instead. See Task 1 Step 3.
|
||||
- **Create** `modules/environments/hermes-agent/default.nix` — the `my.profiles.hermes-agent` module (single responsibility: declare Hermes Agent).
|
||||
- **Modify** `modules/environments/default.nix` — add `./hermes-agent` to the import list.
|
||||
- **Modify** `machines/mibook/environments.nix` — set `hermes-agent.enable = true`.
|
||||
- **Modify** `machines/mibook/secrets.nix` — replace the `placeholder` secret with `hermes-env`.
|
||||
- **Modify (encrypted, operator-only)** `secrets/mibook.yaml` — real credential values, never touched by an agent.
|
||||
|
||||
No other files change.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Wire up the module (flake input, profile module, secret declaration)
|
||||
|
||||
**Files:**
|
||||
- Modify: `flake.nix`
|
||||
- Create: `modules/environments/hermes-agent/default.nix`
|
||||
- Modify: `modules/environments/default.nix`
|
||||
- Modify: `machines/mibook/environments.nix`
|
||||
- Modify: `machines/mibook/secrets.nix`
|
||||
- **Not modified:** `machines/configuration.nix` — see Step 3's correction.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: NixOS option `my.profiles.hermes-agent.enable` (bool). When true, configures `services.hermes-agent` with `environmentFiles = [ config.sops.secrets."hermes-env".path ]` and grants `finn` the `hermes` group. Declares `sops.secrets.hermes-env` (owner/group `hermes`, path resolves to `/run/secrets/hermes-env` at runtime — but note the *service* re-copies it to `/var/lib/hermes/.hermes/.env`, per the spec's persistence note).
|
||||
- Consumes: `hermes-agent.nixosModules.default` from the new flake input, imported directly inside the profile module via `self.inputs` (the `self` module arg, from `specialArgs` — see Step 3 and Step 5's note on why not the `inputs` module arg) — not through `machines/configuration.nix`; existing `sops.secrets.<name>` mechanism from `machines/core/sops.nix` (already applied to every machine, unaffected by this task).
|
||||
|
||||
- [ ] **Step 1: Add the flake input**
|
||||
|
||||
In `flake.nix`, inside `inputs = { ... }`, add after the `sops-nix` block:
|
||||
|
||||
```nix
|
||||
hermes-agent = {
|
||||
url = "github:NousResearch/hermes-agent";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lock the new input**
|
||||
|
||||
Run: `nix flake lock`
|
||||
Expected: `flake.lock` gains a `hermes-agent` entry (and its transitive inputs). No other entries change.
|
||||
|
||||
- [ ] **Step 3: Do NOT wire the module import through `machines/configuration.nix`**
|
||||
|
||||
**Correction (found during final whole-plan review, 2026-09-08):** an
|
||||
earlier version of this step added `hermes-agent` to the
|
||||
`inherit (self.inputs) ...` destructure and `hermes-agent.nixosModules.default`
|
||||
to mibook's `modules` list in `machines/configuration.nix`. That broke
|
||||
`nixosConfigurations.jupiter` evaluation — confirmed by direct `nix eval`.
|
||||
`modules/environments/default.nix` (Step 6) is machine-agnostic, so jupiter
|
||||
loads the profile module too; NixOS's module system errors on a definition
|
||||
for an undeclared option **regardless of `lib.mkIf`'s condition**. Since
|
||||
jupiter never imported `hermes-agent.nixosModules.default`, it never
|
||||
declared `services.hermes-agent` as an option at all, so even the disabled
|
||||
(`mkIf false`) config definition in Step 5 crashed evaluation.
|
||||
|
||||
Skip this step — do not touch `machines/configuration.nix` at all. The fix
|
||||
lives in Step 5: the upstream module is imported from inside the profile
|
||||
module itself, which works correctly on both machines (declares the option
|
||||
everywhere, only ever turns it on where `my.profiles.hermes-agent.enable = true`).
|
||||
|
||||
- [ ] **Step 4: Read a reference module to match repo style**
|
||||
|
||||
Read `modules/environments/immich/default.nix` (same shape: `cfg`, `lib.mkIf`, single-purpose options block). Match its formatting.
|
||||
|
||||
- [ ] **Step 5: Create the profile module**
|
||||
|
||||
Create `modules/environments/hermes-agent/default.nix`:
|
||||
|
||||
```nix
|
||||
# Hermes Agent — Telegram + Email messaging gateway (Claude subscription auth)
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.my.profiles.hermes-agent;
|
||||
in
|
||||
{
|
||||
imports = [ self.inputs.hermes-agent.nixosModules.default ];
|
||||
|
||||
options.my.profiles.hermes-agent.enable =
|
||||
lib.mkEnableOption "Hermes Agent (Telegram + Email gateway)";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
# Deliberate pin, matches the approved design — bump on purpose, not by drift.
|
||||
settings.model.default = "anthropic/claude-sonnet-4-5";
|
||||
environmentFiles = [ config.sops.secrets."hermes-env".path ];
|
||||
addToSystemPackages = true;
|
||||
};
|
||||
|
||||
# addToSystemPackages only puts `hermes` on $PATH; using it needs group
|
||||
# access to /var/lib/hermes/.hermes (mode 2770 hermes:hermes). finn is
|
||||
# this machine's one human user and already knows hermes-env's contents
|
||||
# (typed them into sops directly), so this is not new exposure.
|
||||
users.users.finn.extraGroups = [ "hermes" ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Use `self`, not `inputs`, in the `imports` line.** `inputs` is supplied
|
||||
via `_module.args.inputs = self.inputs;`, set inside a config block — but
|
||||
NixOS resolves a module's `imports` before `_module.args` assignments
|
||||
merge, so anything referenced inside `imports` must come from
|
||||
`specialArgs` (fixed up front) instead. `self` is threaded via
|
||||
`specialArgs = { inherit self; };` in `machines/configuration.nix` and is
|
||||
available at import-time; `inputs` is not. Using `inputs` here reproduces
|
||||
`infinite recursion encountered` — confirmed by testing it directly, not
|
||||
theoretical. `self.inputs.hermes-agent` is the same flake input either way.
|
||||
|
||||
- [ ] **Step 6: Register the module in the environments import list**
|
||||
|
||||
In `modules/environments/default.nix`, add `./hermes-agent` after `./immich`:
|
||||
|
||||
```nix
|
||||
./immich
|
||||
./hermes-agent
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Enable it on mibook**
|
||||
|
||||
In `machines/mibook/environments.nix`, inside the `my.profiles = { ... }` block, add:
|
||||
|
||||
```nix
|
||||
hermes-agent.enable = true;
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Replace the placeholder secret declaration**
|
||||
|
||||
In `machines/mibook/secrets.nix`, replace the whole file body:
|
||||
|
||||
```nix
|
||||
# Secrets decrypted on mibook. sopsFile defaults to secrets/mibook.yaml
|
||||
# via machines/core/sops.nix.
|
||||
{ ... }:
|
||||
{
|
||||
sops.secrets.hermes-env = {
|
||||
owner = "hermes";
|
||||
group = "hermes";
|
||||
mode = "0400";
|
||||
restartUnits = [ "hermes-agent.service" ]; # confirmed against the locked revision's generated unit
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This removes the `placeholder` secret declared here — it was an explicit throwaway (comment: "Safe to delete once a real secret is in place"). Its entry in the encrypted `secrets/mibook.yaml` content itself is cleaned up by the operator in Task 3, not here.
|
||||
|
||||
- [ ] **Step 9: Format**
|
||||
|
||||
Run: `nixfmt-rfc-style modules/environments/hermes-agent/default.nix machines/mibook/secrets.nix machines/mibook/environments.nix flake.nix`
|
||||
|
||||
- [ ] **Step 10: Build-check (this is the "test", with a known-expected failure until Task 3)**
|
||||
|
||||
Run: `nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'`
|
||||
**Correction (found during Task 1 implementation, 2026-09-08):** this does **not** build cleanly yet, and that's expected — `sops.validateSopsFiles = true` (set repo-wide in `machines/core/sops.nix`) makes secret-key existence a **build-time** dependency of `config.system.build.toplevel` (via a `manifest.json.drv` step), not an activation-time check as originally assumed here. Since `secrets/mibook.yaml` still only has the `placeholder` key at this point in the plan (Task 3 adds `hermes-env`), the build correctly fails with:
|
||||
```
|
||||
> .../sops-install-secrets: manifest is not valid: secret hermes-env in .../mibook.yaml is not valid: the key 'hermes-env' cannot be found
|
||||
```
|
||||
Do **not** treat this as a wiring defect, and do not weaken `sops.validateSopsFiles` to work around it — that's a repo-wide safety invariant, not this task's to change. Instead, confirm the wiring is correct by two other means: `nix build ... --dry-run` (should complete with no eval errors and the full derivation list) and Step 11 below. A fully green (non-dry-run) `nix build` of the toplevel is only reachable after Task 3 populates the real secret — treat that as the actual end-to-end checkpoint, revisited in Task 4 Step 1.
|
||||
If `--dry-run` itself fails on an unknown `services.hermes-agent.*` option, reconcile against the actual module: the error will name the bad option; cross-check against `website/docs/getting-started/nix-setup.md` in the `NousResearch/hermes-agent` repo at the locked revision.
|
||||
|
||||
- [ ] **Step 11: Build-check jupiter too — this is the regression gate**
|
||||
|
||||
Run: `nix eval '.#nixosConfigurations.jupiter.config.system.build.toplevel.drvPath'`
|
||||
Expected: succeeds, printing a `.drv` path. **Any change under `modules/` is a two-machine change even when it's "for one host"** — `modules/environments/default.nix` (Step 6) is loaded by every machine, so a mistake here can break jupiter's eval even though jupiter never enables this profile. This is exactly the failure Step 3's correction fixes; this step is what catches it if it ever regresses.
|
||||
|
||||
- [ ] **Step 12: Confirm the option evaluates on mibook**
|
||||
|
||||
Run: `nix eval '.#nixosConfigurations.mibook.config.services.hermes-agent.enable'`
|
||||
Expected: `true`
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add flake.nix flake.lock modules/environments/hermes-agent/default.nix modules/environments/default.nix machines/mibook/environments.nix machines/mibook/secrets.nix
|
||||
git commit -m "feat(mibook): add Hermes Agent profile module"
|
||||
```
|
||||
|
||||
**Post-review note, 2026-09-08:** Task 1 was originally committed as
|
||||
`731678e` with the jupiter-breaking bug Step 3 describes and fixes — the
|
||||
upstream module was wired through `machines/configuration.nix`'s
|
||||
mibook-only module list instead of imported inside the profile module. The
|
||||
final whole-plan review caught it (jupiter's `nixosConfigurations` stopped
|
||||
evaluating, breaking the repo's sole CI job even though jupiter never
|
||||
enables this profile). It was corrected in a follow-up commit on the same
|
||||
branch — run `git log --oneline -- modules/environments/hermes-agent/ machines/configuration.nix`
|
||||
if you need both commit hashes. The steps above already reflect the
|
||||
corrected, final approach — executing this plan from scratch would never
|
||||
hit the bug.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Gather credentials (operator-run, not agent-automatable)
|
||||
|
||||
**Files:** none. All values are held by the operator for Task 3 — do not write them into any file an agent can read, and do not paste them into a chat session.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: an Anthropic setup-token, a Telegram bot token, and email (IMAP/SMTP) credentials — ready to type into `$EDITOR` in Task 3.
|
||||
|
||||
- [ ] **Step 1: Confirm the billing pool is funded**
|
||||
|
||||
In the Anthropic console, confirm the Claude Max plan has **extra usage credits purchased**. The `claude setup-token` auth path bills against this pool only — it does not draw from the base Max allowance, and Claude Pro cannot use this path at all. If this isn't funded, stop here and decide whether to fund it or fall back to a plain `ANTHROPIC_API_KEY` (see Global Constraints).
|
||||
|
||||
- [ ] **Step 2: Generate the setup-token**
|
||||
|
||||
On mibook, run: `claude setup-token`
|
||||
This opens an interactive OAuth flow. On completion it prints a long-lived token. Copy it somewhere only you control (e.g. your password manager) — you'll type it into `sops` in Task 3, not into this session.
|
||||
|
||||
- [ ] **Step 3: Create the Telegram bot, and get your own user ID**
|
||||
|
||||
In Telegram, message `@BotFather`, run `/newbot`, follow the prompts. Copy the resulting bot token (format `123456:ABC-...`). Also get your own numeric Telegram user ID (e.g. via `@userinfobot`) — Task 3 needs it for `TELEGRAM_ALLOWED_USERS`. Telegram defaults to open DM pairing (any stranger who messages the bot gets a pairing code and can request approval); without this allowlist, that's the default posture, not a hardened one.
|
||||
|
||||
- [ ] **Step 4: Gather email credentials**
|
||||
|
||||
Decide which mailbox Hermes Agent should use, and get its IMAP/SMTP host, username, and password (or app-specific password if the provider requires one, e.g. Gmail). The exact env var names (confirmed against the locked `hermes-agent` revision, not guessed) are `EMAIL_ADDRESS`, `EMAIL_PASSWORD`, `EMAIL_IMAP_HOST`, `EMAIL_SMTP_HOST`, plus optional `EMAIL_IMAP_PORT` (default 993) / `EMAIL_SMTP_PORT` (default 587) if your provider differs — see `website/docs/user-guide/messaging/email.md` and `website/docs/reference/environment-variables.md` in the `NousResearch/hermes-agent` repo for more detail. Also decide `EMAIL_ALLOWED_USERS` (your own sending address) — without it, mail from unrecognized senders is **silently ignored** with no log line, which Task 4 Step 4 depends on knowing.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Populate the secret (operator-run, secret material)
|
||||
|
||||
**Files:**
|
||||
- Modify (encrypted): `secrets/mibook.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the three credentials gathered in Task 2, and the `hermes-env` declaration from Task 1.
|
||||
- Produces: a decryptable `hermes-env` entry that Task 1's `environmentFiles` reference resolves at activation.
|
||||
|
||||
- [ ] **Step 1: Open the encrypted file**
|
||||
|
||||
Run: `nix develop -c sops secrets/mibook.yaml`
|
||||
This decrypts and opens `$EDITOR` on the plaintext.
|
||||
|
||||
- [ ] **Step 2: Remove the placeholder key, add `hermes-env`**
|
||||
|
||||
Delete the `placeholder: ...` entry (Task 1 already removed its Nix declaration; this removes the matching content). Add:
|
||||
|
||||
```yaml
|
||||
hermes-env: |
|
||||
ANTHROPIC_TOKEN=<paste the claude setup-token output>
|
||||
TELEGRAM_BOT_TOKEN=<paste the BotFather token>
|
||||
TELEGRAM_ALLOWED_USERS=<your numeric Telegram user id, from Task 2 Step 3>
|
||||
EMAIL_ADDRESS=<mailbox address>
|
||||
EMAIL_PASSWORD=<password or app-password>
|
||||
EMAIL_IMAP_HOST=<imap host>
|
||||
EMAIL_SMTP_HOST=<smtp host>
|
||||
EMAIL_ALLOWED_USERS=<your own sending address, from Task 2 Step 4>
|
||||
```
|
||||
|
||||
(Exact YAML block-scalar syntax per whatever your `$EDITOR` shows — sops re-encrypts on save regardless of formatting, as long as it's valid YAML. Both `*_ALLOWED_USERS` keys are access control, not optional — see Task 2 Steps 3–4 for why.)
|
||||
|
||||
- [ ] **Step 3: Save and let sops re-encrypt**
|
||||
|
||||
Save and quit `$EDITOR`. Run: `git diff --stat secrets/mibook.yaml`
|
||||
Expected: the file shows as changed (still fully encrypted — confirm by `git diff secrets/mibook.yaml | head` and seeing sops's `ENC[...]` ciphertext, never plaintext).
|
||||
|
||||
- [ ] **Step 4: Stage it**
|
||||
|
||||
Run: `git add secrets/mibook.yaml`
|
||||
Required before the next `nix build` — untracked sops files are invisible to flake evaluation (repo rule, `docs/secrets.md`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(mibook): populate Hermes Agent credentials"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy and verify (operator-run, on mibook)
|
||||
|
||||
**Files:** none in repo. Run all commands on mibook.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the built config from Task 1, the secret from Task 3.
|
||||
- Produces: a running, verified `hermes-agent.service` reachable via Telegram and Email.
|
||||
|
||||
- [ ] **Step 1: Test-activate without persisting**
|
||||
|
||||
Run: `sudo nixos-rebuild test --flake '.#mibook'`
|
||||
Expected: activates without reboot-persisting. If it fails at secret decryption ("hermes-env not found in sops file"), Task 3 wasn't saved/staged correctly — redo Task 3.
|
||||
|
||||
- [ ] **Step 2: Watch the service start**
|
||||
|
||||
`hermes-agent.service` is the confirmed unit name (verified against the locked revision's generated unit, not a guess). Run: `journalctl -u hermes-agent.service -f`
|
||||
Expected: starts, reads the env file (lands at `/var/lib/hermes/.hermes/.env` — see spec Part 2), and does **not** crash-loop on auth. `ANTHROPIC_TOKEN` is confirmed supported by the module's credential resolver, so an auth failure here points at the token itself (expired, or the extra-credits pool from Task 2 Step 1 isn't actually funded) rather than the module rejecting the variable name.
|
||||
|
||||
- [ ] **Step 3: Verify Telegram connects**
|
||||
|
||||
Send your bot a message from Telegram. Expected: it responds (confirms both the bot token and the LLM auth work end-to-end).
|
||||
|
||||
- [ ] **Step 4: Verify Email connects**
|
||||
|
||||
Send a test email to the configured mailbox. Expected: Hermes Agent picks it up (check `journalctl -u hermes-agent.service` for an inbound-message log line) and can send a reply.
|
||||
|
||||
- [ ] **Step 5: Persist across reboot**
|
||||
|
||||
Run: `sudo nixos-rebuild switch --flake '.#mibook'`
|
||||
Expected: same running state as Step 1–4, now persisted.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Sign-off (operator-run)
|
||||
|
||||
**Files:** none in repo beyond what Tasks 1 and 3 already committed.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a verified running instance (Task 4).
|
||||
|
||||
- [ ] **Step 1: Open a PR**
|
||||
|
||||
`feat/sops-nix` is already pushed to `origin` but not merged; this work sits on top of it. Push this branch (or continue on `feat/sops-nix` directly, per your preference) and open a PR per the repo's usual Gitea workflow. Note in the PR description that it depends on `feat/sops-nix` landing first (or is stacked on it).
|
||||
|
||||
- [ ] **Step 2: Confidence window**
|
||||
|
||||
Use it for a few days — send yourself Telegram/Email messages periodically, including after mibook has slept and woken up, to see how the accepted availability gap actually behaves in practice.
|
||||
|
||||
- [ ] **Step 3: Rollback (if needed)**
|
||||
|
||||
`my.profiles.hermes-agent.enable = false` in `machines/mibook/environments.nix`, `sudo nixos-rebuild switch --flake '.#mibook'` stops the service — but the plaintext credential file survives on disk (see spec's Rollback section for why). Also run `sudo rm -rf /var/lib/hermes` and revoke the Telegram bot token via `@BotFather` → `/revoke`. No data migration is involved, so this is still cheap, just not a single command.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Host mibook, not jupiter → Global Constraints + File Structure. ✓
|
||||
- NixOS native module, official upstream source → Task 1. ✓
|
||||
- Home Manager rejected → not present anywhere (correctly absent, matches "deliberately deferred" reasoning — no task references it). ✓
|
||||
- `claude setup-token` → `ANTHROPIC_TOKEN`, Max extra-credits caveat → Task 2 Steps 1–2, Task 4 Step 2, Global Constraints. ✓
|
||||
- `hermes-env` single env-file secret → Task 1 Step 8, Task 3. ✓
|
||||
- No sops prerequisite (mibook already registered) → confirmed by reusing existing `machines/mibook/secrets.nix` rather than any `.sops.yaml`/Phase-9-style bootstrapping. ✓
|
||||
- No network exposure → Global Constraints; no task touches `modules/services/webserver`. ✓
|
||||
- Telegram + Email only → Task 2 Steps 3–4, Task 4 Steps 3–4. ✓
|
||||
- Rollback path → Task 5 Step 3. ✓
|
||||
- Deferred (other platforms, availability-gap workarounds, public exposure) → correctly absent. ✓
|
||||
|
||||
**Placeholder scan:** No bare TBD/TODO. The bracketed values (`<paste the claude setup-token output>`, etc.) are operator-supplied secret material by design — Task 2 explicitly produces them, Task 3 explicitly consumes them; they're never meant to be concrete in this document since they're credentials, not code.
|
||||
|
||||
**Type consistency:** `my.profiles.hermes-agent.enable`, `services.hermes-agent.*`, and the `hermes-env` secret name are used identically across Tasks 1, 3, and 4. The `hermes-agent.service` unit name and `ANTHROPIC_TOKEN` var are confirmed (not guessed) consistently in both Task 1's declaration and Task 4's verification.
|
||||
|
||||
**Post-final-review addendum (2026-09-08):** the final whole-plan review found the module-wiring bug described in Task 1 Step 3, plus three access/persistence gaps now folded into Global Constraints, Task 2 Steps 3–4, Task 3's template, and Task 5 Step 3's rollback (TELEGRAM_ALLOWED_USERS/EMAIL_ALLOWED_USERS required; secrets persist to `/var/lib/hermes/.hermes/.env`, not `/run/secrets`; `hermes` CLI needs `finn` in the `hermes` group to be usable). All four are corrected in place above, not left as separate addenda, so the plan reads as a single coherent document rather than a stack of patches.
|
||||
@@ -0,0 +1,105 @@
|
||||
# mibook: choose terminal-only vs desktop at boot
|
||||
|
||||
## Goal
|
||||
|
||||
Let mibook offer a choice, each time it boots, between the normal KDE desktop
|
||||
and a terminal-only ("server") mode with no graphical session — decided at
|
||||
boot, without rebuilding the system.
|
||||
|
||||
## Background: why the first attempt failed
|
||||
|
||||
The first implementation booted mibook to a text console by default
|
||||
(`systemd.defaultUnit = "multi-user.target"`, display manager not started) and
|
||||
provided a `desktop` command to start KDE on demand. On real hardware this
|
||||
locked the machine out:
|
||||
|
||||
- **No SSH.** mibook is WiFi-only and its WiFi credentials are stored per-user
|
||||
in KWallet ("agent-owned"). NetworkManager only receives the password once a
|
||||
desktop session is running, so a headless boot never joins the network and
|
||||
the machine has no IP — nothing to SSH into.
|
||||
- **No usable console.** The boot appeared to "hang" with no login prompt:
|
||||
`NetworkManager-wait-online` stalled waiting for a network that never came
|
||||
up, and the `getty` login prompt on tty1 was buried under later service
|
||||
messages.
|
||||
|
||||
Conclusion: a headless WiFi laptop cannot be reached remotely, and the plain
|
||||
console was hard to use. The design must (a) keep the desktop as the reliable
|
||||
default, (b) make the terminal path a deliberate, self-sufficient choice, and
|
||||
(c) not depend on the network being up.
|
||||
|
||||
## Behavior
|
||||
|
||||
- The **default GRUB entry** boots straight into KDE — unchanged from the
|
||||
known-working baseline.
|
||||
- A **separate GRUB entry, `mibook (terminal)`** (a NixOS *specialisation*),
|
||||
boots to a text console with **autologin** for `finn`. From there the user
|
||||
can work in the shell or run `desktop` to bring KDE up (via SDDM).
|
||||
- The choice is made in the GRUB menu at boot — matching the original request
|
||||
to "decide each time I boot."
|
||||
- Booting never stalls on the network.
|
||||
|
||||
### Known limitation (documented, not fixed in config)
|
||||
|
||||
In terminal mode WiFi will not connect on its own, because the password is
|
||||
stored per-user in KWallet. To reach mibook over SSH from terminal mode, the
|
||||
user must first save the WiFi as a **system** connection in KDE:
|
||||
network settings → the WiFi network → "All users may connect to this network".
|
||||
Until then, terminal mode is local-console-only. This is a one-time manual
|
||||
step outside the scope of the Nix config.
|
||||
|
||||
## Implementation
|
||||
|
||||
### `machines/mibook/configuration.nix`
|
||||
|
||||
- Add a NixOS specialisation `specialisation.terminal.configuration`:
|
||||
- `system.nixos.tags = [ "terminal" ];` — labels the generated boot entry.
|
||||
- `systemd.defaultUnit = lib.mkForce "multi-user.target";` — boots to the
|
||||
text console. `graphical.target` is what pulls in the display manager (via
|
||||
its embedded `Wants=display-manager.service`), so defaulting to
|
||||
`multi-user.target` leaves SDDM installed but not started at boot.
|
||||
- `services.getty.autologinUser = "finn";` — guarantees a usable shell on
|
||||
the console instead of a login prompt that can scroll off screen.
|
||||
- A `desktop` command via
|
||||
`pkgs.writeShellScriptBin "desktop" "exec sudo systemctl start display-manager.service"`
|
||||
in `environment.systemPackages`, to start KDE on demand.
|
||||
- Add `systemd.services.NetworkManager-wait-online.enable = false;` (applies to
|
||||
both the default and terminal boots) so boot never stalls waiting for the
|
||||
network.
|
||||
|
||||
### Reverted from the first attempt
|
||||
|
||||
- `modules/environments/kde-desktop/default.nix` — remove the `startOnBoot`
|
||||
option and its `mkMerge`/`mkIf` machinery; back to the original profile that
|
||||
simply enables SDDM + Plasma 6.
|
||||
- `machines/mibook/environments.nix` — remove `kde-desktop.startOnBoot = false;`
|
||||
(back to just `kde-desktop.enable = true;`).
|
||||
|
||||
## Why a specialisation
|
||||
|
||||
A specialisation generates a second boot-menu entry automatically from a
|
||||
modified copy of the configuration. It is the idiomatic NixOS mechanism for a
|
||||
boot-time choice and avoids fragile hand-written GRUB `extraEntries` that would
|
||||
need to track kernel/initrd paths across generations. The default entry remains
|
||||
byte-for-byte the working desktop configuration.
|
||||
|
||||
## Testing / verification
|
||||
|
||||
- `nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'`
|
||||
builds both `nixos-system-mibook` and `nixos-system-mibook-terminal`.
|
||||
- Verified on the built closures:
|
||||
- Parent `default.target` → `graphical.target`; no console autologin
|
||||
(identical to the pre-change baseline).
|
||||
- Specialisation `default.target` → `multi-user.target`; tty1 getty wrapper
|
||||
contains `--autologin finn`; `desktop` present in the system profile.
|
||||
- `NetworkManager-wait-online` disabled in both.
|
||||
- Post-`switch` manual check on mibook: default GRUB entry boots to KDE; the
|
||||
`terminal` entry boots to an autologged-in console; running `desktop` there
|
||||
starts SDDM and a working Plasma session.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- Autologin on the terminal console means physical access grants a shell
|
||||
without a password. Acceptable for a personal laptop the user controls; the
|
||||
desktop (default) boot is unaffected.
|
||||
- `desktop` relies on `sudo`; the user has sudo access, so no extra config is
|
||||
required.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Immich: Docker → NixOS module migration
|
||||
|
||||
**Date:** 2026-08-05
|
||||
**Machine:** jupiter (home server, Intel iGPU)
|
||||
**Status:** Design approved, pending implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the existing docker-compose Immich deployment on jupiter with the
|
||||
native `services.immich` NixOS module, wrapped in the repo's standard
|
||||
`my.profiles.*` pattern. Preserve all existing data (albums, faces, shared
|
||||
links, metadata) and photo/video library.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Approach | Native `services.immich` (nixpkgs), not `oci-containers` |
|
||||
| Version target | **Resolved: docker runs 2.7.5 == stable nixpkgs 2.7.5.** Use the stable module as-is; no `package` override. Same-version restore, no forward schema migration |
|
||||
| Media location | Default local path `/var/lib/immich`. NAS deferred to a future read-only external library |
|
||||
| Database | Migrate via dump/restore — keep everything |
|
||||
| HW acceleration | Video transcoding only (VAAPI/QSV via existing Intel graphics stack). ML on CPU |
|
||||
| Access | LAN + VPN only: open port 2283, register on homepage dashboard. No reverse proxy/TLS |
|
||||
|
||||
### Deliberately deferred (YAGNI)
|
||||
- OpenVINO ML acceleration
|
||||
- NAS-backed external library
|
||||
- Reverse proxy / TLS / public hostname
|
||||
|
||||
## Part 1 — The module
|
||||
|
||||
New file `modules/environments/immich/default.nix` following the profile
|
||||
pattern; add `./environments/immich` to `modules/environments/default.nix`;
|
||||
enable `my.profiles.immich.enable = true` in
|
||||
`machines/jupiter/environments.nix`.
|
||||
|
||||
```nix
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.my.profiles.immich;
|
||||
hostName = config.networking.hostName;
|
||||
port = 2283;
|
||||
in {
|
||||
options.my.profiles.immich.enable = lib.mkEnableOption "Immich photo server";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.immich = {
|
||||
enable = true;
|
||||
# package = pkgs.unstable.immich; # only if docker :release is > 2.7.5
|
||||
host = "0.0.0.0";
|
||||
inherit port;
|
||||
openFirewall = true;
|
||||
mediaLocation = "/var/lib/immich";
|
||||
machine-learning.enable = true;
|
||||
accelerationDevices = [ "/dev/dri/renderD128" ];
|
||||
settings.server.externalDomain = "http://${hostName}:${toString port}";
|
||||
};
|
||||
|
||||
# native module does not add GPU groups; needed for VAAPI/QSV transcoding
|
||||
users.users.immich.extraGroups = [ "video" "render" ];
|
||||
|
||||
my.homepage.services = [{
|
||||
group = "Media";
|
||||
name = "Immich";
|
||||
description = "Photo & video server";
|
||||
href = "http://${hostName}:${toString port}";
|
||||
icon = "immich.png";
|
||||
}];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Provided for free by the native module:** local PostgreSQL with the required
|
||||
vector extension over a **unix socket + peer auth** (so no DB password / sops
|
||||
secret needed), Redis, `immich-server` and `immich-machine-learning` systemd
|
||||
units, the `immich` system user, and `mediaLocation` created via tmpfiles.
|
||||
|
||||
**Transcoding is two parts:** (a) NixOS exposes the GPU device + `video`/`render`
|
||||
groups (above); (b) the hwaccel backend (QSV/VAAPI) is chosen in Immich's
|
||||
**admin → video transcoding** settings after cutover — a UI toggle, not Nix.
|
||||
|
||||
## Part 2 — Migration runbook (on jupiter)
|
||||
|
||||
### Pre-flight (hard blocker)
|
||||
1. Get running docker Immich version (`docker exec <server> immich --version` or web UI footer).
|
||||
2. **Resolved 2026-08-05: running version is 2.7.5, equal to stable nixpkgs.**
|
||||
Use the stable module as-is (no `package` override). Kept for reference:
|
||||
- running ≤ 2.7.5 → stable module as-is ← **this case**
|
||||
- 2.7.6–3.0.3 → set `package = pkgs.unstable.immich`
|
||||
- `> 3.0.3` → bump nixpkgs first; **stop and re-plan**
|
||||
3. Record docker `UPLOAD_LOCATION` and DB container name/credentials.
|
||||
|
||||
### Backup (before touching anything)
|
||||
4. `docker compose down` (DB may stay up for the dump).
|
||||
5. Dump DB: `docker exec -t <db> pg_dumpall --clean --if-exists --username=postgres > immich-db.sql`
|
||||
6. Verify upload folder intact; note size (no copy yet).
|
||||
|
||||
### Cutover
|
||||
7. Add the module to jupiter's `environments.nix` (leave `database.createDB` default).
|
||||
8. `sudo nixos-rebuild switch --flake '.#jupiter'` → creates user, empty DB + role, `mediaLocation`. Then `systemctl stop immich-server immich-machine-learning`.
|
||||
9. Restore the DB into the NixOS Postgres (drop the freshly-created empty `immich` DB, load `immich-db.sql`) per Immich's restore docs.
|
||||
10. Move media into `/var/lib/immich` (subfolders `library/`, `upload/`, `thumbs/`, `encoded-video/`, `profile/`); `chown -R immich:immich /var/lib/immich`.
|
||||
11. `systemctl start immich-server`; it runs schema migrations forward. Watch `journalctl -u immich-server -f`.
|
||||
|
||||
### Verify
|
||||
12. UI at `http://jupiter:2283` loads; log in; spot-check albums, faces, a shared link, and that thumbnails/originals actually load.
|
||||
13. Homepage tile works.
|
||||
14. Enable QSV/VAAPI in admin settings; transcode one video; confirm `journalctl` shows the hw path, not a CPU fallback error.
|
||||
|
||||
### Rollback
|
||||
Before deleting any docker data: `systemctl stop immich-*`, disable the profile,
|
||||
`nixos-rebuild switch`, `docker compose up -d`. Original docker DB + upload
|
||||
folder remain untouched until explicitly removed after a few days of confidence.
|
||||
|
||||
## Known risk — RESOLVED
|
||||
|
||||
The main risk was step 9 crossing the **pgvecto.rs → VectorChord** vector-extension
|
||||
boundary. With source and target both at **2.7.5**, both use VectorChord — no
|
||||
boundary crossing and no forward schema migration. The restore is a same-version
|
||||
dump/load. Residual risk is limited to routine dump/restore mechanics
|
||||
(roles, extension availability in the NixOS Postgres, ownership on restore).
|
||||
@@ -1,205 +0,0 @@
|
||||
# Hermes Agent on mibook
|
||||
|
||||
**Date:** 2026-09-08
|
||||
**Machine:** mibook (laptop, KDE desktop, current daily driver)
|
||||
**Status:** Design approved, pending implementation plan
|
||||
**Base branch:** `feat/sops-nix` (pushed to `origin`, not yet merged to `main`)
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent)
|
||||
as a NixOS-native systemd service on mibook, reachable via Telegram and Email,
|
||||
using an Anthropic Claude subscription (`claude setup-token`) rather than a
|
||||
pay-per-token API key. Design + implementation plan only — no execution in
|
||||
this round; a later session applies the plan.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Host | mibook, not jupiter. Availability gap (laptop sleeps/lid-closes) is accepted, not engineered around |
|
||||
| Use case | Messaging gateway (Telegram + Email), not CLI-only |
|
||||
| Deployment mode | NixOS native module (`services.hermes-agent.*` from upstream `nixosModules.default`), wrapped in `my.profiles.hermes-agent`. Home Manager was considered and rejected — not wired into this flake anywhere; adding it for one service is a new framework dependency, not proportionate |
|
||||
| Module source | Official `github:NousResearch/hermes-agent`, not the community `0xrsydn/nix-hermes-agent` wrapper |
|
||||
| Nix support tier | Docs explicitly call Nix/NixOS "Tier 2, best-effort" — Docker/FHS is upstream's primary supported path. Accepted tradeoff, not a blocker |
|
||||
| LLM provider auth | Anthropic via `claude setup-token` (long-lived, headless-friendly token) → `ANTHROPIC_TOKEN`, not `ANTHROPIC_API_KEY` |
|
||||
| Billing caveat | Setup-token/OAuth path only draws from a **Claude Max "extra usage credits"** pool, never the base Max allowance. Claude Pro cannot use this path at all. User intends to use `claude setup-token`; not independently re-verified that extra credits are purchased — confirm in Anthropic console before/during implementation |
|
||||
| Secrets | One sops secret, `hermes-env`, containing a full env-file blob (matches the module's `environmentFiles` list-of-paths interface — not a single-value secret) |
|
||||
| sops prerequisite | **None.** mibook is already a registered sops recipient (`host_mibook` in `.sops.yaml`, `secrets/mibook.yaml` exists, proven via a live `nixos-rebuild switch` in the sops-nix work). No "Phase 9"-style bootstrapping needed — that deferred work is about *jupiter*, irrelevant now that mibook is the target |
|
||||
| Network exposure | None. Telegram (long-polling) and Email (IMAP/SMTP) are both outbound-initiated from mibook. No Caddy/webserver module changes, no public endpoint |
|
||||
| Messaging platforms | Telegram + Email only, first pass. Hermes Agent supports 27+ platforms total (Discord, Slack, WhatsApp, Matrix, etc.) — others deliberately out of scope, same pattern applies later if wanted |
|
||||
|
||||
### Deliberately deferred (YAGNI)
|
||||
- Any workaround for mibook's availability gap (cloud relay, wake scheduling, HA failover to jupiter)
|
||||
- Discord, Slack, or any other messaging platform beyond Telegram + Email
|
||||
- Public/internet exposure of any kind
|
||||
- Merging or rebasing `feat/sops-nix` — this design builds on top of it as-is
|
||||
|
||||
## Part 1 — The module
|
||||
|
||||
New flake input in `flake.nix`, following the exact shape `sops-nix` already
|
||||
uses:
|
||||
|
||||
```nix
|
||||
hermes-agent = {
|
||||
url = "github:NousResearch/hermes-agent";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
```
|
||||
|
||||
Import the upstream module **from inside the profile module itself**, via
|
||||
`imports = [ self.inputs.hermes-agent.nixosModules.default ];` — using
|
||||
`self` (available via `specialArgs = { inherit self; };` in
|
||||
`machines/configuration.nix`), not the `inputs` module arg. **Correction
|
||||
(found during implementation, 2026-09-08):** `inputs` is supplied via
|
||||
`_module.args.inputs = self.inputs;`, set inside a config block — but
|
||||
NixOS resolves a module's `imports` list before `_module.args` assignments
|
||||
merge, so anything referenced inside `imports` must come from
|
||||
`specialArgs` (fixed up front) instead. Using `inputs` here reproduces
|
||||
`infinite recursion encountered`; `self.inputs.hermes-agent` is the same
|
||||
flake input, reached the way that actually works. Separately, this is
|
||||
deliberately *not* wired through `machines/configuration.nix`'s per-machine
|
||||
module lists. NixOS's module system errors on a definition for an
|
||||
undeclared option regardless of `lib.mkIf`'s condition — so declaring
|
||||
`services.hermes-agent` only on mibook while jupiter still loads the
|
||||
profile module (because `modules/environments/default.nix` is
|
||||
machine-agnostic) breaks jupiter's evaluation outright, even with the
|
||||
profile disabled there. Importing the upstream module unconditionally, and
|
||||
gating only `config` behind `lib.mkIf cfg.enable`, is what actually
|
||||
respects "mibook only, not jupiter": the option exists everywhere
|
||||
(harmless), but only ever turns on where `enable = true`.
|
||||
|
||||
New file `modules/environments/hermes-agent/default.nix` following the
|
||||
repo's standard profile pattern:
|
||||
|
||||
```nix
|
||||
# Hermes Agent — Telegram + Email messaging gateway (Claude subscription auth)
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.my.profiles.hermes-agent;
|
||||
in
|
||||
{
|
||||
imports = [ self.inputs.hermes-agent.nixosModules.default ];
|
||||
|
||||
options.my.profiles.hermes-agent.enable =
|
||||
lib.mkEnableOption "Hermes Agent (Telegram + Email gateway)";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
settings.model.default = "anthropic/claude-sonnet-4-5"; # deliberate pin, see Open risks
|
||||
environmentFiles = [ config.sops.secrets."hermes-env".path ];
|
||||
addToSystemPackages = true;
|
||||
};
|
||||
|
||||
# addToSystemPackages only puts `hermes` on $PATH; using it needs group
|
||||
# access to /var/lib/hermes/.hermes (mode 2770 hermes:hermes). finn is
|
||||
# this machine's one human user and already knows hermes-env's contents
|
||||
# (typed them into sops directly), so this is not new exposure.
|
||||
users.users.finn.extraGroups = [ "hermes" ];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Add `./environments/hermes-agent` to `modules/environments/default.nix`;
|
||||
enable `my.profiles.hermes-agent.enable = true` in
|
||||
`machines/mibook/environments.nix`. `machines/configuration.nix` is **not**
|
||||
touched by this design.
|
||||
|
||||
**Provided for free by the upstream module:** `hermes` system user/group
|
||||
(`createUser` default true), hardened systemd unit (`NoNewPrivileges`,
|
||||
`ProtectSystem=strict`, `PrivateTmp` — not independently user-configurable
|
||||
per the docs), `hermes` CLI on `$PATH` via `addToSystemPackages` (usable by
|
||||
finn once group membership is granted, above).
|
||||
|
||||
## Part 2 — Secret
|
||||
|
||||
Add to `machines/mibook/secrets.nix` (existing file, per `docs/secrets.md`):
|
||||
|
||||
```nix
|
||||
sops.secrets.hermes-env = {
|
||||
owner = "hermes";
|
||||
group = "hermes";
|
||||
mode = "0400";
|
||||
restartUnits = [ "hermes-agent.service" ]; # confirm exact unit name at implementation
|
||||
};
|
||||
```
|
||||
|
||||
Populate via `nix develop -c sops secrets/mibook.yaml`, adding a
|
||||
multi-line `hermes-env` block:
|
||||
|
||||
```
|
||||
ANTHROPIC_TOKEN=<output of `claude setup-token`>
|
||||
TELEGRAM_BOT_TOKEN=<from @BotFather>
|
||||
TELEGRAM_ALLOWED_USERS=<your numeric Telegram user id>
|
||||
EMAIL_ADDRESS=<mailbox address>
|
||||
EMAIL_PASSWORD=<password or app-password>
|
||||
EMAIL_IMAP_HOST=<imap host>
|
||||
EMAIL_SMTP_HOST=<smtp host>
|
||||
EMAIL_ALLOWED_USERS=<your own sending address>
|
||||
```
|
||||
(`EMAIL_IMAP_PORT`/`EMAIL_SMTP_PORT` default to 993/587 — only set if the
|
||||
provider differs. See `website/docs/reference/environment-variables.md` for
|
||||
the full key list.)
|
||||
|
||||
Both `*_ALLOWED_USERS` keys are access control, not optional: Telegram
|
||||
defaults to open DM pairing (any stranger who messages the bot gets a
|
||||
pairing code and can request approval), and email with no
|
||||
`EMAIL_ALLOWED_USERS` set **silently ignores** messages from unrecognized
|
||||
senders — no log line, no error. For an agent with terminal access on the
|
||||
daily driver, treat both as required.
|
||||
|
||||
**Resolved (confirmed against the locked `hermes-agent` revision's source —
|
||||
`agent/anthropic_credentials.py`, `hermes_cli/providers.py`):** the module
|
||||
reads `ANTHROPIC_TOKEN` for the setup-token path. No longer an open
|
||||
question; see Open risks below for what replaced it.
|
||||
|
||||
**Where this actually lands at runtime:** the upstream module does not use
|
||||
systemd `EnvironmentFile=`. An activation script concatenates every
|
||||
`environmentFiles` entry into **`/var/lib/hermes/.hermes/.env`**, mode
|
||||
`0640`, owned `hermes:hermes` — plaintext on mibook's plain ext4 root (no
|
||||
LUKS, per `machines/mibook/disks.nix`), not sops-nix's `/run/secrets`
|
||||
tmpfs. This is upstream behavior, not something this design controls; see
|
||||
Rollback below for why it matters.
|
||||
|
||||
**Must `git add secrets/mibook.yaml` before `nix build`** — untracked
|
||||
files are invisible to flake evaluation (existing repo rule, `docs/secrets.md`).
|
||||
|
||||
## Part 3 — Rollout runbook (on mibook)
|
||||
|
||||
1. Confirm Claude Max extra-usage-credits are actually purchased (Anthropic
|
||||
console) — the whole auth design depends on this pool being funded.
|
||||
2. Run `claude setup-token` on mibook; capture the token.
|
||||
3. Create/gather Telegram bot token (`@BotFather`) and email credentials.
|
||||
4. Add the `hermes-agent` flake input; add the module wrapper file; wire it
|
||||
into `modules/environments/default.nix` and `machines/mibook/environments.nix`.
|
||||
5. Add `sops.secrets.hermes-env` to `machines/mibook/secrets.nix`.
|
||||
6. `nix develop -c sops secrets/mibook.yaml`, populate `hermes-env`, `git add`.
|
||||
7. `nix build '.#nixosConfigurations.mibook.config.system.build.toplevel'` — CI-style check before touching the running system.
|
||||
8. `sudo nixos-rebuild test --flake '.#mibook'` — verify before persisting across reboot.
|
||||
9. `journalctl -u hermes-agent.service -f` — confirm it starts, decrypts the secret, connects to Telegram (and doesn't loop-crash on the `ANTHROPIC_TOKEN` question from Part 2).
|
||||
10. Send a test Telegram message; send a test email; confirm both round-trip.
|
||||
11. `sudo nixos-rebuild switch --flake '.#mibook'` once verified.
|
||||
|
||||
### Rollback
|
||||
`my.profiles.hermes-agent.enable = false`, `nixos-rebuild switch` stops the
|
||||
service, but does **not** clean up `/var/lib/hermes` — the plaintext
|
||||
`.env` (Anthropic token, Telegram token, email password) stays on disk
|
||||
indefinitely, per the persistence note in Part 2. A real rollback is:
|
||||
`sudo rm -rf /var/lib/hermes`, plus revoking the Telegram bot token
|
||||
(`@BotFather` → `/revoke`) since it can't be un-leaked once exposed. No
|
||||
data migration is involved, so this is still cheap — just not one command.
|
||||
|
||||
## Open risks (carried forward, not resolved by design)
|
||||
|
||||
1. ~~`ANTHROPIC_TOKEN` vs `ANTHROPIC_API_KEY`~~ — **Resolved**: confirmed supported by reading the locked `hermes-agent` revision's source (Part 2).
|
||||
2. **Extra-credits billing pool funding** — not independently verified, user-asserted intent (Part 3, step 1).
|
||||
3. **Tier 2 Nix support** — expect some manual troubleshooting; upstream doesn't guarantee the flake stays working across their commits.
|
||||
4. **Availability gap** — accepted by design, not a bug to fix later unless priorities change.
|
||||
5. **`ANTHROPIC_TOKEN` cannot refresh unattended.** The setup-token is a terminal value to the module's credential resolver; the refresh path needs a `~/.claude/.credentials.json` with a refresh token that the `hermes` service user won't have. When the token expires, the gateway silently stops authenticating and the operator has to re-run `claude setup-token` and re-populate the secret by hand — watch for this during the confidence window, not something this design solves.
|
||||
Generated
+34
-220
@@ -20,27 +20,6 @@
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790898470,
|
||||
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
@@ -55,7 +34,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_3": {
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"nix",
|
||||
@@ -63,11 +42,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790898470,
|
||||
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -87,11 +66,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790500375,
|
||||
"narHash": "sha256-XN3sDtn8TU9hAc9xqZ+SqRB/HHm2wjxM6aSWYLJU+oo=",
|
||||
"lastModified": 1783008725,
|
||||
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "a0e4241b51206fbcbf52fd322eb5f0cd80f153c4",
|
||||
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -100,68 +79,21 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hermes-agent": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts_2",
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"npm-lockfile-fix": "npm-lockfile-fix",
|
||||
"pyproject-build-systems": "pyproject-build-systems",
|
||||
"pyproject-nix": "pyproject-nix",
|
||||
"uv2nix": "uv2nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792650380,
|
||||
"narHash": "sha256-4l8C4z7K4VQDq2ExP6v9NUdCpSFagB1hbt4gh/Y6YSw=",
|
||||
"owner": "NousResearch",
|
||||
"repo": "hermes-agent",
|
||||
"rev": "81a55437f3f31a9f12a30c53b6c5d49d420898aa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NousResearch",
|
||||
"repo": "hermes-agent",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"hermes-agent",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786487128,
|
||||
"narHash": "sha256-ad60hrRVhH/bo3Jl1YLzO+QcS3mUNZr9LNJdzhMO2P4=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "f404edbfa4117810c96b97048299242fc50e5362",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts_3",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"git-hooks-nix": "git-hooks-nix",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-23-11": "nixpkgs-23-11",
|
||||
"nixpkgs-regression": "nixpkgs-regression"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792618091,
|
||||
"narHash": "sha256-sDmJ6tgPjlSk9P2jCXXjV2l0Ewe7K9CwclMcAvpEyR0=",
|
||||
"lastModified": 1784762557,
|
||||
"narHash": "sha256-R/r6jRnANV50c8F5Fz5+1Q1moab0IGWRk+cg5ME2nMY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nix",
|
||||
"rev": "42d96171bbef8a8073106dd93ca68878d7339abd",
|
||||
"rev": "d10c84cd0cc0efdcb29cf2611caf5fbcd10fa071",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -211,11 +143,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792491056,
|
||||
"narHash": "sha256-6n1Dn+WYtD8lprnSi+0k4FjbKfIVNG8lblpJ5MiiqXs=",
|
||||
"lastModified": 1784723954,
|
||||
"narHash": "sha256-1CfD8ZUjCkTgjsneLZ/lxCHhgDfqxxE7/GX0MmsgiqA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "efb6d0d5e2394c3c5416a18652808aba206c0988",
|
||||
"rev": "a017f5b72210026af5b3ac5949f08d94380a6fbd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -226,11 +158,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789344334,
|
||||
"narHash": "sha256-it+7kNGnOOHBCEnl4SfZ7QtfGziH3EryzHqwvJpjAfg=",
|
||||
"rev": "c3eea5b2156db11c7eeeada3dc737711255b253e",
|
||||
"lastModified": 1783148766,
|
||||
"narHash": "sha256-H9+N+GFtsbVC8ZniHliChM7ndizxtqVZs6bnGOLM3WQ=",
|
||||
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.9729.c3eea5b2156d/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.4193.a50de1b7d8a5/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -255,11 +187,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1790477367,
|
||||
"narHash": "sha256-oujbxmVKDhci2E4EM3KyAbRUgp5/IsCoiA/LfEF7OOk=",
|
||||
"lastModified": 1782614948,
|
||||
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "f7cd230690d9fc982d06129f9189b101fddcfa3b",
|
||||
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -286,11 +218,11 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1792502884,
|
||||
"narHash": "sha256-ij8QUxzTOHovLrNOxVRx+RAhGQFOMsJbji0veiatH5k=",
|
||||
"rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
|
||||
"lastModified": 1784796856,
|
||||
"narHash": "sha256-vwxWgF+Gj276WznzGb1LxGsK/39HaQwgQXiU3EkC844=",
|
||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1097682.02c087f3fe67/nixexprs.tar.zst"
|
||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1040357.e2587caef70c/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
@@ -300,11 +232,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1789546076,
|
||||
"narHash": "sha256-vWkSk5bbfTqdtMoSgD9FshACO8JCvXTFi+3cqEp0mH0=",
|
||||
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
|
||||
"lastModified": 1767892417,
|
||||
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
|
||||
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1074753.b1b875982b17/nixexprs.tar.xz"
|
||||
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
@@ -313,11 +245,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1792615934,
|
||||
"narHash": "sha256-q1ku4ygV7tdmv5Q9QYwhVDsNMZRJcRYjVczoX5+XWBo=",
|
||||
"rev": "da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36",
|
||||
"lastModified": 1784707089,
|
||||
"narHash": "sha256-DUedXhD2Rg8q4Xyd07Sb90eZGy4gg6W+Vl/WbLNwAZo=",
|
||||
"rev": "b3fe9581c9061c749abef42b6d4ee7b7c05c33fa",
|
||||
"type": "tarball",
|
||||
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.12400.da6dc0e7b260/nixexprs.tar.zst"
|
||||
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.5845.b3fe9581c906/nixexprs.tar.xz"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
@@ -325,132 +257,14 @@
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
"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-nix": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"hermes-agent": "hermes-agent",
|
||||
"nix": "nix",
|
||||
"nixos-generators": "nixos-generators",
|
||||
"nixos-hardware": "nixos-hardware",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
||||
"sops-nix": "sops-nix"
|
||||
}
|
||||
},
|
||||
"sops-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1792056300,
|
||||
"narHash": "sha256-YU1LjDe1o2Ymg5zEiCHbmMXLkDCtxHQRiVqZ3nC5GGk=",
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"rev": "44931708c62937c2b1bf7953902989fa5bb9ee1b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"uv2nix": {
|
||||
"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"
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,14 +11,6 @@
|
||||
url = "github:nix-community/nixos-generators";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
sops-nix = {
|
||||
url = "github:Mic92/sops-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
hermes-agent = {
|
||||
url = "github:NousResearch/hermes-agent";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
@@ -44,15 +36,6 @@
|
||||
_module.args.pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
sops
|
||||
age
|
||||
ssh-to-age
|
||||
nixfmt
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systems = [ "x86_64-linux" ];
|
||||
|
||||
@@ -4,7 +4,6 @@ let
|
||||
nixpkgs
|
||||
nixpkgs-unstable
|
||||
nixos-hardware
|
||||
sops-nix
|
||||
#hyprland
|
||||
;
|
||||
nixosSystem = nixpkgs.lib.makeOverridable nixpkgs.lib.nixosSystem;
|
||||
@@ -41,7 +40,6 @@ let
|
||||
];
|
||||
}
|
||||
../modules
|
||||
sops-nix.nixosModules.sops
|
||||
# ../profiles
|
||||
];
|
||||
defaultModules = baseModules ++ customModules;
|
||||
|
||||
@@ -3,6 +3,5 @@
|
||||
./modules.nix
|
||||
./network.nix
|
||||
./nix.nix
|
||||
./sops.nix
|
||||
./users.nix
|
||||
]
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
|
||||
settings = {
|
||||
auto-optimise-store = true;
|
||||
# Without these, gc sweeps away eval-time-only sources (flake inputs,
|
||||
# their build deps) since they're not part of the runtime closure.
|
||||
# The next evaluation then cold-refetches everything from GitHub,
|
||||
# which is enough on its own to trip GitHub's unauthenticated rate
|
||||
# limit for large dependency trees (e.g. hermes-agent's).
|
||||
keep-outputs = true;
|
||||
keep-derivations = true;
|
||||
};
|
||||
|
||||
gc = {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# sops-nix plumbing — applied to every machine.
|
||||
# See docs/secrets.md for the day-to-day workflow.
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Each host defaults to its own secrets/<hostname>.yaml. Override per-secret
|
||||
# with sops.secrets.<name>.sopsFile for shared files.
|
||||
sops.defaultSopsFile = lib.mkDefault (../../secrets + "/${config.networking.hostName}.yaml");
|
||||
sops.defaultSopsFormat = "yaml";
|
||||
sops.validateSopsFiles = true;
|
||||
|
||||
# Decrypt with the host's own SSH host key, converted to age.
|
||||
# Explicit rather than inheriting the openssh-derived default, so this does
|
||||
# not silently break if services.openssh is ever disabled.
|
||||
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
||||
sops.age.generateKey = false;
|
||||
|
||||
# Age only. The default here is the RSA host key imported as a GPG key,
|
||||
# which is deprecated and an extra failure surface.
|
||||
sops.gnupg.sshKeyPaths = [ ];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
sops
|
||||
age
|
||||
ssh-to-age
|
||||
];
|
||||
}
|
||||
@@ -49,22 +49,11 @@
|
||||
# Try fix wifi disconnect
|
||||
networking.networkmanager.wifi.powersave = false;
|
||||
|
||||
# Disable hibernate and suspend completely - this is a headless server.
|
||||
# The board exposes a phantom ACPI lid switch (PNP0C0D) that fires a
|
||||
# "closed" event when the HDMI cable is unplugged, which used to trigger
|
||||
# a real suspend via logind's default HandleLidSwitch=suspend.
|
||||
# Disable hibernate completely
|
||||
powerManagement.enable = true;
|
||||
systemd.targets."hibernate".enable = false;
|
||||
systemd.targets."hybrid-sleep".enable = false;
|
||||
systemd.targets."suspend-then-hibernate".enable = false;
|
||||
systemd.targets."suspend".enable = false;
|
||||
systemd.targets."sleep".enable = false;
|
||||
|
||||
services.logind.settings.Login = {
|
||||
HandleLidSwitch = "ignore";
|
||||
HandleLidSwitchExternalPower = "ignore";
|
||||
HandleLidSwitchDocked = "ignore";
|
||||
};
|
||||
|
||||
# Optional: kernel parameter to fully disable hibernation
|
||||
boot.kernelParams = [ "nohibernate" ];
|
||||
@@ -72,9 +61,6 @@
|
||||
|
||||
system = {
|
||||
stateVersion = "23.05";
|
||||
# Was silently failing daily (legacy channel path, no --flake set) without
|
||||
# ever reaching the network. Disabled rather than fixed: rebuilds should
|
||||
# only happen when triggered manually.
|
||||
autoUpgrade.enable = false;
|
||||
autoUpgrade.enable = true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# enabled profiles
|
||||
_: {
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
secrets = config.sops.secrets;
|
||||
in
|
||||
{
|
||||
my.profiles = {
|
||||
hyprland.enable = false;
|
||||
zsh.enable = true;
|
||||
@@ -10,15 +14,13 @@ _: {
|
||||
};
|
||||
actual.enable = false;
|
||||
audiobookshelf.enable = true;
|
||||
claude-code.enable = true;
|
||||
kde-desktop.enable = false;
|
||||
radarr.enable = true;
|
||||
docker.enable = true;
|
||||
readarr.enable = true;
|
||||
sonarr.enable = true;
|
||||
jellyfin.enable = true;
|
||||
seerr.enable = true;
|
||||
immich.enable = true;
|
||||
jellyseerr.enable = true;
|
||||
development.enable = true;
|
||||
home-assistant.enable = true;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ _: {
|
||||
];
|
||||
|
||||
networking = {
|
||||
domain = "jupiter";
|
||||
domain = "jupiter.solar.internal";
|
||||
search = [ "jupiter.solar.internal" ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,19 +10,10 @@
|
||||
./disks.nix
|
||||
./hardware-configuration.nix
|
||||
./environments.nix
|
||||
./network.nix
|
||||
./secrets.nix
|
||||
# ./system.nix use docker here
|
||||
];
|
||||
|
||||
networking.hostName = "mibook";
|
||||
|
||||
swapDevices = [
|
||||
{
|
||||
device = "/var/lib/swapfile";
|
||||
size = 8 * 1024; # 8G, headroom for nix builds (7.7G RAM, no prior swap)
|
||||
}
|
||||
];
|
||||
boot.loader.grub = {
|
||||
enable = true;
|
||||
device = "/dev/nvme0n1";
|
||||
@@ -51,6 +42,37 @@
|
||||
|
||||
services.openssh.enable = true;
|
||||
|
||||
# Don't let boot stall waiting for a network that may never come up
|
||||
# (WiFi credentials live in KWallet and need a desktop session), which
|
||||
# otherwise hangs the terminal boot before the login prompt appears.
|
||||
systemd.services.NetworkManager-wait-online.enable = false;
|
||||
|
||||
# Boot-time choice: the default GRUB entry boots straight into KDE.
|
||||
# A separate "terminal" entry (a NixOS specialisation) boots to a text
|
||||
# console with autologin, where you can work or run `desktop` to bring
|
||||
# KDE up. Pick the entry you want in the GRUB menu at boot.
|
||||
#
|
||||
# NOTE: in terminal mode WiFi will not connect on its own (the password
|
||||
# is stored per-user in KWallet). To reach the machine over SSH from
|
||||
# terminal mode, first save the WiFi as a system connection in KDE:
|
||||
# network settings -> your WiFi -> "All users may connect to this network".
|
||||
specialisation.terminal.configuration = {
|
||||
system.nixos.tags = [ "terminal" ];
|
||||
|
||||
# Boot to a text console. graphical.target is what pulls in the display
|
||||
# manager (via its embedded Wants=display-manager.service), so defaulting
|
||||
# to multi-user.target leaves SDDM installed but not started at boot.
|
||||
systemd.defaultUnit = lib.mkForce "multi-user.target";
|
||||
|
||||
# Guarantee a usable shell on the console (no login prompt to hunt for).
|
||||
services.getty.autologinUser = "finn";
|
||||
|
||||
# Bring the desktop up on demand from the terminal.
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeShellScriptBin "desktop" "exec sudo systemctl start display-manager.service")
|
||||
];
|
||||
};
|
||||
|
||||
# KDE (PowerDevil) power settings: do nothing on lid close while on AC power.
|
||||
# Shipped as a system-wide default; KConfig cascades so a user's own
|
||||
# ~/.config/powerdevilrc will override this if present.
|
||||
@@ -61,9 +83,6 @@
|
||||
|
||||
system = {
|
||||
stateVersion = "23.05";
|
||||
# Was silently failing daily (legacy channel path, no --flake set) without
|
||||
# ever reaching the network. Disabled rather than fixed: rebuilds should
|
||||
# only happen when triggered manually.
|
||||
autoUpgrade.enable = false;
|
||||
autoUpgrade.enable = true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# enabled profiles
|
||||
_: {
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
secrets = config.sops.secrets;
|
||||
in
|
||||
{
|
||||
my.profiles = {
|
||||
kde-desktop.enable = true;
|
||||
zsh.enable = true;
|
||||
apps = {
|
||||
desktop_apps = false;
|
||||
dev_apps = false;
|
||||
desktop_apps = true;
|
||||
dev_apps = true;
|
||||
};
|
||||
development.enable = true;
|
||||
docker.enable = true;
|
||||
claude-code.enable = true;
|
||||
hermes-agent.enable = true;
|
||||
};
|
||||
|
||||
my.hardware = {
|
||||
@@ -19,7 +22,7 @@ _: {
|
||||
nvidia.enable = false;
|
||||
};
|
||||
my.services = {
|
||||
vpn.enable = false;
|
||||
vpn.enable = true;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
_: {
|
||||
# Athena (local AI): allow LAN access to the Hermes web dashboard.
|
||||
# Bound to 0.0.0.0:9119 in the athena docker stack; NixOS default-deny
|
||||
# firewall otherwise blocks inbound connections from other devices.
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
9119 # athena hermes dashboard
|
||||
47823 # hermes agent ad-hoc webpage server
|
||||
];
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# Secrets decrypted on mibook. sopsFile defaults to secrets/mibook.yaml
|
||||
# via machines/core/sops.nix.
|
||||
{ ... }:
|
||||
{
|
||||
sops.secrets.hermes-env = {
|
||||
owner = "hermes";
|
||||
group = "hermes";
|
||||
mode = "0400";
|
||||
restartUnits = [ "hermes-agent.service" ]; # confirmed/fixed in Task 4 if the unit name differs
|
||||
};
|
||||
}
|
||||
@@ -25,13 +25,11 @@ in
|
||||
spotify
|
||||
keepassxc
|
||||
moonlight-qt
|
||||
# jetbrains.idea-ultimate
|
||||
nextcloud-client
|
||||
pipewire
|
||||
wireplumber
|
||||
vlc
|
||||
ghostty
|
||||
zed-editor
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
./readarr
|
||||
./sonarr
|
||||
./jellyfin
|
||||
./seerr
|
||||
./immich
|
||||
./hermes-agent
|
||||
./jellyseerr
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ in
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.finn.packages = with pkgs; [
|
||||
google-chrome
|
||||
vscode
|
||||
zed-editor
|
||||
neovim
|
||||
# jetbrains.idea-ultimate
|
||||
go
|
||||
nodejs # bundles npm
|
||||
(python3.withPackages (
|
||||
ps: with ps; [
|
||||
jupyter # notebooks
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# Hermes Agent — Telegram + Email messaging gateway (Claude subscription auth)
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.my.profiles.hermes-agent;
|
||||
in
|
||||
{
|
||||
imports = [ self.inputs.hermes-agent.nixosModules.default ];
|
||||
|
||||
options.my.profiles.hermes-agent.enable =
|
||||
lib.mkEnableOption "Hermes Agent (Telegram + Email gateway)";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
# Deliberate pin, matches the approved design — bump on purpose, not by drift.
|
||||
settings.model.default = "anthropic/claude-sonnet-5-5";
|
||||
# The Nix flake input is the source of truth for hermes's version here; its
|
||||
# built-in self-updater polling GitHub independently just burns API quota.
|
||||
settings.updates.check = false;
|
||||
environmentFiles = [ config.sops.secrets."hermes-env".path ];
|
||||
addToSystemPackages = true;
|
||||
|
||||
backend = {
|
||||
# "dashboard" serves the browser admin panel, not just the API.
|
||||
mode = "dashboard";
|
||||
# Non-loopback bind opens the dashboard's auth gate — username/password
|
||||
# comes from HERMES_DASHBOARD_BASIC_AUTH_* in hermes-env (see secrets.nix).
|
||||
host = "0.0.0.0";
|
||||
port = 9119;
|
||||
};
|
||||
};
|
||||
|
||||
# NixOS default-deny firewall otherwise blocks inbound LAN connections.
|
||||
networking.firewall.allowedTCPPorts = [ config.services.hermes-agent.backend.port ];
|
||||
|
||||
# addToSystemPackages only puts `hermes` on $PATH; using it needs group
|
||||
# access to /var/lib/hermes/.hermes (mode 2770 hermes:hermes). finn is
|
||||
# this machine's one human user and already knows hermes-env's contents
|
||||
# (typed them into sops directly), so this is not new exposure.
|
||||
users.users.finn.extraGroups = [ "hermes" ];
|
||||
};
|
||||
}
|
||||
@@ -54,16 +54,14 @@ in
|
||||
"f /var/lib/hass/scenes.yaml 0644 hass hass - []"
|
||||
];
|
||||
|
||||
# Disabled: ZBT-2 antenna isn't connected, so otbr-agent can't reach the
|
||||
# radio and just crash-loops on every switch.
|
||||
# services.openthread-border-router = {
|
||||
# enable = true;
|
||||
# package = pkgs.unstable.openthread-border-router;
|
||||
# openFirewall = true;
|
||||
# backboneInterfaces = [ "enp3s0" ];
|
||||
# radio.device = "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_DCB4D9149C7C-if00";
|
||||
# radio.baudRate = 460800;
|
||||
# };
|
||||
services.openthread-border-router = {
|
||||
enable = true;
|
||||
package = pkgs.unstable.openthread-border-router;
|
||||
openFirewall = true;
|
||||
backboneInterfaces = [ "enp3s0" ];
|
||||
radio.device = "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_DCB4D9149C7C-if00";
|
||||
radio.baudRate = 460800;
|
||||
};
|
||||
|
||||
my.homepage.services = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
[
|
||||
{
|
||||
Developer = [
|
||||
{
|
||||
Github = [
|
||||
{
|
||||
abbr = "GitLab - Smoothcloud";
|
||||
href = "https://gitlab.smoothcloud.de/";
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
Social = [
|
||||
{
|
||||
@@ -6,6 +18,7 @@
|
||||
{
|
||||
icon = "reddit.png";
|
||||
href = "https://reddit.com/";
|
||||
description = "The front page of the internet";
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -16,7 +29,7 @@
|
||||
{
|
||||
YouTube = [
|
||||
{
|
||||
icon = "youtube.png";
|
||||
abbr = "YT";
|
||||
href = "https://youtube.com/";
|
||||
}
|
||||
];
|
||||
|
||||
@@ -12,107 +12,91 @@ let
|
||||
manualServices = import ./manual-services.nix;
|
||||
manualWidgets = import ./manual-widgets.nix;
|
||||
|
||||
groupedServices = lib.foldl' (
|
||||
acc: entry:
|
||||
acc
|
||||
// {
|
||||
${entry.group} = (acc.${entry.group} or [ ]) ++ [ entry ];
|
||||
}
|
||||
) { } config.my.homepage.services;
|
||||
groupedServices =
|
||||
lib.foldl'
|
||||
(
|
||||
acc: entry:
|
||||
acc
|
||||
// {
|
||||
${entry.group} = (acc.${entry.group} or [ ]) ++ [ entry ];
|
||||
}
|
||||
)
|
||||
{ }
|
||||
config.my.homepage.services;
|
||||
|
||||
homepageServices = lib.mapAttrsToList (group: entries: {
|
||||
${group} = map (
|
||||
entry:
|
||||
let
|
||||
service = builtins.removeAttrs entry [
|
||||
"group"
|
||||
"name"
|
||||
];
|
||||
in
|
||||
{
|
||||
${entry.name} = lib.filterAttrs (_: value: value != null) service;
|
||||
}
|
||||
) entries;
|
||||
}) groupedServices;
|
||||
|
||||
# homepage-dashboard keeps only the first services.yaml list entry with a
|
||||
# given group name (first match wins), so module-contributed and manual
|
||||
# groups sharing a name (e.g. "Services") must be merged here rather than
|
||||
# simply concatenated, or the later ones are silently dropped.
|
||||
mergeGroups =
|
||||
lists:
|
||||
let
|
||||
merged = lib.foldl' (
|
||||
acc: groupAttrs:
|
||||
homepageServices = lib.mapAttrsToList (
|
||||
group: entries:
|
||||
{
|
||||
${group} = map (
|
||||
entry:
|
||||
let
|
||||
groupName = builtins.head (builtins.attrNames groupAttrs);
|
||||
service = builtins.removeAttrs entry [
|
||||
"group"
|
||||
"name"
|
||||
];
|
||||
in
|
||||
acc // { ${groupName} = (acc.${groupName} or [ ]) ++ groupAttrs.${groupName}; }
|
||||
) { } lists;
|
||||
in
|
||||
lib.mapAttrsToList (name: entries: { ${name} = entries; }) merged;
|
||||
{
|
||||
${entry.name} = lib.filterAttrs (_: value: value != null) service;
|
||||
}
|
||||
) entries;
|
||||
}
|
||||
) groupedServices;
|
||||
in
|
||||
{
|
||||
|
||||
options.my.homepage.services =
|
||||
with lib;
|
||||
mkOption {
|
||||
type = types.listOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service group";
|
||||
};
|
||||
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service name";
|
||||
};
|
||||
|
||||
description = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service description";
|
||||
};
|
||||
|
||||
href = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service URL";
|
||||
};
|
||||
|
||||
icon = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Optional Homepage service icon";
|
||||
};
|
||||
options.my.homepage.services = with lib; mkOption {
|
||||
type = types.listOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service group";
|
||||
};
|
||||
}
|
||||
);
|
||||
default = [ ];
|
||||
description = "Merged homepage service metadata contributed by repo modules.";
|
||||
};
|
||||
|
||||
options.my.homepage.widgets =
|
||||
with lib;
|
||||
mkOption {
|
||||
type = types.listOf types.attrs;
|
||||
default = [ ];
|
||||
description = "Widget definitions passed directly to services.homepage-dashboard.widgets. Each entry is an attrset like { resources = { cpu = true; memory = true; }; }.";
|
||||
};
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service name";
|
||||
};
|
||||
|
||||
description = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service description";
|
||||
};
|
||||
|
||||
href = mkOption {
|
||||
type = types.str;
|
||||
description = "Homepage service URL";
|
||||
};
|
||||
|
||||
icon = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Optional Homepage service icon";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = [ ];
|
||||
description = "Merged homepage service metadata contributed by repo modules.";
|
||||
};
|
||||
|
||||
options.my.homepage.widgets = with lib; mkOption {
|
||||
type = types.listOf types.attrs;
|
||||
default = [ ];
|
||||
description = "Widget definitions passed directly to services.homepage-dashboard.widgets. Each entry is an attrset like { resources = { cpu = true; memory = true; }; }.";
|
||||
};
|
||||
|
||||
options.my.profiles.homepage = with lib; {
|
||||
enable = mkEnableOption "getHomepage.dev Dashboard";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
networking.firewall.allowedTCPPorts = [ dashboardPort ];
|
||||
|
||||
services.homepage-dashboard = {
|
||||
enable = true;
|
||||
listenPort = dashboardPort;
|
||||
allowedHosts = "${dashboardHost}:${toString dashboardPort},192.168.178.65:${toString dashboardPort},localhost:${toString dashboardPort},127.0.0.1:${toString dashboardPort},jupiter:${toString dashboardPort},jupiter.solar.internal:${toString dashboardPort}";
|
||||
allowedHosts = "${dashboardHost}:${toString dashboardPort},localhost:${toString dashboardPort},127.0.0.1:${toString dashboardPort},jupiter.solar.internal:${toString dashboardPort}";
|
||||
bookmarks = import ./bookmarks.nix;
|
||||
services = mergeGroups (homepageServices ++ manualServices);
|
||||
services = homepageServices ++ manualServices;
|
||||
widgets = config.my.homepage.widgets ++ manualWidgets;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
# Example:
|
||||
{
|
||||
"Services" = [
|
||||
{
|
||||
"Athena Dashboard" = {
|
||||
href = "http://mibook:9119";
|
||||
description = "Hermes Agent messaging gateway";
|
||||
icon = "mdi-robot-outline";
|
||||
};
|
||||
}
|
||||
{
|
||||
"Tesla - Dashboard" = {
|
||||
href = "https://grafana.smoothcloud.de";
|
||||
@@ -17,10 +10,10 @@
|
||||
};
|
||||
}
|
||||
{
|
||||
"Gitea" = {
|
||||
href = "https://git.rechberg.online";
|
||||
description = "Selfhosted Gitea";
|
||||
icon = "gitea.png";
|
||||
"GitLab" = {
|
||||
href = "https://gitlab.smoothcloud.de";
|
||||
description = "Selfhosted GitLab";
|
||||
icon = "gitlab.png";
|
||||
};
|
||||
}
|
||||
{
|
||||
@@ -32,40 +25,4 @@
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
"Gaming" = [
|
||||
{
|
||||
"Talon" = {
|
||||
href = "https://ethraldos.online";
|
||||
description = "Kunst der Täuschung";
|
||||
icon = "foundry-vtt.png";
|
||||
};
|
||||
}
|
||||
{
|
||||
"Tyranny of Dragons" = {
|
||||
href = "https://hotdq.smoothcloud.de";
|
||||
description = "Tyranny of Dragons (D&D)";
|
||||
icon = "foundry-vtt.png";
|
||||
};
|
||||
}
|
||||
{
|
||||
"Beholder D&D" = {
|
||||
href = "https://beholder.ethraldos.online";
|
||||
description = "Andrey FoundryVTT Server";
|
||||
icon = "foundry-vtt.png";
|
||||
};
|
||||
}
|
||||
{
|
||||
"Minecraft" = {
|
||||
href = "https://rechberg.online";
|
||||
description = "Java Minecraft Server";
|
||||
icon = "minecraft.png";
|
||||
widget = {
|
||||
type = "minecraft";
|
||||
url = "tcp://rechberg.online:25565";
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Immich self-hosted photo & video server
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.my.profiles.immich;
|
||||
hostName = config.networking.hostName;
|
||||
port = 2283;
|
||||
in
|
||||
{
|
||||
options.my.profiles.immich = with lib; {
|
||||
enable = mkEnableOption "Immich photo server";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.immich = {
|
||||
enable = true;
|
||||
# nixos-26.05 pins immich 2.7.5; pull 3.x from the unstable overlay instead.
|
||||
package = pkgs.unstable.immich;
|
||||
host = "0.0.0.0";
|
||||
inherit port;
|
||||
openFirewall = true;
|
||||
mediaLocation = "/var/lib/immich";
|
||||
machine-learning.enable = true;
|
||||
accelerationDevices = [ "/dev/dri/renderD128" ];
|
||||
# Setting `settings` puts Immich in config-file mode: the admin settings
|
||||
# UI becomes read-only and system config is managed declaratively here.
|
||||
settings = {
|
||||
server.externalDomain = "http://${hostName}:${toString port}";
|
||||
# Intel Quick Sync hardware transcoding (jupiter's iGPU).
|
||||
ffmpeg.accel = "qsv";
|
||||
};
|
||||
};
|
||||
|
||||
# The native module does not add GPU groups; required for VAAPI/QSV transcoding.
|
||||
users.users.immich.extraGroups = [
|
||||
"video"
|
||||
"render"
|
||||
];
|
||||
|
||||
my.homepage.services = [
|
||||
{
|
||||
group = "Media";
|
||||
name = "Immich";
|
||||
description = "Photo & video server";
|
||||
href = "http://${hostName}:${toString port}";
|
||||
icon = "immich.png";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
+6
-6
@@ -1,17 +1,17 @@
|
||||
# manages and downloads films
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.my.profiles.seerr;
|
||||
cfg = config.my.profiles.jellyseerr;
|
||||
hostName = config.networking.hostName;
|
||||
port = 5055;
|
||||
in
|
||||
{
|
||||
options.my.profiles.seerr = with lib; {
|
||||
enable = mkEnableOption "Seerr Media Service";
|
||||
options.my.profiles.jellyseerr = with lib; {
|
||||
enable = mkEnableOption "JellySeerr Media Service";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.seerr = {
|
||||
services.jellyseerr = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
};
|
||||
@@ -19,14 +19,14 @@ in
|
||||
my.homepage.services = [
|
||||
{
|
||||
group = "Media";
|
||||
name = "Seerr";
|
||||
name = "Jellyseerr";
|
||||
description = "Media requests";
|
||||
href = "http://${hostName}:${toString port}";
|
||||
icon = "mdi-television-guide";
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.seerr = {
|
||||
systemd.services.jellyseerr = {
|
||||
after = [ "network-online.target" ];
|
||||
};
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
hermes-env: ENC[AES256_GCM,data:du6bieWblEAhAYdZtCqnbNy0yWjFj13djw6co5nrNt1J8zWrmjdXo+UzvwKVpH5EMnxIEuzq9VrqsDPBLDAy1/SQRiWUuK5V7bRSVn88Hs/c7zJ1wo57i1DTVU5gwygzZHmEZbn6gKnFzNBx3aqZV+dLk3+Q4s6LpXbtRgZc63WeuMHqjlUBFeZoGIPZZ9FzqWjVx/FIgWRFm56utyBsnWhMK/2ZD2E6aOFNCZaXZp0QVXGRmiLtx7MLBY8oqJUBl16wytjOB8Y5khf5pJ5AV2NkXx9h27ycZbEQwouq1P6ty/OjQfmkZn7Jbt/oL9lG2FiOsR1x6f4bmVYrTrhjXmFoWvvDSlPJ7noBg2L+J0UrAZpDnOr2DCxZXlLnBCKaMdqvq27yTyATeWAW1BE5gCLlYnT5iE2vlr9GttY4LjfXW8HL3ieQ4bL/PB/PBZSZVKl5QrLfVrou1i8cxtM5cGfFKi9zCaDYI3G282961u1VHuVYVKZoU73WiR2BEuWqXn+1F3YOWEkOg7lM4xMoy9Q9qx7rSv3M/zOF7gjI0spCQv5RiuUOgoHbiERXgTAEfIaDRTYL30IqnHMS3O9SsTTINwDXBNk4cDxI2Ctyfy2IeAucBWAd4OhEqnji8bJ7tO4At8UIxjZ3oYGJiu0uqaDFS/Jdre4uYrSk,iv:CyO15YBh6BKNpZnsp+LAizND9rRgIjWj8SyT33Nii2c=,tag:WLlmMBIcvmYceSY4RXGUuw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZUk5cjhGTlVveUFRdHdS
|
||||
elpVV0pZVU54U3plYjNBai9ZZVBxTDhyRFFnCmpGWW81VGJoMnd4eWxCWnovQnFF
|
||||
WXR1MVBTZ3VRUnNMUjI5Rko4NHpBdjQKLS0tIDkxYWNxTmhUS2dYTkFnTWZXaS9V
|
||||
ejdtSmF2M1ZVdFNiWENMWlNraUYxVzAKdlz4xLhTKjJfFESDcym2xCOa71rXj6BE
|
||||
mdeaCDhE11CAXqrpKU+j0/kwTsZRzyqlwQsfeI4ratu1K1vY66ONsw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1a0v442ct2qfjcdhyktn25spw02yq4fl08plrrvuradwwxzsle4mq8thln4
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYT1hRSGt6TDRSMklQNDA3
|
||||
Z2lYS2pMRWxxeEtGRGJSeXRDSlY5UTFBNEZvCm5RK0huT282MXpwWjdrTHByY3Ev
|
||||
bTBMczR1MzVEcnNDVUxadlREbGg3UUUKLS0tIHhydUlmRExkbmNNNkVTcW1OSnpZ
|
||||
aEtLZkRTRE9GZ0I1bEN0ZEl3RjVMeDgKoCY7NyxFMS+WuBUez2Irf91Xuy9QpM5g
|
||||
RTmhK6mw2wd8oUMdgPOUESNwh1xkCz4hDHPrDQnfd4sPhSAvh28jwg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1n3uqtee5ymykrls6k04dznq50cwmj99hw7ny83yxjndt4y2ncf5qhjwa2p
|
||||
lastmodified: "2026-09-10T06:28:38Z"
|
||||
mac: ENC[AES256_GCM,data:t9oIZWrBtwkGyyb9hGlfWzAguI/xlqtRceTyyYE2y99xmGIl+pXHJkFF2pP9T09/NWHcXV0gRmV7LuKfF1zX1ZPNY7dba0AwVtU5ezoUdUuL2i47S7Ty1Onk9y6PqtgsRE1SkRAdzAr7s0csBzGXAn8I4z2rqZa43obRwtx7XV4=,iv:Lw6UmeeSNsfNftho423VM+htR0VzQFrCw3sDQ9OLQP0=,tag:SDQ0jJLf1jQIhBWPkm2WRg==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
Reference in New Issue
Block a user