Compare commits
27
Commits
15e081e52d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25e17f163e | ||
|
|
9b96d3c6f8 | ||
|
|
32dd2abdce | ||
|
|
694317acbb | ||
|
|
a8e5c200dc | ||
|
|
ee0a2f39e9 | ||
|
|
666e84bb26 | ||
|
|
f3dddc9150 | ||
|
|
4adb59808f | ||
|
|
a5edaab80d | ||
|
|
7d9bb7d183 | ||
|
|
6f24ab69ad | ||
|
|
3899290c5b | ||
|
|
bd0aab4e61 | ||
|
|
1b2260293b | ||
|
|
f7b12bc7cd | ||
|
|
b3c3cc38f0 | ||
|
|
27b924f5e9 | ||
|
|
38608c4008 | ||
|
|
05d26e386f | ||
|
|
2bf71494f4 | ||
|
|
3aecaf9de5 | ||
|
|
45e08e35a2 | ||
|
|
a8a1cffa3e | ||
|
|
15ae1cf608 | ||
|
|
d117d26fde | ||
|
|
959ad30fdc |
@@ -74,6 +74,9 @@ nix build .#nixosConfigurations.mercury-vm.config.system.build.vm -o result
|
||||
nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA
|
||||
# end-to-end VM test of `deploy kexec-local` (~45s once the tarball is built)
|
||||
nix build .#checks.x86_64-linux.kexec-local -L
|
||||
# VM test of luna's app hosting on mars (hosts/mars/luna-sites.nix): podman socket
|
||||
# proxy, registry validation, caddy routes, reboot persistence
|
||||
nix build .#checks.x86_64-linux.luna-sites -L
|
||||
```
|
||||
|
||||
`checks.kexec-local` is the only way to exercise `kexec-local` at all: it jumps the
|
||||
@@ -207,3 +210,9 @@ kept its ssh host key. Run it after ANY change to the kexec paths.
|
||||
- **`kexec-local` stages on `/var/tmp`, not `/tmp`**: `kexec-run.sh` appends a fresh cpio
|
||||
to `kexec/initrd` in place and execs binaries from that dir, so a size-capped or
|
||||
`noexec` tmpfs gives a half-written initrd or a bare "Permission denied".
|
||||
- **terra's greeter is a throwaway Hyprland running `dotfiles/quickshell/greeter.qml`**
|
||||
(`services/desktop/quickshell-greeter.nix`). It must exit after login or greetd never
|
||||
starts the session, and with a Lua config `hyprctl dispatch exit` is REJECTED — it needs
|
||||
`hyprctl dispatch 'hl.dsp.exit()'`. Test the flow without touching the real greetd by
|
||||
running greetd's `fakegreet "qs -p …/greeter.qml"` inside a nested Hyprland
|
||||
(user `user`, password `password`, then answer `9`).
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services),
|
||||
`neptun` (netcup VPS: public reverse proxy, Authentik, headscale),
|
||||
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site,
|
||||
single-purpose: Hermes Agent only).
|
||||
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop), `mars` (on-site:
|
||||
Hermes Agent, plus the LAN web apps luna hosts at `http://mars.sol/<name>/`).
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -27,9 +27,12 @@ hosts/
|
||||
vm.nix # VirtualBox test image (jupiter-vbox)
|
||||
neptun/ # netcup public reverse proxy + tailnet node
|
||||
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
||||
mars/ # on-site, single-purpose: Hermes Agent only
|
||||
mars/ # on-site: Hermes Agent + luna's LAN web apps
|
||||
configuration.nix disk-config.nix hardware-configuration.nix secrets.nix
|
||||
hermes-agent.nix # Hermes Agent (moved here from jupiter)
|
||||
luna-sites.nix # luna's apps: rootless podman + caddy, no nix edit per app
|
||||
luna-sites-README.md # what luna is told (mounted into her container)
|
||||
luna-sites-test.nix # VM test: nix build .#checks.x86_64-linux.luna-sites -L
|
||||
secrets/ # age-encrypted sops files, one per host
|
||||
scripts/ # deploy, edit_secrets
|
||||
```
|
||||
@@ -427,6 +430,93 @@ another way in.
|
||||
(via the `/mnt/jupiter` samba mount) before the first switch if you want
|
||||
it preserved instead of starting clean.
|
||||
|
||||
### Obsidian vaults (jupiter CouchDB + mars bridge)
|
||||
|
||||
CouchDB itself is fully declarative (`services/dev/obsidian-livesync.nix`), but
|
||||
three things are runtime state it cannot own.
|
||||
|
||||
**1. Each vault's database is created by the plugin.** Point Self-hosted
|
||||
LiveSync at `https://notes.mgaction.town` (URI field) with the database name in
|
||||
its own field — *not* as a path on the URI. Turn on End-to-End Encryption and
|
||||
Obfuscate Properties **before the first sync**; both are remote-format
|
||||
decisions and changing them later means converting or rebuilding the database.
|
||||
The passphrase lives in the HomeLab Proton Pass vault, never in sops — it is
|
||||
what keeps a publicly reachable database from being a readable one.
|
||||
|
||||
Database names must start with a lowercase letter (`a-z0-9_$()+-` after that).
|
||||
An illegal name is rejected by neptun's matcher rather than CouchDB, and shows
|
||||
up in Obsidian as a connection failure with **no error message at all**.
|
||||
|
||||
**2. luna's vault credentials on mars.** `hosts/mars/secrets.nix` needs two
|
||||
values before mars will activate: `couchdb_luna_password` and
|
||||
`obsidian_luna_passphrase`.
|
||||
|
||||
```
|
||||
sops --set '["couchdb_luna_password"] "<password>"' secrets/mars.yaml
|
||||
sops --set '["obsidian_luna_passphrase"] "<passphrase>"' secrets/mars.yaml
|
||||
```
|
||||
|
||||
Keep both alphanumeric. sops substitutes into already-rendered JSON, so a `"`
|
||||
or `\` in either produces an invalid `config.json`; the bridge logs
|
||||
`Could not parse configuration!` and then runs on with **zero peers** instead
|
||||
of exiting, which looks exactly like a bridge that is simply idle.
|
||||
|
||||
As set up today these are the `obsidian` admin password and the same
|
||||
passphrase as the personal vault, which means mars — the box running an
|
||||
autonomous agent — can decrypt and read every vault database. Optional
|
||||
hardening, either half independently:
|
||||
|
||||
```
|
||||
# password comes straight out of sops; never echo it
|
||||
LUNA_PW=$(sops --decrypt --extract '["couchdb_luna_password"]' secrets/mars.yaml)
|
||||
ADMIN=obsidian # prompts for the admin password
|
||||
curl -u "$ADMIN" -X PUT http://jupiter.orbit.sol:5984/_users/org.couchdb.user:luna \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"luna\",\"type\":\"user\",\"roles\":[],\"password\":\"$LUNA_PW\"}"
|
||||
curl -u "$ADMIN" -X PUT http://jupiter.orbit.sol:5984/luna_wiki/_security \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"admins":{"names":[],"roles":[]},"members":{"names":["luna"],"roles":[]}}'
|
||||
unset LUNA_PW
|
||||
```
|
||||
|
||||
then set `username` in `hosts/mars/livesync-bridge.nix` to `luna` and put that
|
||||
account's password in `couchdb_luna_password`. Run it against jupiter over the
|
||||
tailnet — `/_users` is blocked on the public vhost on purpose. A vault-specific
|
||||
passphrase is the other half, changed in the plugin and mirrored into sops.
|
||||
|
||||
**3. The database name must match.** `database` in
|
||||
`hosts/mars/livesync-bridge.nix` has to be exactly the name entered in the
|
||||
plugin. A mismatch does not error — with an admin credential PouchDB simply
|
||||
creates the misnamed database and replicates an empty vault into it.
|
||||
|
||||
Order matters: set the vault up from Obsidian first so the database exists and
|
||||
carries the plugin's own tweaks, then deploy mars. Afterwards:
|
||||
|
||||
```
|
||||
systemctl status livesync-bridge # on mars
|
||||
cat /var/lib/livesync-bridge/health.json # per-peer ok/backendUp/detail
|
||||
ls /var/lib/livesync-bridge/vault # her notes, as real markdown
|
||||
```
|
||||
|
||||
The vault is mounted into the agent container at `/opt/data/vault`, inside
|
||||
`HERMES_WRITE_SAFE_ROOT`, so luna can write as well as read.
|
||||
|
||||
A note luna writes reaches CouchDB as soon as the bridge sees it, but whether
|
||||
it then reaches your devices depends on that vault's **Sync Mode** in the
|
||||
plugin. Only "LiveSync (real-time)" pulls continuously; the periodic/on-save
|
||||
presets need their timer or a manual **Replicate**. A file that appears only
|
||||
after clicking Replicate is the client waiting, not the bridge failing — the
|
||||
database already had it. Check the bridge's own side in the journal:
|
||||
|
||||
```
|
||||
journalctl -u livesync-bridge | grep -- '--> luna-remote'
|
||||
```
|
||||
|
||||
⚠️ **Verify her writes actually land before trusting this.** Upstream has three
|
||||
open issues on the storage→CouchDB direction (#50, #23, #46) and all fail
|
||||
silently — the log reports the upload and the database never updates. Create a
|
||||
note as luna, confirm it appears on a phone, and re-check after any input bump.
|
||||
|
||||
### mercury (Raspberry Pi 3B+)
|
||||
|
||||
- `./deploy flash mercury /dev/sdX` writes the dedicated age key to the root
|
||||
|
||||
+9
-9
@@ -1,4 +1,4 @@
|
||||
{ pkgs, ... }:
|
||||
{ pkgs, config, ... }:
|
||||
|
||||
# Shared base for all hosts: user, SSH hardening, nix settings, packages.
|
||||
{
|
||||
@@ -54,10 +54,9 @@
|
||||
environment.systemPackages = with pkgs; [ git btop tmux curl wget zsh-powerlevel10k lsd jq ];
|
||||
|
||||
# ---- home-manager (user-level config for darman, all hosts) ----
|
||||
# Requires home-manager.nixosModules.home-manager in the host's own
|
||||
# `modules` list (flake.nix) — this only sets values for options that
|
||||
# module declares, it doesn't import it, so every nixosSystem using
|
||||
# common.nix needs that line too (mirrors terra's original setup).
|
||||
# Only sets values for options declared by home-manager.nixosModules.home-manager;
|
||||
# it doesn't import that module, so every nixosSystem using common.nix must
|
||||
# also list it in flake.nix's `modules`.
|
||||
home-manager.useGlobalPkgs = true;
|
||||
home-manager.useUserPackages = true;
|
||||
# Protects activation if a plain (non-symlink) ~/.zshrc etc. already
|
||||
@@ -91,16 +90,17 @@
|
||||
boot.loader.systemd-boot.configurationLimit = 5;
|
||||
boot.loader.generic-extlinux-compatible.configurationLimit = 5;
|
||||
|
||||
# Stock journald defaults to ~10% of the filesystem (up to 4G) before it
|
||||
# rotates — no scheduled vacuum, just a ceiling it grows into. On jupiter's
|
||||
# 29G eMMC that's ~2.9G it could silently accumulate. Cap it well below that
|
||||
# everywhere instead of only noticing when a disk fills up again.
|
||||
# Stock journald grows unbounded up to ~10% of the filesystem (4G cap, no
|
||||
# scheduled vacuum) — on jupiter's 29G eMMC that's ~2.9G it could silently
|
||||
# fill. Cap it well below that everywhere.
|
||||
services.journald.extraConfig = ''
|
||||
SystemMaxUse=200M
|
||||
'';
|
||||
|
||||
# ---- Locale / firewall base ----
|
||||
time.timeZone = "Europe/Berlin";
|
||||
# Flatpak needs /etc/timezone, falls back to UTC if not set.
|
||||
environment.etc."timezone".text = config.time.timeZone;
|
||||
i18n.defaultLocale = "en_US.UTF-8";
|
||||
console.keyMap = "de";
|
||||
|
||||
|
||||
+104
-13
@@ -4,38 +4,129 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What this is
|
||||
|
||||
A [Quickshell](https://quickshell.org/) configuration — a QML-based Wayland desktop shell (bar, launcher, tray, decorations) for a Hyprland/wlroots setup. `~/.config/quickshell` is a symlink to this repo, so Quickshell loads `shell.qml` here as the "default" config.
|
||||
A [Quickshell](https://quickshell.org/) configuration — a QML-based Wayland desktop shell (bar, launcher, tray, decorations) for a Hyprland/wlroots setup. `shell.qml` is the entry point.
|
||||
|
||||
## Running / testing changes
|
||||
|
||||
**`~/.config/quickshell` is NOT a symlink to this repo.** `hosts/terra/home.nix`
|
||||
ships the tree with `xdg.configFile."quickshell"`, which COPIES it into the nix
|
||||
store, so the config directory is a read-only symlink into `/nix/store/...`.
|
||||
Editing a file here therefore changes nothing about the running shell: it is
|
||||
watching the frozen store copy, and every edit would otherwise cost a
|
||||
`nixos-rebuild`. Two consequences worth knowing before debugging anything:
|
||||
|
||||
- **A new file must be `git add`ed before it can be deployed at all.** Flakes
|
||||
read the git tree, and untracked files are silently dropped — with no warning
|
||||
and no eval error. An untracked module that `shell.qml` imports produces a
|
||||
deployed config that fails to load, which does not surface until the next
|
||||
restart because the running shell keeps serving the store path it resolved at
|
||||
launch.
|
||||
- To check what a deploy actually shipped, compare the evaluated source with
|
||||
what is live:
|
||||
`nix eval --raw '.#nixosConfigurations.terra.config.home-manager.users.darman.xdg.configFile."quickshell".source'`
|
||||
then `ls` that path against `ls -l ~/.config/quickshell`.
|
||||
|
||||
```sh
|
||||
qs # runs ~/.config/quickshell/shell.qml (this repo, since it's the symlinked default config)
|
||||
qs -p . # run this directory explicitly regardless of symlink
|
||||
qs -n # exit immediately if another instance is already running (use to avoid duplicate shells while iterating)
|
||||
nix develop # then: qs-dev — swap the running shell for the WORKING TREE, no rebuild
|
||||
qs # runs the packaged (store) config
|
||||
qs -p . # run this directory explicitly
|
||||
qs -n # exit immediately if another instance is already running
|
||||
qs kill # kill the default-config instance ('qs kill -p <path>' for a working-tree one)
|
||||
```
|
||||
|
||||
Quickshell hot-reloads QML on file save when already running, so for most edits just save and check the running instance rather than restarting `qs`. It watches file CONTENT: `touch` alone never reloads (mtime is not a change), while any real edit does, including inode-replacing ones (`sed -i`, `perl -i`). A save that is not picked up leaves the shell rendering the previous config with no error — `qs log` shows a `Reloading configuration...` line for every save it saw, so that is the check. There is no separate build/lint/test tooling in this repo — verification is visual/behavioral via the running shell. `qmlls` (QML language server) is configured via `.qmlls.ini` for editor diagnostics.
|
||||
`qs-dev` is the edit-save-see loop: it starts a working-tree instance, waits
|
||||
until it is confirmed up, and only then kills the packaged one, so a QML error
|
||||
leaves you on your normal bar instead of no bar. It is a SWAP rather than a
|
||||
second instance because quickshell keys instance identity on the config path —
|
||||
two instances would both map layer-shell bars onto every output. See the
|
||||
`nix develop` block in `flake.nix`.
|
||||
|
||||
Pointed at the working tree, quickshell hot-reloads on file save. It watches
|
||||
file CONTENT: `touch` alone never reloads (mtime is not a change), while any
|
||||
real edit does, including inode-replacing ones (`sed -i`, `perl -i`). A save
|
||||
that is not picked up leaves the shell rendering the previous config with no
|
||||
error — `qs log` shows a `Reloading configuration...` line for every save it
|
||||
saw, so that is the check.
|
||||
|
||||
A single component can also be run in isolation, which is the way to exercise
|
||||
something that owns a service or a surface without bringing up the whole rail:
|
||||
point `qs -p` at a scratch directory whose `shell.qml` instantiates only that
|
||||
component, with the repo's directories symlinked in for the `qs.` imports.
|
||||
|
||||
There is no build/lint/test tooling wired up in this repo, but two things are
|
||||
worth reaching for. `qmllint` (from qtdeclarative) catches syntax and binding
|
||||
errors without a compositor — expect noise from the synthesized `qs.*` modules
|
||||
and the `Theme` singleton, which it cannot resolve:
|
||||
|
||||
```sh
|
||||
qmllint -I <qtdeclarative>/lib/qt-6/qml -I <quickshell>/lib/qt-6/qml -I . <file>.qml
|
||||
```
|
||||
|
||||
And `tools/quickshell-preview/render.sh` renders an `Item`-rooted component to
|
||||
a PNG offscreen (see `tests/`). Keep production `PanelWindow` wrappers thin and
|
||||
put the visuals in an `Item` so they can go through that path. `qmlls` is
|
||||
configured via `.qmlls.ini` for editor diagnostics.
|
||||
|
||||
## Architecture
|
||||
|
||||
`shell.qml` is the entry point: a `Scope` that instantiates the top-level pieces — `Bar`, `BarBottom`, `BarTop`, and a hidden `Launcher` — as siblings. Each top-level widget manages its own `PanelWindow`(s); there's no central layout manager.
|
||||
`shell.qml` is a `Scope` instantiating the top-level pieces as siblings:
|
||||
`HyprChromeShell` (the status rail), the eleven launcher variants,
|
||||
`Notifications`, `VolumeOsd` and `Vitals`. Each manages its own
|
||||
`PanelWindow`(s); there is no central layout manager.
|
||||
|
||||
The one exception, and the pattern to follow for anything new that needs it, is
|
||||
`HyprChromeShell`: it owns the state its surfaces have to AGREE on rather than
|
||||
letting each decide — which monitor they live on, the rail's density, whether a
|
||||
polkit prompt is open, and the layer pair. A second reader is what makes a
|
||||
property shell state; the file's own header comment enumerates them and says
|
||||
why each qualifies. Layer levels in particular are derived TOGETHER, because
|
||||
two surfaces on one layer stack by creation order while one layer apart is a
|
||||
guarantee.
|
||||
|
||||
**Import convention**: QML modules are imported by their path under the repo root using the `qs.` namespace, e.g. `import qs.widgets.launcher`, `import qs.widgets.decoration`. Sibling files in the same directory are imported with a relative string import instead (e.g. `Bar.qml` does `import "modules"`).
|
||||
|
||||
**Multi-monitor**: Bar/BarTop/BarBottom each wrap their `PanelWindow` in `Variants { model: Quickshell.screens }`, so one window instance is created per connected screen. `pragma ComponentBehavior: Bound` + `required property var modelData` is the standard pattern for these per-screen delegates.
|
||||
**Multi-monitor**: a surface that must exist on every screen wraps its
|
||||
`PanelWindow` in `Variants { model: Quickshell.screens }`, one instance per
|
||||
connected screen; `pragma ComponentBehavior: Bound` + `required property var
|
||||
modelData` is the standard pattern for those delegates. A surface that belongs
|
||||
to ONE screen instead takes it as a property from the shell. Note
|
||||
`Quickshell.screens` is a QML list, not a JS array — no `.find()` or `.filter()`
|
||||
on it, hence the index loops in `HyprChromeShell`.
|
||||
|
||||
**Directory layout**:
|
||||
- `widgets/bar/` — `Bar.qml` is the main sidebar (right-anchored, full height) hosting the module stack (date, clock, tray, decorative dividers); `BarTop.qml`/`BarBottom.qml` are thin accent-colored strips anchored to the top/bottom edges.
|
||||
- `widgets/bar/modules/` — individual bar widgets (`Clock`, `Date`, `Tray`/`TrayItem`, `Volume`) built on the shared `BarWidget` base component.
|
||||
- `widgets/launcher/` — shared `AppModel` search/execution plus eight launcher variants. `ApplicationLauncher` (variant 8 and the primary `SUPER` launcher) keeps its visual core in the headlessly renderable `ApplicationLauncherContent`; variants 1–7 remain available on `SUPER CTRL 1–7` for comparison.
|
||||
- `HyprChrome/` — the current shell. `Widgets/HyprChromeShell.qml` is the owner
|
||||
described above; `Widgets/ChromeBackdrop.qml` is the scrim (dim + drafting
|
||||
grid) shared by the rail and the polkit prompt; `Widgets/Bar/` holds the rail
|
||||
and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all
|
||||
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
|
||||
`Widgets/Launcher/` is the primary application launcher (`SUPER_L`);
|
||||
`Widgets/Greeter/` is the greetd login screen, run standalone via `greeter.qml`
|
||||
(see `services/desktop/quickshell-greeter.nix`), NOT part of `shell.qml`;
|
||||
`Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a
|
||||
single widget on the secondary monitor for eyeballing it in isolation.
|
||||
|
||||
The prompt and the launcher are MODALS: each raises the shared scrim, lands
|
||||
on the focused monitor, and takes the keyboard off the rail. That is why the
|
||||
shell instantiates them rather than `shell.qml` — see `modalOpen` there, which
|
||||
is the one place a new modal has to be named.
|
||||
- `widgets/bar/` — `DenseBar` and `StatusBarPanel`, the rail's predecessor. Not
|
||||
instantiated by `shell.qml` any more; `StatusBarPanel` is still used by the
|
||||
remaining launcher variants.
|
||||
- `widgets/launcher/` — shared `AppModel` search/execution plus the ten launcher
|
||||
variants still under evaluation, on `SUPER CTRL 1–11`. Variant 8 has moved to
|
||||
`HyprChrome/Widgets/Launcher/`; `AppModel.qml` is duplicated there so the
|
||||
HyprChrome tree stands alone, and this copy goes when the variants do.
|
||||
- `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle.
|
||||
- `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`).
|
||||
- `widgets/layout/` — `HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space.
|
||||
- `assets/` — SVG icons referenced via `file://${Quickshell.shellDir}/assets/...`.
|
||||
|
||||
**Styling**: All colors and font families come from the `Theme` singleton
|
||||
(`widgets/theme/Theme.qml`, `import qs.widgets.theme`) — there are no color or
|
||||
font literals left anywhere under `widgets/`. Add a token there rather than
|
||||
**Styling**: All colors and font families come from a `Theme` singleton — there
|
||||
are no color or font literals left anywhere under `widgets/`. There are TWO,
|
||||
carrying the same palette for the two trees: `widgets/theme/Theme.qml`
|
||||
(`import qs.widgets.theme`) and `HyprChrome/Theme/Theme.qml`
|
||||
(`import qs.HyprChrome.Theme`). Match the one your file's tree already uses; a
|
||||
token added to one does not exist in the other. Add a token rather than
|
||||
hardcoding a value; alpha variants of the two main colors go through
|
||||
`Theme.textAlpha(a)` / `Theme.accentAlpha(a)` instead of a hand-written
|
||||
`Qt.rgba(...)`. Metrics (sizes, spacing) are still per-component.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.widgets.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Debug stage: a bare, chrome-less staging area in the middle of ONE monitor
|
||||
// (the secondary by default), used to look at a widget in isolation before it
|
||||
@@ -0,0 +1,182 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Polkit
|
||||
|
||||
// GUI password prompt for `sudo -A`, reusing the polkit dialog.
|
||||
//
|
||||
// sudo does NOT speak polkit — it is setuid + PAM reading your tty, and no
|
||||
// sudoers option bridges the two. What it does support is an ASKPASS helper: a
|
||||
// program it runs to obtain the password, which prints it on stdout. So this is
|
||||
// not the polkit agent serving sudo; it is a second, separate path that happens
|
||||
// to render the same dialog.
|
||||
//
|
||||
// Flow, driven by the helper in home.nix (`qs-askpass`):
|
||||
//
|
||||
// sudo -A
|
||||
// -> qs-askpass makes a 0600 fifo under XDG_RUNTIME_DIR
|
||||
// -> qs ipc call askpass prompt "<prompt>" "<fifo>" (returns at once)
|
||||
// -> this dialog opens, user types
|
||||
// -> a one-line writer is started here, secret written to its STDIN
|
||||
// -> qs-askpass reads the fifo and prints the secret on stdout
|
||||
// -> sudo reads it
|
||||
//
|
||||
// The secret travels on a pipe the whole way. It is never an argument and never
|
||||
// an environment variable, so it does not appear in /proc for any process — the
|
||||
// fifo PATH is in argv, which is not secret. It does cross more process
|
||||
// boundaries than the polkit path, where the password stays inside the PAM
|
||||
// conversation; that is the inherent cost of askpass, not of this design.
|
||||
//
|
||||
// Cancelling answers with an empty line, so the helper reads nothing, exits
|
||||
// non-zero, and sudo aborts rather than burning a retry on a blank password.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Which output to appear on; the shell puts it on the focused monitor.
|
||||
property var screen: null
|
||||
|
||||
// The fifo the helper is blocked reading. Non-empty means a request is in
|
||||
// flight, which is exactly what "a prompt is open" means here.
|
||||
property string fifoPath: ""
|
||||
property string promptText: ""
|
||||
property bool failed: false
|
||||
|
||||
readonly property bool active: root.fifoPath !== ""
|
||||
|
||||
// Held only between submit and the writer process actually starting: a
|
||||
// Process cannot be written to before it is running.
|
||||
property string pendingSecret: ""
|
||||
|
||||
IpcHandler {
|
||||
target: "askpass"
|
||||
|
||||
// Called by qs-askpass. Returns immediately — the helper blocks on the
|
||||
// fifo, not on this call, because an IpcHandler function runs on the
|
||||
// QML thread and blocking here would freeze the whole shell.
|
||||
function prompt(message: string, fifo: string): string {
|
||||
if (root.active)
|
||||
return "busy";
|
||||
|
||||
root.promptText = message === "" ? "Password:" : message;
|
||||
root.fifoPath = fifo;
|
||||
root.failed = false;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// So a helper that times out can take the dialog down with it rather
|
||||
// than leaving it on screen with nothing listening.
|
||||
function cancel(): string {
|
||||
root.dismiss();
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling answers with an EMPTY line rather than by closing silently:
|
||||
// the helper then reads zero bytes and exits non-zero, so sudo aborts
|
||||
// instead of spending a retry on a blank password.
|
||||
function dismiss() {
|
||||
root.respond("");
|
||||
}
|
||||
|
||||
function submit(secret) {
|
||||
root.respond(secret);
|
||||
}
|
||||
|
||||
// The writer reads ONE LINE and exits; it does not wait for EOF.
|
||||
//
|
||||
// The obvious version — `cat > fifo`, write the secret, then close stdin by
|
||||
// setting stdinEnabled false — does not terminate. Measured: the secret
|
||||
// arrives intact but `cat` never sees EOF, so the fifo is never closed and
|
||||
// the helper blocks until its timeout. sudo would hang after you typed.
|
||||
//
|
||||
// A single `read` needs no EOF at all: the trailing newline ends it, the
|
||||
// shell writes what it got and exits, and THAT close is what gives the
|
||||
// helper its EOF. `IFS=` keeps leading and trailing whitespace, `-r` keeps
|
||||
// backslashes, and the secret still travels on stdin rather than in argv.
|
||||
function respond(secret) {
|
||||
if (!root.active)
|
||||
return;
|
||||
|
||||
root.pendingSecret = secret + "\n";
|
||||
writer.command = ["sh", "-c", "IFS= read -r line; printf %s \"$line\" > \"$1\"", "sh", root.fifoPath];
|
||||
writer.running = true;
|
||||
root.fifoPath = "";
|
||||
}
|
||||
|
||||
// Opening a fifo for writing BLOCKS until a reader attaches, which is why
|
||||
// this is a subprocess rather than a FileView: the helper's `cat` is that
|
||||
// reader, and blocking the QML thread on it would freeze the shell.
|
||||
Process {
|
||||
id: writer
|
||||
|
||||
stdinEnabled: true
|
||||
|
||||
// Written on `started`, not at respond() time: a Process has no stdin
|
||||
// to write to until it is actually running.
|
||||
onStarted: {
|
||||
writer.write(root.pendingSecret);
|
||||
root.pendingSecret = "";
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
screen: root.screen
|
||||
visible: root.active
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: Theme.textAlpha(0)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
// No click-off dismissal, for the same reason the polkit dialog has
|
||||
// none: something is blocked waiting on the answer, and losing it to a
|
||||
// stray click would leave sudo hanging with no visible cause.
|
||||
|
||||
PolkitPromptContent {
|
||||
id: content
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: Math.max(32, Math.round(parent.height / 3 - height / 2))
|
||||
width: 520
|
||||
|
||||
// Deliberately the polkit dialog's own content component: this is a
|
||||
// password prompt with the same shape, and keeping one means a
|
||||
// restyle of PolkitPanel covers both. `identities` stays empty —
|
||||
// sudo offers no choice of who authenticates — which hides the
|
||||
// picker and the "AS" line on its own.
|
||||
message: "Authentication is required to run a command as another user"
|
||||
actionId: "sudo"
|
||||
iconName: ""
|
||||
showIcon: false
|
||||
identities: []
|
||||
|
||||
responseRequired: true
|
||||
inputPrompt: root.promptText
|
||||
responseVisible: false
|
||||
failed: root.failed
|
||||
|
||||
onSubmitted: value => root.submit(value)
|
||||
onCancelled: root.dismiss()
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (win.visible) {
|
||||
content.clearResponse();
|
||||
content.focusInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
import qs.hyprchrome.widgets.panels
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Panels
|
||||
|
||||
// Staging widget for the debug window: a BarPanel carrying filler copy in both
|
||||
// of the panel's densities — the full block when expanded, one elided line when
|
||||
@@ -0,0 +1,69 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Panels
|
||||
|
||||
// Staging slot for whatever is being worked on, sized to itself so it can be
|
||||
// dropped anywhere in the rail without a width. It counts seconds since the
|
||||
// shell loaded, which is the cheapest thing that proves the panel is live and
|
||||
// not a still frame — a reload visibly restarts it.
|
||||
//
|
||||
// It also stands ALONE between two spacers, so it is the pair of caps and
|
||||
// traces that a rail of butted panels never exercises: a right cap joining the
|
||||
// next panel's left cap across a gap, twice over.
|
||||
BarPanel {
|
||||
id: test
|
||||
|
||||
panelId: "TST"
|
||||
title: "TEST"
|
||||
meta: "STAGE"
|
||||
|
||||
property int seconds: 0
|
||||
|
||||
// Sized to its content, like the tray: a staging panel has no business
|
||||
// reserving a share of the rail.
|
||||
implicitWidth: Math.max(test.headerMinWidth,
|
||||
test.briefLeft + brief.implicitWidth + test.padding,
|
||||
test.padding * 2 + body.implicitWidth)
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: test.seconds++
|
||||
}
|
||||
|
||||
summary: Text {
|
||||
id: brief
|
||||
|
||||
text: "T+" + test.seconds
|
||||
color: Theme.accent
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: "T+" + test.seconds
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 17
|
||||
font.bold: true
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "SECONDS SINCE LOAD"
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 1.4
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -4,8 +4,8 @@ import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.hyprchrome.theme
|
||||
import qs.hyprchrome.widgets.panels
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Panels
|
||||
|
||||
// Host identity in the hyprchrome panel chrome: the machine's name set large,
|
||||
// with its timezone and the current date and time.
|
||||
@@ -0,0 +1,261 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Debug
|
||||
import qs.HyprChrome.Widgets.Bar.Host
|
||||
import qs.HyprChrome.Widgets.Bar.Vitals
|
||||
import qs.HyprChrome.Widgets.Bar.Workspaces
|
||||
import qs.HyprChrome.Widgets.Bar.Tray
|
||||
|
||||
// The dense status rail itself: one layer surface holding the panel row.
|
||||
//
|
||||
// It owns nothing shared — the screen, the density and its layer all arrive
|
||||
// from HyprChromeShell, which is also what keeps this surface and the backdrop
|
||||
// one layer apart. What it does own is its own measurement: `contentHeight` is
|
||||
// the settled height of the current density, which the shell hands to the
|
||||
// backdrop and which sizes the exclusive zone.
|
||||
PanelWindow {
|
||||
id: window
|
||||
|
||||
// Density for every panel on the rail; driven by the shell.
|
||||
property bool expanded: false
|
||||
|
||||
// Which layer to sit on. An int rather than a private decision: it is half
|
||||
// of a pair with the backdrop's, so the shell derives both. See
|
||||
// HyprChromeShell.
|
||||
property int wlrLayer: WlrLayer.Overlay
|
||||
|
||||
// Whether the rail should hold the keyboard, so ESC can close it. Driven by
|
||||
// the shell rather than derived from `expanded`, because the rail is not the
|
||||
// only thing that wants the keyboard: while a polkit prompt is up the shell
|
||||
// withholds this, so ESC reaches the DIALOG and dismisses that instead.
|
||||
// Once the prompt is gone the rail gets the keyboard back and a second ESC
|
||||
// closes the rail — one key, one thing at a time, innermost first.
|
||||
property bool grabsKeyboard: false
|
||||
|
||||
// EXCLUSIVE rather than OnDemand: OnDemand only offers focus to a surface
|
||||
// the user clicks, and the whole point here is to answer a keypress the
|
||||
// user has not aimed at anything. Taking the keyboard is defensible because
|
||||
// an expanded rail is already a modal-ish state — it dims the desktop
|
||||
// behind itself with the same scrim the prompt uses.
|
||||
WlrLayershell.keyboardFocus: window.grabsKeyboard
|
||||
? WlrKeyboardFocus.Exclusive
|
||||
: WlrKeyboardFocus.None
|
||||
|
||||
signal dismissed
|
||||
|
||||
// A layer surface only delivers keys to an item that has active focus, and
|
||||
// nothing in the rail wants focus for its own sake — the panels are
|
||||
// readouts. So one focus sink covers the whole surface. It re-takes focus
|
||||
// whenever the grab is handed back, since losing the surface's focus drops
|
||||
// the item's too.
|
||||
Item {
|
||||
id: keySink
|
||||
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
window.dismissed();
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: window
|
||||
function onGrabsKeyboardChanged() {
|
||||
if (window.grabsKeyboard)
|
||||
keySink.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Own namespace so a layerrule can exempt the rail from Hyprland's layer
|
||||
// animation without also catching the launchers, which share the default
|
||||
// "quickshell" namespace and do want their fade.
|
||||
WlrLayershell.namespace: "hyprchrome-bar"
|
||||
WlrLayershell.layer: window.wlrLayer
|
||||
|
||||
property int margin: 12
|
||||
|
||||
// The surface never resizes: it is always tall enough for the expanded
|
||||
// rail, and only the exclusive zone tracks the current state. Resizing a
|
||||
// layer surface makes Hyprland animate the change, which showed up as the
|
||||
// panels twitching a pixel or two the moment the collapse finished.
|
||||
//
|
||||
// The zone still follows the target height, so tiled windows reflow once
|
||||
// per toggle, at the start, and slide while the panels animate.
|
||||
readonly property real expandedContent: Math.max(hostPanel.expandedHeight, workspacesPanel.expandedHeight, vitalsPanel.expandedHeight, testPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2
|
||||
readonly property real contentHeight: Math.max(hostPanel.targetHeight, workspacesPanel.targetHeight, vitalsPanel.targetHeight, testPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2
|
||||
|
||||
implicitHeight: Math.round(window.expandedContent)
|
||||
|
||||
exclusionMode: ExclusionMode.Normal
|
||||
exclusiveZone: Math.round(window.contentHeight)
|
||||
|
||||
// Only the panels take input. Without this the surface would keep eating
|
||||
// clicks across its full height while the rail is collapsed.
|
||||
mask: Region {
|
||||
item: panelRow
|
||||
}
|
||||
|
||||
anchors { top: true; left: true; right: true; }
|
||||
|
||||
color: "transparent"
|
||||
|
||||
RowLayout {
|
||||
id: panelRow
|
||||
x: window.margin
|
||||
y: window.margin
|
||||
width: window.width - window.margin * 2
|
||||
spacing: 0
|
||||
|
||||
HostPanel {
|
||||
id: hostPanel
|
||||
|
||||
expanded: window.expanded
|
||||
// Workspaces butt against its right edge; the left end of the row is free.
|
||||
rightChamfer: false
|
||||
|
||||
toggleOnClick: false
|
||||
Layout.preferredWidth: 400
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
WorkspacesPanel {
|
||||
id: workspacesPanel
|
||||
|
||||
expanded: window.expanded
|
||||
// Mid-rail: a panel on either side, so neither corner is cut.
|
||||
leftChamfer: false
|
||||
rightChamfer: false
|
||||
|
||||
toggleOnClick: false
|
||||
// No preferred width: the panel sizes itself to however many outputs
|
||||
// the session has, the same way the tray sizes itself to its items.
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
VitalsPanel {
|
||||
id: vitalsPanel
|
||||
|
||||
expanded: window.expanded
|
||||
// Workspaces on the left, the spacer on the right — and a spacer is not
|
||||
// a panel, so that edge keeps its cut.
|
||||
leftChamfer: false
|
||||
|
||||
toggleOnClick: false
|
||||
Layout.preferredWidth: 500
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
// Slack on both sides of the staging panel, so it floats between the left
|
||||
// group and the tray rather than butting against either. Two spacers is
|
||||
// also what puts a gap on both of its sides, which is what gives it a cap
|
||||
// and a trace at each end.
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
TestPanel {
|
||||
id: testPanel
|
||||
|
||||
expanded: window.expanded
|
||||
toggleOnClick: false
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
TrayPanel {
|
||||
id: trayPanel
|
||||
|
||||
expanded: window.expanded
|
||||
toggleOnClick: false
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
|
||||
// Traces between the panels' caps. They live here rather than in BarPanel
|
||||
// because the run they draw is the gap BETWEEN two panels, which is the one
|
||||
// piece of this geometry no panel can see. Laid over the row, in the row's
|
||||
// own coordinates, so a panel's x is directly usable.
|
||||
Item {
|
||||
id: traces
|
||||
|
||||
x: panelRow.x
|
||||
y: panelRow.y
|
||||
width: panelRow.width
|
||||
height: panelRow.height
|
||||
|
||||
// Consecutive PANELS, with the spacers dropped — a spacer has no caps, so
|
||||
// it is not something a trace can start or end at, and skipping it is
|
||||
// exactly what makes the trace span it. `rightChamfer` is the tell: an
|
||||
// Item put in the row for slack has no such property.
|
||||
readonly property var pairs: {
|
||||
const panels = [];
|
||||
for (let i = 0; i < panelRow.children.length; i++) {
|
||||
const child = panelRow.children[i];
|
||||
if (child.rightChamfer !== undefined)
|
||||
panels.push(child);
|
||||
}
|
||||
|
||||
// A cap only exists on an open chamfer, so a pair that has both is
|
||||
// exactly a pair with something to join.
|
||||
const found = [];
|
||||
for (let i = 0; i + 1 < panels.length; i++) {
|
||||
if (panels[i].rightChamfer && panels[i + 1].leftChamfer)
|
||||
found.push({ from: panels[i], to: panels[i + 1] });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: traces.pairs
|
||||
|
||||
CapTrace {
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One run: out of a panel's top-right cap, along the top, one 45° step down
|
||||
// at the midpoint of the gap, then along the bottom into the next panel's
|
||||
// bottom-left cap. 45° means the step is as wide as it is tall, so the run
|
||||
// IS the drop — clamped if the gap is too narrow to fit it, which is the
|
||||
// only case where the angle gives.
|
||||
component CapTrace: Item {
|
||||
id: trace
|
||||
|
||||
required property var modelData
|
||||
|
||||
property int lineWidth: 3
|
||||
|
||||
readonly property real fromX: trace.modelData.from.x + trace.modelData.from.rightCapX
|
||||
readonly property real fromY: trace.modelData.from.y + trace.modelData.from.rightCapY
|
||||
readonly property real toX: trace.modelData.to.x + trace.modelData.to.leftCapX
|
||||
readonly property real toY: trace.modelData.to.y + trace.modelData.to.leftCapY
|
||||
|
||||
readonly property real drop: trace.toY - trace.fromY
|
||||
readonly property real gap: trace.toX - trace.fromX
|
||||
readonly property real step: Math.max(0, Math.min(Math.abs(trace.drop), trace.gap))
|
||||
readonly property real mid: (trace.fromX + trace.toX) / 2
|
||||
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.accent
|
||||
// Nothing to draw while the panels overlap, which they do for a frame
|
||||
// or two while the row is still laying itself out.
|
||||
strokeWidth: trace.gap > 0 ? trace.lineWidth : 0
|
||||
startX: trace.fromX; startY: trace.fromY
|
||||
PathLine { x: trace.mid - trace.step / 2; y: trace.fromY }
|
||||
PathLine { x: trace.mid + trace.step / 2; y: trace.toY }
|
||||
PathLine { x: trace.toX; y: trace.toY }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-16
@@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.hyprchrome.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Chamfered panel chrome for the dense status rail: outline, corner accent
|
||||
// lines, header strip (id chip / title / meta / tick marks) and a collapsing
|
||||
@@ -82,7 +82,7 @@ Item {
|
||||
// Where the summary sits when collapsed: just past the slug chip, and
|
||||
// centred in the strip the panel collapses to, which is sized to the
|
||||
// summary itself (or the height floor, whichever is taller).
|
||||
readonly property real briefLeft: panel.headerContentWidth
|
||||
readonly property real briefLeft: panel.headerContentWidth + 6
|
||||
readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2)
|
||||
readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2)
|
||||
|
||||
@@ -135,6 +135,36 @@ Item {
|
||||
property int outlineWidth: 1
|
||||
readonly property int connectorWidth: panel.outlineWidth + 2
|
||||
|
||||
// A cut corner has nothing butting against it, so it gets capped: the very
|
||||
// corner the chamfer removed, put back OUTSIDE the panel as a detached
|
||||
// accent triangle. Its hypotenuse faces the cut and its right angle points
|
||||
// away, so the cap and the notch read as two halves of one corner.
|
||||
//
|
||||
// Nothing guards these — a chamfer that is off measures zero, which
|
||||
// collapses its triangle to no area at all, so one piece of geometry covers
|
||||
// both cases.
|
||||
//
|
||||
// capGap is the perpendicular distance from the chamfer, which is why the
|
||||
// per-axis shift is it over root 2 rather than the gap itself: the cap
|
||||
// moves along the cut's normal, not along an axis.
|
||||
property real capGap: 4
|
||||
readonly property real capOffset: panel.capGap / Math.SQRT2
|
||||
|
||||
// Where a trace attaches: the MIDDLE of the cap's outward-facing edge — the
|
||||
// vertical one, since a trace arrives horizontally — rather than the tip,
|
||||
// so the line meets the triangle's face instead of clipping its corner.
|
||||
// That edge runs from the cut's end to the corner, so its midpoint is the
|
||||
// half-way point between them, carried out by the same offset as the cap.
|
||||
//
|
||||
// The RAIL draws those traces, between one panel's right cap and the next
|
||||
// panel's left cap, because the run between two panels is the one piece of
|
||||
// this that no panel can see. All a panel owes it is where its own caps
|
||||
// ended up.
|
||||
readonly property real rightCapX: panel.width + panel.capOffset
|
||||
readonly property real rightCapY: (panel.offsetY + panel.activeChamfer) / 2 - panel.capOffset
|
||||
readonly property real leftCapX: -panel.capOffset
|
||||
readonly property real leftCapY: panel.height - panel.activeChamfer / 2 + panel.capOffset
|
||||
|
||||
Shape {
|
||||
id: panelShape
|
||||
anchors.fill: parent
|
||||
@@ -154,22 +184,31 @@ Item {
|
||||
PathLine { x: 0; y: panel.offsetY }
|
||||
}
|
||||
|
||||
// Connecting edges — drawn only on a side whose chamfer is off, which is
|
||||
// exactly where a neighbour butts against this panel.
|
||||
// Cap on the top-right chamfer: the corner the cut removed, sitting
|
||||
// just outside it. The two ends of its hypotenuse are the same points
|
||||
// the outline turns on, shifted clear along the cut's normal; the third
|
||||
// is the corner itself, which the outline never reaches.
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.accent
|
||||
strokeWidth: panel.rightChamfer ? 0 : panel.connectorWidth
|
||||
startX: panelShape.width; startY: panel.offsetY
|
||||
PathLine { x: panelShape.width; y: panelShape.height }
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
startX: panelShape.width - panel.rightCut + panel.capOffset; startY: panel.offsetY - panel.capOffset
|
||||
PathLine {
|
||||
x: panelShape.width + panel.capOffset
|
||||
y: (panel.rightChamfer ? panel.activeChamfer : panel.offsetY) - panel.capOffset
|
||||
}
|
||||
PathLine { x: panelShape.width + panel.capOffset; y: panel.offsetY - panel.capOffset }
|
||||
PathLine { x: panelShape.width - panel.rightCut + panel.capOffset; y: panel.offsetY - panel.capOffset }
|
||||
}
|
||||
|
||||
// Cap on the bottom-left chamfer, the same triangle mirrored, clearing
|
||||
// the panel in the other direction.
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.accent
|
||||
strokeWidth: panel.leftChamfer ? 0 : panel.connectorWidth
|
||||
startX: 0; startY: panel.offsetY
|
||||
PathLine { x: 0; y: panelShape.height }
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
startX: panel.leftCut - panel.capOffset; startY: panelShape.height + panel.capOffset
|
||||
PathLine { x: -panel.capOffset; y: panelShape.height - panel.leftCut + panel.capOffset }
|
||||
PathLine { x: -panel.capOffset; y: panelShape.height + panel.capOffset }
|
||||
PathLine { x: panel.leftCut - panel.capOffset; y: panelShape.height + panel.capOffset }
|
||||
}
|
||||
|
||||
// Upper left accent line
|
||||
@@ -206,7 +245,7 @@ Item {
|
||||
y: Math.round((panel.headerHeight - height) / 2)
|
||||
// Puts the title where the accent line ends: chip + headerPadding on
|
||||
// both sides of it.
|
||||
spacing: panel.headerPadding
|
||||
spacing: panel.headerPadding + 6
|
||||
|
||||
// Header slug — the one piece that survives a collapse, so the strip
|
||||
// still says which panel it is.
|
||||
@@ -223,7 +262,7 @@ Item {
|
||||
text: panel.panelId
|
||||
color: Theme.surface
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.hyprchrome.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// One tray item as a chamfered cell. Declares `modelData` required so it can be
|
||||
// a Repeater delegate directly, without an Item wrapper in between.
|
||||
+2
-2
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
import qs.hyprchrome.widgets.panels
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Panels
|
||||
|
||||
// System tray in the hyprchrome panel chrome, in both densities: the same
|
||||
// items, drawn large enough to hit when expanded and shrunk onto the header
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Segmented horizontal meter: a row of cells lit up to `value`.
|
||||
//
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// One metric of the expanded vitals panel: label, meter, readout on a line.
|
||||
//
|
||||
+2
-2
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import qs.hyprchrome.theme
|
||||
import qs.hyprchrome.widgets.panels
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Panels
|
||||
import qs.widgets.vitals
|
||||
|
||||
// Host vitals in the hyprchrome panel chrome, in both of BarPanel's densities.
|
||||
@@ -0,0 +1,237 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
import qs.HyprChrome.Widgets.Bar.Panels
|
||||
|
||||
// Which workspace each monitor is currently showing, in the hyprchrome panel
|
||||
// chrome, in both of BarPanel's densities.
|
||||
//
|
||||
// One indicator in both: a box carrying the workspace's name, filled accent
|
||||
// while its monitor is showing it and outlined otherwise. Collapsed, each
|
||||
// output gets exactly one — the workspace it is on. Expanded, it gets the whole
|
||||
// strip it cycles through, at a larger cell, with the shown one filled.
|
||||
//
|
||||
// Expanded, the strips line up in a column: the output name sits in a
|
||||
// fixed-width cell, so the boxes start at the same x on every line regardless
|
||||
// of how long a connector name is.
|
||||
//
|
||||
// Both densities read the same two models, so they cannot disagree. Hyprland's
|
||||
// own distinction is kept: `active` is the workspace its monitor is showing
|
||||
// (one per output), `focused` is the single one taking input — so the fill
|
||||
// marks the shown workspace and the accent marker marks where the keyboard is.
|
||||
//
|
||||
// Display only: workspaces expose activate(), but BarPanel's density toggle
|
||||
// covers the whole panel, so a chip could not receive the click anyway.
|
||||
BarPanel {
|
||||
id: panel
|
||||
|
||||
panelId: "WKS"
|
||||
title: "WORKSPACES"
|
||||
meta: panel.monitorCount + (panel.monitorCount === 1 ? " OUTPUT" : " OUTPUTS")
|
||||
|
||||
readonly property int monitorCount: Hyprland.monitors.values.length
|
||||
|
||||
// Sizes itself horizontally, like the tray: how many outputs a session has
|
||||
// is not something the bar can hardcode.
|
||||
implicitWidth: Math.max(panel.headerMinWidth,
|
||||
panel.briefLeft + brief.implicitWidth + panel.padding,
|
||||
panel.padding * 2 + outputs.implicitWidth)
|
||||
|
||||
// Height of one expanded output line; every cell on it centres against this.
|
||||
readonly property int lineHeight: 26
|
||||
|
||||
// Width of the output-name cell, which is what makes the strips align.
|
||||
// Fixed rather than measured: a connector name is "DP-2" or "HDMI-A-1", and
|
||||
// the alternative is probing every name's rendered width to take a maximum,
|
||||
// which costs a hidden Text per output to save nothing. Anything longer
|
||||
// elides.
|
||||
readonly property int nameWidth: 64
|
||||
|
||||
// What to call a workspace. Hyprland numbers them, but a named workspace
|
||||
// carries its name instead and a scratchpad arrives as "special:<name>" —
|
||||
// the prefix is noise once it is sitting next to a monitor's name.
|
||||
function label(ws): string {
|
||||
if (!ws)
|
||||
return "--";
|
||||
const name = ws.name ?? "";
|
||||
if (name.startsWith("special:"))
|
||||
return name.slice(8).toUpperCase();
|
||||
return (name.length > 0 ? name : String(ws.id)).toUpperCase();
|
||||
}
|
||||
|
||||
// The ordinary workspaces on one output, lowest id first. Specials share
|
||||
// the same list under negative ids: they show up as the active workspace
|
||||
// when one is open, but never as a slot in the strip, which is meant to be
|
||||
// the fixed set the output cycles through.
|
||||
function slots(monitor): var {
|
||||
return Hyprland.workspaces.values
|
||||
.filter(ws => ws.monitor === monitor && ws.id > 0)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
// Collapsed: output name and the one box it is showing.
|
||||
summary: Row {
|
||||
id: brief
|
||||
|
||||
spacing: 14
|
||||
|
||||
Repeater {
|
||||
model: Hyprland.monitors
|
||||
|
||||
Row {
|
||||
id: briefOutput
|
||||
|
||||
required property HyprlandMonitor modelData
|
||||
|
||||
spacing: 8
|
||||
height: 18
|
||||
|
||||
// A Row aligns its children by their tops only, so the label
|
||||
// takes the box's height and centres its text in it.
|
||||
Text {
|
||||
text: briefOutput.modelData.name
|
||||
color: briefOutput.modelData.focused ? Theme.accent : Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 1.2
|
||||
height: parent.height
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
Chip {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
modelData: briefOutput.modelData.activeWorkspace
|
||||
// Filled by construction: this box IS the output's active
|
||||
// workspace, so it does not wait on the flag that says so.
|
||||
shown: true
|
||||
cell: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: panel.monitorCount === 0
|
||||
text: "NO OUTPUTS"
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 1.2
|
||||
height: 18
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded: one line per output, strips aligned.
|
||||
Column {
|
||||
id: outputs
|
||||
|
||||
spacing: 4
|
||||
|
||||
Repeater {
|
||||
model: Hyprland.monitors
|
||||
|
||||
Output {}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: panel.monitorCount === 0
|
||||
text: "NO OUTPUTS"
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 1.2
|
||||
height: panel.lineHeight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
// One output: focus marker, name, then its workspace strip. Every cell is
|
||||
// lineHeight tall and centres its own content, so the pieces sit on one
|
||||
// line and the same cell widths repeat down the column.
|
||||
component Output: Row {
|
||||
id: output
|
||||
|
||||
required property HyprlandMonitor modelData
|
||||
|
||||
spacing: 10
|
||||
|
||||
// Marker rather than a colored name: the focused output has to be
|
||||
// findable without reading anything.
|
||||
Rectangle {
|
||||
width: 3
|
||||
height: panel.lineHeight
|
||||
color: output.modelData.focused ? Theme.accent : Theme.hair
|
||||
}
|
||||
|
||||
Item {
|
||||
width: panel.nameWidth
|
||||
height: panel.lineHeight
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width
|
||||
text: output.modelData.name
|
||||
color: output.modelData.focused ? Theme.accent : Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1.4
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: strip.implicitWidth
|
||||
height: panel.lineHeight
|
||||
|
||||
Row {
|
||||
id: strip
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
Repeater {
|
||||
model: panel.slots(output.modelData)
|
||||
|
||||
Chip {
|
||||
cell: 22
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The indicator, at whatever size the density asks for: filled accent while
|
||||
// its output is showing that workspace, outlined otherwise.
|
||||
component Chip: Rectangle {
|
||||
id: chip
|
||||
|
||||
required property HyprlandWorkspace modelData
|
||||
|
||||
// Box height; the width grows with the label and the type scales with
|
||||
// the box, so one component covers both densities.
|
||||
property int cell: 16
|
||||
property bool shown: chip.modelData?.active ?? false
|
||||
|
||||
readonly property bool urgent: chip.modelData?.urgent ?? false
|
||||
|
||||
width: Math.max(chip.cell, chipText.implicitWidth + chip.cell / 2)
|
||||
height: chip.cell
|
||||
color: chip.shown ? Theme.accent : "transparent"
|
||||
border.width: 1
|
||||
border.color: chip.urgent ? Theme.hot : chip.shown ? Theme.accent : Theme.hair
|
||||
|
||||
Text {
|
||||
id: chipText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: panel.label(chip.modelData)
|
||||
color: chip.shown ? Theme.surface : chip.urgent ? Theme.hot : Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 11
|
||||
font.bold: chip.shown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid
|
||||
// over it. It is always on screen — only how far DOWN it reaches changes.
|
||||
// Expanded, it covers the whole output; collapsed, it shrinks to the band the
|
||||
// rail itself occupies, so the bar keeps its backing without the desktop being
|
||||
// dimmed. That collapsed band ends in a fade rather than a cut, so there is no
|
||||
// hard line across the wallpaper; expanded there is nothing to fade against —
|
||||
// the scrim runs to the bottom of the output.
|
||||
//
|
||||
// Its layer arrives from HyprChromeShell, which derives it together with the
|
||||
// bar's so the two stay exactly one level apart — see that file for why the
|
||||
// pair cannot be split. Collapsed that puts it on BACKGROUND, shared with the
|
||||
// wallpaper (hyprpaper), where order IS creation order: if the wallpaper is
|
||||
// restarted under a running shell it comes up on top and the collapsed band
|
||||
// goes with it. A `layerrule = order` in the Hyprland config is the fix if that
|
||||
// ever bites.
|
||||
//
|
||||
// It reserves nothing and takes no input: the mask is an empty Region, so
|
||||
// clicks land on whatever is underneath rather than on the scrim.
|
||||
PanelWindow {
|
||||
id: backdrop
|
||||
|
||||
property bool active: true
|
||||
|
||||
// Which layer to sit on. An int rather than a private decision: it is half
|
||||
// of a pair with the bar's, so the shell derives both.
|
||||
property int wlrLayer: WlrLayer.Top
|
||||
|
||||
property real dim: 0.65
|
||||
property int gridSpacing: 60
|
||||
|
||||
// Height of the collapsed rail, including its margins — the band the scrim
|
||||
// stays behind while the bar is collapsed. Driven by the host, which is the
|
||||
// only thing that knows what the panels currently measure.
|
||||
property real barHeight: 0
|
||||
|
||||
// Share of the COLLAPSED band spent fading to nothing at the bottom, as a
|
||||
// fraction rather than a pixel length so the tail scales with whatever the
|
||||
// rail currently measures. Expanded there is no fade at all: the scrim runs
|
||||
// to the bottom of the output, where the screen edge ends it.
|
||||
property real fade: 0.5
|
||||
readonly property real fadeAmount: Math.max(0, Math.min(0.95, backdrop.fade))
|
||||
|
||||
// The share actually in force. Animated rather than switched, so expanding
|
||||
// shrinks the tail away as the scrim grows instead of dropping a hard edge
|
||||
// onto the desktop the moment the state flips.
|
||||
property real fadeSpan: backdrop.active ? 0 : backdrop.fadeAmount
|
||||
// Position, in fractions of revealHeight, where the falloff starts. 1 while
|
||||
// expanded, i.e. no falloff.
|
||||
readonly property real fadeStart: 1 - backdrop.fadeSpan
|
||||
|
||||
// Collapsed, the SOLID part is the bar band and the tail hangs below it,
|
||||
// hence the division: barHeight is what must survive the fade, not what the
|
||||
// whole scrim measures. Sized off the static fadeAmount, not the animated
|
||||
// fadeSpan — the height and the ramp have to animate independently or each
|
||||
// would be chasing the other. Bound rather than readonly so the Behavior
|
||||
// below can animate the state change.
|
||||
property real revealHeight: backdrop.active
|
||||
? backdrop.height
|
||||
: Math.min(backdrop.height, backdrop.barHeight / (1 - backdrop.fadeAmount))
|
||||
|
||||
// Both matched to the bar's own collapse so the scrim and the panels
|
||||
// resolve together rather than one trailing the other.
|
||||
Behavior on revealHeight {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on fadeSpan {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// The dense bar drew this grid at 0.018 against its own near-black panel.
|
||||
// Over a 55% scrim on top of lit windows that is invisible, so it is a
|
||||
// knob rather than a constant.
|
||||
property real gridOpacity: 0.10
|
||||
|
||||
// The accent with its saturation pulled back: warm enough to read as part
|
||||
// of the palette, not so loud that a full-screen grid competes with the
|
||||
// bar. Derived rather than a literal so it tracks a palette change.
|
||||
// Registration crosses sit on every other intersection of the grid.
|
||||
property color crossColor: Theme.muted
|
||||
property real crossOpacity: 0.15
|
||||
property int crossSize: 20
|
||||
|
||||
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
|
||||
// widths can straddle a 1px rule symmetrically, so an even pixel count
|
||||
// would push every mark half a pixel off the grid it registers against.
|
||||
property int crossThickness: 2
|
||||
readonly property int crossWeight: Math.max(1, backdrop.crossThickness) * 2 - 1
|
||||
|
||||
property color gridColor: Qt.hsla(Theme.accent.hslHue,
|
||||
Theme.accent.hslSaturation * 0.45,
|
||||
Theme.accent.hslLightness,
|
||||
1)
|
||||
|
||||
// Fade targets keep the source RGB and drop only the alpha: interpolating
|
||||
// toward a plain "transparent" would run the gradient through black.
|
||||
readonly property color gridSolid: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
|
||||
backdrop.gridColor.b, backdrop.gridOpacity)
|
||||
readonly property color gridClear: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
|
||||
backdrop.gridColor.b, 0)
|
||||
readonly property color dimSolid: Qt.rgba(Theme.surface.r, Theme.surface.g,
|
||||
Theme.surface.b, backdrop.dim)
|
||||
readonly property color dimClear: Qt.rgba(Theme.surface.r, Theme.surface.g,
|
||||
Theme.surface.b, 0)
|
||||
|
||||
// What the gradients END on. With no fade the ramp has zero length, so its
|
||||
// start stop and its end stop sit on the SAME position — and Qt sorts stops
|
||||
// with an unstable sort, leaving which of the two wins undefined. It picked
|
||||
// the transparent one, which turned "no fade" into a ramp across the entire
|
||||
// scrim. Ending on the solid color instead makes the degenerate case
|
||||
// unambiguous: all three stops match and the fill is flat.
|
||||
readonly property color gridEnd: backdrop.fadeSpan > 0 ? backdrop.gridClear : backdrop.gridSolid
|
||||
readonly property color dimEnd: backdrop.fadeSpan > 0 ? backdrop.dimClear : backdrop.dimSolid
|
||||
|
||||
// Same ramp as the gradients above, for the marks that are placed at a
|
||||
// single y and so cannot carry a gradient of their own. Reads revealHeight
|
||||
// and fadeStart, so bindings that call it re-evaluate when either changes.
|
||||
function fadeAt(y: real): real {
|
||||
const start = backdrop.revealHeight * backdrop.fadeStart;
|
||||
if (y <= start)
|
||||
return 1;
|
||||
if (y >= backdrop.revealHeight)
|
||||
return 0;
|
||||
return 1 - (y - start) / (backdrop.revealHeight - start);
|
||||
}
|
||||
|
||||
visible: backdrop.revealHeight > 0
|
||||
|
||||
WlrLayershell.namespace: "hyprchrome-scrim"
|
||||
WlrLayershell.layer: backdrop.wlrLayer
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
readonly property int crossColumns: Math.ceil(backdrop.width / (backdrop.gridSpacing * 2)) + 1
|
||||
readonly property int crossRows: Math.ceil(backdrop.height / (backdrop.gridSpacing * 2)) + 1
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
mask: Region {}
|
||||
|
||||
Item {
|
||||
id: scrim
|
||||
|
||||
// Only as tall as the scrim currently reaches; everything inside is
|
||||
// laid out against this, so shrinking it scopes the whole drawing
|
||||
// rather than just clipping it.
|
||||
width: backdrop.width
|
||||
height: backdrop.revealHeight
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: backdrop.dimSolid }
|
||||
GradientStop { position: backdrop.fadeStart; color: backdrop.dimSolid }
|
||||
GradientStop { position: 1; color: backdrop.dimEnd }
|
||||
}
|
||||
}
|
||||
|
||||
// Faint drafting grid; no gradient and deliberately subordinate to
|
||||
// whatever is showing through it — except at the bottom, where it has to
|
||||
// fade with the scrim it sits on.
|
||||
Repeater {
|
||||
model: Math.ceil(scrim.width / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
x: index * backdrop.gridSpacing
|
||||
width: 1
|
||||
height: scrim.height
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: backdrop.gridSolid }
|
||||
GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid }
|
||||
GradientStop { position: 1; color: backdrop.gridEnd }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
// Modelled against the whole output, not the current reveal, so a
|
||||
// collapse fades the rules out where they stand instead of
|
||||
// restocking the Repeater on every animation frame.
|
||||
model: Math.ceil(backdrop.height / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
readonly property real line: index * backdrop.gridSpacing
|
||||
|
||||
y: line
|
||||
width: scrim.width
|
||||
height: 1
|
||||
color: backdrop.gridColor
|
||||
opacity: backdrop.gridOpacity * backdrop.fadeAt(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Register marks on every other line, so they land on a 2x grid rather
|
||||
// than on every crossing — sparse enough to read as drafting registration
|
||||
// rather than as texture.
|
||||
Repeater {
|
||||
model: backdrop.crossColumns * backdrop.crossRows
|
||||
|
||||
Item {
|
||||
required property int index
|
||||
|
||||
readonly property int column: index % backdrop.crossColumns
|
||||
readonly property int row: Math.floor(index / backdrop.crossColumns)
|
||||
|
||||
// Marks on every other rule in both directions, so they line up
|
||||
// in columns as well as rows. Both offsets are whole multiples of
|
||||
// gridSpacing, so every mark lands on a real intersection.
|
||||
//
|
||||
// Math.floor, not /2: the item offset and the bars inside it must
|
||||
// round the same way, or an even crossSize sits half a pixel off the
|
||||
// rule it marks.
|
||||
x: column * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||
width: backdrop.crossSize
|
||||
height: backdrop.crossSize
|
||||
// Sampled at the intersection the mark registers against, not at
|
||||
// its own top edge, so a cross fades as one piece.
|
||||
opacity: backdrop.crossOpacity * backdrop.fadeAt(row * backdrop.gridSpacing * 2)
|
||||
|
||||
Rectangle {
|
||||
// Placed with the same Math.floor the item's own offset uses.
|
||||
// anchors.verticalCenter halves the height unfloored, so an even
|
||||
// crossSize put the 1px bar half a pixel off the rule it marks.
|
||||
y: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
|
||||
width: parent.width
|
||||
height: backdrop.crossWeight
|
||||
color: backdrop.crossColor
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
|
||||
width: backdrop.crossWeight
|
||||
height: parent.height
|
||||
color: backdrop.crossColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Greetd
|
||||
import qs.HyprChrome.Widgets
|
||||
|
||||
// greetd greeter: backdrop on every output, login panel on the primary one.
|
||||
// Configured through env set by services/desktop/quickshell-greeter.nix.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
readonly property string primaryName: Quickshell.env("QS_GREETER_OUTPUT") ?? ""
|
||||
readonly property string defaultUser: Quickshell.env("QS_GREETER_USER") ?? ""
|
||||
readonly property string sessionCommand: Quickshell.env("QS_GREETER_SESSION") || "start-hyprland"
|
||||
readonly property string sessionName: Quickshell.env("QS_GREETER_SESSION_NAME") || "hyprland"
|
||||
readonly property string hostName: Quickshell.env("QS_GREETER_HOST") || "localhost"
|
||||
|
||||
readonly property var primaryScreen: {
|
||||
const screens = Quickshell.screens;
|
||||
if (screens.length === 0)
|
||||
return null;
|
||||
for (let i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === root.primaryName)
|
||||
return screens[i];
|
||||
}
|
||||
return screens[0];
|
||||
}
|
||||
|
||||
// Typed password, held until PAM's first secret prompt arrives.
|
||||
property string pendingSecret: ""
|
||||
// PAM asked a follow-up (OTP etc.) that the user must answer directly.
|
||||
property bool awaitingResponse: false
|
||||
property bool busy: false
|
||||
property bool launching: false
|
||||
|
||||
function setMessage(text, isError) {
|
||||
content.message = text;
|
||||
content.messageIsError = isError;
|
||||
}
|
||||
|
||||
function resetToPassword() {
|
||||
root.pendingSecret = "";
|
||||
root.awaitingResponse = false;
|
||||
root.busy = false;
|
||||
content.inputPrompt = "";
|
||||
content.responseVisible = false;
|
||||
content.clearSecret();
|
||||
content.focusSecret();
|
||||
}
|
||||
|
||||
function submit(user, secret) {
|
||||
if (!Greetd.available) {
|
||||
root.setMessage("greetd socket unavailable", true);
|
||||
return;
|
||||
}
|
||||
|
||||
content.failed = false;
|
||||
|
||||
if (root.awaitingResponse) {
|
||||
root.awaitingResponse = false;
|
||||
root.busy = true;
|
||||
content.clearSecret();
|
||||
Greetd.respond(secret);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Greetd.state !== GreetdState.Inactive)
|
||||
return;
|
||||
|
||||
root.setMessage("", false);
|
||||
root.pendingSecret = secret;
|
||||
root.busy = true;
|
||||
Greetd.createSession(user);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Greetd
|
||||
|
||||
function onAuthMessage(message, error, responseRequired, echoResponse) {
|
||||
if (!responseRequired) {
|
||||
root.setMessage(message, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// First hidden prompt is the password already typed.
|
||||
if (root.pendingSecret !== "" && !echoResponse) {
|
||||
const secret = root.pendingSecret;
|
||||
root.pendingSecret = "";
|
||||
Greetd.respond(secret);
|
||||
return;
|
||||
}
|
||||
|
||||
root.busy = false;
|
||||
root.awaitingResponse = true;
|
||||
content.inputPrompt = message.replace(/:\s*$/, "");
|
||||
content.responseVisible = echoResponse;
|
||||
content.clearSecret();
|
||||
content.focusSecret();
|
||||
}
|
||||
|
||||
function onAuthFailure(message) {
|
||||
root.resetToPassword();
|
||||
content.failed = true;
|
||||
root.setMessage(message || "authentication failed", true);
|
||||
}
|
||||
|
||||
function onError(error) {
|
||||
root.resetToPassword();
|
||||
root.setMessage(error, true);
|
||||
}
|
||||
|
||||
function onReadyToLaunch() {
|
||||
root.setMessage("starting " + root.sessionName, false);
|
||||
root.launching = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fade out first: greetd wants the greeter gone promptly after launch().
|
||||
Timer {
|
||||
running: root.launching
|
||||
interval: 220
|
||||
onTriggered: Greetd.launch([root.sessionCommand])
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
ChromeBackdrop {
|
||||
required property var modelData
|
||||
|
||||
screen: modelData
|
||||
active: true
|
||||
dim: 1
|
||||
wlrLayer: WlrLayer.Background
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
screen: root.primaryScreen
|
||||
visible: root.primaryScreen !== null
|
||||
|
||||
WlrLayershell.namespace: "hyprchrome-greeter"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
LoginContent {
|
||||
id: content
|
||||
|
||||
width: 640
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: Math.max(32, Math.round(parent.height * 0.42 - height / 2)) + (1 - opacity) * 12
|
||||
|
||||
opacity: root.launching ? 0 : 1
|
||||
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
|
||||
|
||||
hostName: root.hostName
|
||||
sessionName: root.sessionName
|
||||
user: root.defaultUser
|
||||
busy: root.busy || root.launching
|
||||
|
||||
onSubmitted: (user, secret) => root.submit(user, secret)
|
||||
|
||||
// A different user invalidates a half-finished conversation.
|
||||
onUserEdited: {
|
||||
if (Greetd.state !== GreetdState.Inactive)
|
||||
Greetd.cancelSession();
|
||||
root.resetToPassword();
|
||||
content.focusUser();
|
||||
}
|
||||
|
||||
onPowerRequested: action => Quickshell.execDetached(["systemctl", action])
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.defaultUser !== "")
|
||||
content.focusSecret();
|
||||
else
|
||||
content.focusUser();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
running: true
|
||||
repeat: true
|
||||
interval: 1000
|
||||
triggeredOnStart: true
|
||||
onTriggered: content.now = new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Headless visual core of the login greeter; Greeter.qml owns greetd.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string hostName: ""
|
||||
property string sessionName: ""
|
||||
property date now: new Date()
|
||||
|
||||
// Label over the secret field. Empty means the plain password step; set
|
||||
// when PAM asks for something else (OTP, PIN).
|
||||
property string inputPrompt: ""
|
||||
property bool responseVisible: false
|
||||
|
||||
property string message: ""
|
||||
property bool messageIsError: false
|
||||
property bool failed: false
|
||||
property bool busy: false
|
||||
|
||||
property alias user: userInput.text
|
||||
property alias response: secretInput.text
|
||||
|
||||
signal submitted(string user, string response)
|
||||
signal userEdited
|
||||
signal powerRequested(string action)
|
||||
|
||||
function focusSecret() { secretInput.forceActiveFocus(); }
|
||||
function focusUser() { userInput.forceActiveFocus(); }
|
||||
function clearSecret() { secretInput.text = ""; }
|
||||
|
||||
implicitWidth: 640
|
||||
implicitHeight: panel.implicitHeight
|
||||
|
||||
function submit() {
|
||||
if (root.busy)
|
||||
return;
|
||||
if (userInput.text.trim() === "") {
|
||||
root.focusUser();
|
||||
return;
|
||||
}
|
||||
root.submitted(userInput.text.trim(), secretInput.text);
|
||||
}
|
||||
|
||||
component MicroText: Text {
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 8
|
||||
font.letterSpacing: 0.9
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
component Field: Rectangle {
|
||||
id: field
|
||||
|
||||
property bool active: false
|
||||
property bool alert: false
|
||||
property string glyph: ""
|
||||
default property alias input: slot.data
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 36
|
||||
color: Theme.selection
|
||||
border.width: 1
|
||||
border.color: field.alert ? Theme.hot : field.active ? Theme.accentAlpha(0.7) : Theme.hair
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: 120 } }
|
||||
|
||||
// Focus tick on the left edge.
|
||||
Rectangle {
|
||||
width: 2
|
||||
height: parent.height
|
||||
color: Theme.accent
|
||||
visible: field.active
|
||||
}
|
||||
|
||||
Text {
|
||||
id: glyphText
|
||||
|
||||
x: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: field.glyph
|
||||
color: field.active ? Theme.accent : Theme.disabled
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Item {
|
||||
id: slot
|
||||
|
||||
anchors.left: glyphText.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
}
|
||||
|
||||
component PowerChip: Rectangle {
|
||||
id: chip
|
||||
|
||||
property string label: ""
|
||||
property string action: ""
|
||||
|
||||
width: chipLabel.implicitWidth + 16
|
||||
height: chipLabel.implicitHeight + 8
|
||||
color: chipMouse.containsMouse ? Theme.accent : "transparent"
|
||||
border.width: 1
|
||||
border.color: chipMouse.containsMouse ? Theme.accent : Theme.disabled
|
||||
|
||||
Text {
|
||||
id: chipLabel
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: chip.label
|
||||
color: chipMouse.containsMouse ? Theme.surface : Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: chipMouse
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.powerRequested(chip.action)
|
||||
}
|
||||
}
|
||||
|
||||
LoginPanel {
|
||||
id: panel
|
||||
|
||||
width: root.width
|
||||
panelId: "LGN"
|
||||
title: "SESSION // " + root.hostName.toUpperCase()
|
||||
meta: "GREETD"
|
||||
busy: root.busy
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
|
||||
// ---- clock column ----
|
||||
ColumnLayout {
|
||||
Layout.preferredWidth: 190
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: Qt.formatTime(root.now, "HH:mm")
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 54
|
||||
font.letterSpacing: 2
|
||||
}
|
||||
|
||||
MicroText {
|
||||
text: Qt.formatDate(root.now, "ddd dd.MM.yyyy").toUpperCase()
|
||||
color: Theme.accent
|
||||
font.pixelSize: 10
|
||||
}
|
||||
|
||||
// Seconds as a segment meter: one cell per five seconds.
|
||||
Row {
|
||||
Layout.topMargin: 10
|
||||
spacing: 3
|
||||
|
||||
Repeater {
|
||||
model: 12
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
width: 11
|
||||
height: 5
|
||||
color: index < Math.floor(root.now.getSeconds() / 5) + 1
|
||||
? Theme.accent : Theme.raised
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MicroText {
|
||||
Layout.topMargin: 10
|
||||
text: "SESSION " + root.sessionName.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: 1
|
||||
color: Theme.textAlpha(0.12)
|
||||
}
|
||||
|
||||
// ---- credentials ----
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: 6
|
||||
|
||||
MicroText { text: "OPERATOR" }
|
||||
|
||||
Field {
|
||||
glyph: "@"
|
||||
active: userInput.activeFocus
|
||||
|
||||
TextInput {
|
||||
id: userInput
|
||||
|
||||
anchors.fill: parent
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
enabled: !root.busy && root.inputPrompt === ""
|
||||
color: Theme.text
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.surface
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 14
|
||||
font.letterSpacing: 1
|
||||
clip: true
|
||||
|
||||
onTextEdited: root.userEdited()
|
||||
onAccepted: root.focusSecret()
|
||||
KeyNavigation.tab: secretInput
|
||||
}
|
||||
}
|
||||
|
||||
MicroText {
|
||||
Layout.topMargin: 6
|
||||
text: root.inputPrompt !== "" ? root.inputPrompt.toUpperCase() : "PASSPHRASE"
|
||||
color: root.inputPrompt !== "" ? Theme.accent : Theme.muted
|
||||
}
|
||||
|
||||
Field {
|
||||
glyph: ">_"
|
||||
active: secretInput.activeFocus
|
||||
alert: root.failed
|
||||
|
||||
TextInput {
|
||||
id: secretInput
|
||||
|
||||
anchors.fill: parent
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
enabled: !root.busy
|
||||
color: Theme.text
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.surface
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 14
|
||||
font.letterSpacing: 1
|
||||
clip: true
|
||||
|
||||
echoMode: root.responseVisible ? TextInput.Normal : TextInput.Password
|
||||
passwordCharacter: "▪"
|
||||
passwordMaskDelay: 0
|
||||
|
||||
onAccepted: root.submit()
|
||||
KeyNavigation.backtab: userInput
|
||||
}
|
||||
}
|
||||
|
||||
// Status line; keeps its height so the panel does not jump.
|
||||
MicroText {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
Layout.preferredHeight: 12
|
||||
text: root.busy && root.message === "" ? "AUTHENTICATING…" : root.message.toUpperCase()
|
||||
color: root.messageIsError ? Theme.hot : root.busy ? Theme.accent : Theme.muted
|
||||
font.pixelSize: 9
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- footer ----
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 1
|
||||
color: Theme.textAlpha(0.12)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 14
|
||||
|
||||
MicroText { text: "ENTER LOG IN" }
|
||||
MicroText { text: "TAB SWITCH FIELD" }
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
PowerChip { label: "REBOOT"; action: "reboot" }
|
||||
PowerChip { label: "POWER OFF"; action: "poweroff" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Panel chrome for the login greeter. Same vocabulary as PolkitPanel, mirrored:
|
||||
// cuts on the OTHER diagonal (top-left, bottom-right), registration brackets
|
||||
// instead of detached caps, and a segmented accent spine down the left edge.
|
||||
Item {
|
||||
id: panel
|
||||
|
||||
property string panelId: ""
|
||||
property string title: ""
|
||||
property string meta: ""
|
||||
|
||||
property int chamfer: 20
|
||||
property int padding: 18
|
||||
property int spineWidth: 3
|
||||
property int spineSegments: 9
|
||||
|
||||
// Sweeps the bottom rule while greetd is working.
|
||||
property bool busy: false
|
||||
|
||||
readonly property int headerHeight: 32
|
||||
readonly property int headerPadding: 12
|
||||
|
||||
property real bracketGap: 6
|
||||
property real bracketArm: 14
|
||||
|
||||
readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1))
|
||||
|
||||
default property alias content: body.data
|
||||
|
||||
implicitHeight: Math.round(body.y + body.height + panel.padding)
|
||||
|
||||
Shape {
|
||||
id: panelShape
|
||||
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
fillColor: Theme.surface
|
||||
strokeColor: Theme.hair
|
||||
strokeWidth: 1
|
||||
|
||||
startX: panel.activeChamfer; startY: 0
|
||||
PathLine { x: panelShape.width; y: 0 }
|
||||
PathLine { x: panelShape.width; y: panelShape.height - panel.activeChamfer }
|
||||
PathLine { x: panelShape.width - panel.activeChamfer; y: panelShape.height }
|
||||
PathLine { x: 0; y: panelShape.height }
|
||||
PathLine { x: 0; y: panel.activeChamfer }
|
||||
PathLine { x: panel.activeChamfer; y: 0 }
|
||||
}
|
||||
|
||||
// Accent along each cut, inset so it reads as an edge highlight.
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.accent
|
||||
strokeWidth: 2
|
||||
capStyle: ShapePath.FlatCap
|
||||
|
||||
startX: 0; startY: panel.activeChamfer
|
||||
PathLine { x: panel.activeChamfer; y: 0 }
|
||||
}
|
||||
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.accent
|
||||
strokeWidth: 2
|
||||
capStyle: ShapePath.FlatCap
|
||||
|
||||
startX: panelShape.width; startY: panelShape.height - panel.activeChamfer
|
||||
PathLine { x: panelShape.width - panel.activeChamfer; y: panelShape.height }
|
||||
}
|
||||
|
||||
// Registration brackets on the two square corners, echoing the
|
||||
// backdrop's crosses.
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.muted
|
||||
strokeWidth: 1
|
||||
|
||||
startX: panelShape.width + panel.bracketGap - panel.bracketArm
|
||||
startY: -panel.bracketGap
|
||||
PathLine { x: panelShape.width + panel.bracketGap; y: -panel.bracketGap }
|
||||
PathLine { x: panelShape.width + panel.bracketGap; y: -panel.bracketGap + panel.bracketArm }
|
||||
}
|
||||
|
||||
ShapePath {
|
||||
fillColor: "transparent"
|
||||
strokeColor: Theme.muted
|
||||
strokeWidth: 1
|
||||
|
||||
startX: -panel.bracketGap
|
||||
startY: panelShape.height + panel.bracketGap - panel.bracketArm
|
||||
PathLine { x: -panel.bracketGap; y: panelShape.height + panel.bracketGap }
|
||||
PathLine { x: -panel.bracketGap + panel.bracketArm; y: panelShape.height + panel.bracketGap }
|
||||
}
|
||||
}
|
||||
|
||||
// Segmented spine, below the header.
|
||||
Column {
|
||||
x: 0
|
||||
y: panel.headerHeight + 8
|
||||
spacing: 3
|
||||
|
||||
readonly property real segment: (panel.height - panel.headerHeight - 8 - panel.activeChamfer - 8
|
||||
- (panel.spineSegments - 1) * spacing) / panel.spineSegments
|
||||
|
||||
Repeater {
|
||||
model: panel.spineSegments
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
width: panel.spineWidth
|
||||
height: Math.max(1, parent.segment)
|
||||
color: Theme.accent
|
||||
// Fades downward.
|
||||
opacity: 1 - index / panel.spineSegments * 0.85
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header: title, meta, slug chip on the right.
|
||||
Text {
|
||||
x: panel.activeChamfer + panel.headerPadding
|
||||
anchors.verticalCenter: header.verticalCenter
|
||||
text: panel.title
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.4
|
||||
}
|
||||
|
||||
Row {
|
||||
id: header
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: panel.headerPadding
|
||||
y: Math.round((panel.headerHeight - height) / 2)
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: slugChip.verticalCenter
|
||||
visible: panel.meta.length > 0
|
||||
text: panel.meta
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 8
|
||||
font.letterSpacing: 0.7
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: slugChip
|
||||
|
||||
width: slugText.implicitWidth + 10
|
||||
height: slugText.implicitHeight + 4
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.accent
|
||||
|
||||
Text {
|
||||
id: slugText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: panel.panelId
|
||||
color: Theme.accent
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header rule: dashed, not solid, to set it apart from the polkit dialog.
|
||||
Row {
|
||||
x: panel.activeChamfer
|
||||
y: panel.headerHeight
|
||||
spacing: 3
|
||||
|
||||
Repeater {
|
||||
model: Math.max(0, Math.floor((panel.width - panel.activeChamfer - 1) / 9))
|
||||
|
||||
Rectangle {
|
||||
width: 6
|
||||
height: 1
|
||||
color: Theme.textAlpha(0.18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Busy sweep along the bottom edge.
|
||||
Item {
|
||||
x: 0
|
||||
y: panel.height - 2
|
||||
width: panel.width - panel.activeChamfer
|
||||
height: 2
|
||||
clip: true
|
||||
visible: panel.busy
|
||||
|
||||
Rectangle {
|
||||
id: sweep
|
||||
|
||||
width: parent.width / 4
|
||||
height: parent.height
|
||||
color: Theme.accent
|
||||
|
||||
NumberAnimation on x {
|
||||
running: panel.busy
|
||||
loops: Animation.Infinite
|
||||
from: -sweep.width
|
||||
to: panel.width
|
||||
duration: 900
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: body
|
||||
|
||||
x: panel.padding + panel.spineWidth
|
||||
y: panel.headerHeight + panel.padding
|
||||
width: Math.max(0, panel.width - panel.padding * 2 - panel.spineWidth)
|
||||
height: childrenRect.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import qs.HyprChrome.Widgets.Bar
|
||||
import qs.HyprChrome.Widgets
|
||||
import qs.HyprChrome.Widgets.Polkit
|
||||
import qs.HyprChrome.Widgets.Launcher
|
||||
import qs.HyprChrome.Widgets.Askpass
|
||||
|
||||
// The hyprchrome shell: owns everything the rail's surfaces have to agree on,
|
||||
// and instantiates them.
|
||||
//
|
||||
// State lives here rather than in any one surface because more than one of them
|
||||
// reads it, and a second reader is what turns a local property into shared
|
||||
// state. Four things qualify so far:
|
||||
//
|
||||
// * which monitor the shell lives on — every surface has to pick the same one
|
||||
// * the density — the whole rail expands and collapses as one, so the toggle
|
||||
// and the shortcut that drives it belong to the shell, not to the bar
|
||||
// * whether a MODAL is open — the polkit prompt, the launcher, or the sudo
|
||||
// askpass dialog. Each raises the same scrim the rail uses, freezes the
|
||||
// density, lands on the focused monitor and takes the keyboard off the
|
||||
// rail, so several surfaces read it. That is why they live here rather than
|
||||
// as siblings of the remaining launcher variants in shell.qml
|
||||
// * the layer PAIR — the backdrop must sit exactly one layer below the bar in
|
||||
// both densities. Two surfaces on the same layer stack by creation order,
|
||||
// which is not something to rely on; one layer apart is a guarantee. Split
|
||||
// across two files those two assignments drifted apart and the scrim ended
|
||||
// up over the bar, so they are derived together here and passed down.
|
||||
//
|
||||
// A future widget joins by taking `targetScreen` and `expanded` the same way.
|
||||
Scope {
|
||||
id: shell
|
||||
|
||||
// Monitor the rail lives on. Falls back to the FIRST connected screen when
|
||||
// the name matches nothing, so the bar still appears on a single-monitor
|
||||
// session or after a cable swap (DebugWindow falls back to the last one
|
||||
// instead — it wants the secondary).
|
||||
property string screenName: "DP-2"
|
||||
|
||||
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
|
||||
readonly property var targetScreen: {
|
||||
const screens = Quickshell.screens;
|
||||
if (screens.length === 0)
|
||||
return null;
|
||||
for (let i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === shell.screenName)
|
||||
return screens[i];
|
||||
}
|
||||
return screens[0];
|
||||
}
|
||||
|
||||
// Every output the rail does NOT live on. They get a scrim of their own
|
||||
// while a prompt is up, because a modal that dims one monitor and leaves
|
||||
// the others lit does not read as modal at all — and the rail's backdrop
|
||||
// covers exactly one output.
|
||||
readonly property var otherScreens: {
|
||||
const out = [];
|
||||
const screens = Quickshell.screens;
|
||||
for (let i = 0; i < screens.length; i++) {
|
||||
if (screens[i] !== shell.targetScreen)
|
||||
out.push(screens[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Where a prompt appears: wherever the user is actually looking, which is
|
||||
// not necessarily where the rail lives. Hyprland reports the focused
|
||||
// monitor by name, and Quickshell.screens is keyed the same way, so the
|
||||
// two are matched by name exactly as targetScreen is above.
|
||||
//
|
||||
// Falls back to the rail's own screen rather than to nothing: a prompt that
|
||||
// fails to place itself would leave its caller blocked on a dialog nobody
|
||||
// can see.
|
||||
readonly property var focusedScreen: {
|
||||
const focused = Hyprland.focusedMonitor;
|
||||
if (!focused)
|
||||
return shell.targetScreen;
|
||||
|
||||
const screens = Quickshell.screens;
|
||||
for (let i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === focused.name)
|
||||
return screens[i];
|
||||
}
|
||||
return shell.targetScreen;
|
||||
}
|
||||
|
||||
// Density is a property of the SHELL: every panel follows it, so the whole
|
||||
// rail expands and collapses as one. Panels keep their own animation; only
|
||||
// the decision is centralised here.
|
||||
//
|
||||
// A prompt does NOT change it: an authorization request leaves the rail at
|
||||
// whatever density it was, and only freezes it there.
|
||||
property bool expanded: false
|
||||
|
||||
function toggle() {
|
||||
// Frozen while a prompt is up, and dropped rather than queued: SUPER A
|
||||
// during a prompt does nothing at all, instead of arming a change that
|
||||
// springs the rail open or shut the moment the dialog goes.
|
||||
if (shell.modalOpen)
|
||||
return;
|
||||
|
||||
shell.expanded = !shell.expanded;
|
||||
}
|
||||
|
||||
// The surfaces that take over the screen: they dim EVERY output, land on the
|
||||
// focused one, and take the keyboard off the rail. Grouped because
|
||||
// everything below treats them alike, so a fourth one joins by being named
|
||||
// here and nowhere else.
|
||||
readonly property bool modalOpen: polkit.prompting || launcher.active || askpass.active
|
||||
|
||||
// Whether the scrim is up, from ANY cause. This is the fact the surfaces
|
||||
// actually share — the rail's density is only one of the things that can
|
||||
// raise it — so the backdrop and the layer pair below key off this rather
|
||||
// than off `expanded`.
|
||||
//
|
||||
// One backdrop instance serves all of them. A modal opening over an already
|
||||
// expanded rail therefore changes nothing about the scrim on that monitor:
|
||||
// it is already up, already full height, and the modal simply appears above
|
||||
// it. Over a COLLAPSED rail the same scrim expands from its bar-height band
|
||||
// to the whole output, using the animation it already has, and the rail
|
||||
// stays collapsed throughout.
|
||||
readonly property bool scrimUp: shell.expanded || shell.modalOpen
|
||||
|
||||
// Scrim up, the rail is over everything; scrim down, it drops below ordinary
|
||||
// windows. BOTTOM rather than BACKGROUND for the lowered bar: it is the
|
||||
// lowest level that still leaves a layer underneath for the backdrop, and
|
||||
// it keeps the rail off the wallpaper's own level.
|
||||
//
|
||||
// Both key off `scrimUp`, not `expanded`, so the pair stays exactly one
|
||||
// level apart in every state — which is the whole point of deriving them
|
||||
// together. A prompt over a collapsed rail raises BOTH: the scrim has to
|
||||
// clear ordinary windows to dim them at all (BACKGROUND sits under them),
|
||||
// and the bar has to stay one above the scrim or the shell would be dimming
|
||||
// its own chrome. The rail is raised but still collapsed: its layer answers
|
||||
// to the scrim, its height to `expanded`.
|
||||
readonly property int barLayer: shell.scrimUp ? WlrLayer.Overlay : WlrLayer.Bottom
|
||||
readonly property int backdropLayer: shell.scrimUp ? WlrLayer.Top : WlrLayer.Background
|
||||
|
||||
// SUPER A — see hosts/terra/home/hyprland.nix.
|
||||
GlobalShortcut {
|
||||
name: "chrome"
|
||||
description: "Expand or collapse the hyprchrome bar"
|
||||
onPressed: shell.toggle()
|
||||
}
|
||||
|
||||
// Backdrop first: it is a layer below the bar, so stacking does not depend
|
||||
// on creation order, but keeping the declaration order the same as the
|
||||
// visual order costs nothing.
|
||||
ChromeBackdrop {
|
||||
screen: shell.targetScreen
|
||||
active: shell.scrimUp
|
||||
wlrLayer: shell.backdropLayer
|
||||
|
||||
// Collapsed, the scrim only backs the rail, so it needs the band the
|
||||
// rail occupies. contentHeight is the SETTLED height for the current
|
||||
// state — it jumps once per toggle rather than tracking the panels
|
||||
// frame by frame, so the backdrop animates the change itself instead of
|
||||
// chasing a value that is already being animated.
|
||||
barHeight: bar.contentHeight
|
||||
}
|
||||
|
||||
// The same scrim on every other output, up only while a prompt is. These
|
||||
// have no rail to back, so barHeight stays 0 and revealHeight falls to
|
||||
// nothing between prompts — the surfaces take themselves off screen rather
|
||||
// than lingering as a strip the way the rail's does.
|
||||
//
|
||||
// Deliberately NOT tied to `scrimUp`: expanding the rail dims the rail's
|
||||
// monitor only, which is the existing behaviour and the right one — the
|
||||
// rail is a thing on one screen. A prompt is the only event that concerns
|
||||
// every screen at once.
|
||||
//
|
||||
// TOP unconditionally: there is no bar on these outputs to keep one level
|
||||
// above the scrim, and BACKGROUND would put the dim under ordinary windows
|
||||
// where it would dim nothing. Inactive they are invisible, so the level
|
||||
// costs nothing between prompts.
|
||||
Variants {
|
||||
model: shell.otherScreens
|
||||
|
||||
ChromeBackdrop {
|
||||
required property var modelData
|
||||
|
||||
screen: modelData
|
||||
active: shell.modalOpen
|
||||
wlrLayer: WlrLayer.Top
|
||||
barHeight: 0
|
||||
}
|
||||
}
|
||||
|
||||
HyprChromeBar {
|
||||
id: bar
|
||||
|
||||
screen: shell.targetScreen
|
||||
// Set here, not from the window's own `screen`: reading that inside
|
||||
// `visible` is circular — a hidden window has no screen to report.
|
||||
visible: shell.targetScreen !== null
|
||||
expanded: shell.expanded
|
||||
wlrLayer: shell.barLayer
|
||||
|
||||
// ESC closes the rail — but only when it is the innermost thing open.
|
||||
// While a prompt is up the rail gives up the keyboard so ESC dismisses
|
||||
// the DIALOG; the prompt closing hands it back, and the next ESC closes
|
||||
// the rail. Withheld rather than left to the compositor to arbitrate
|
||||
// between two exclusive surfaces, which would decide by stacking and
|
||||
// silently swap the order the day the layers change.
|
||||
grabsKeyboard: shell.expanded && !shell.modalOpen
|
||||
|
||||
onDismissed: shell.expanded = false
|
||||
}
|
||||
|
||||
// Polkit authentication agent. It registers for this logind session on
|
||||
// creation, so it replaces hyprpolkitagent rather than coexisting with it —
|
||||
// only one agent may hold a session (see hosts/terra/home/hyprland.nix).
|
||||
//
|
||||
// It lives here rather than beside the launchers in shell.qml because its
|
||||
// state is shared: `prompting` raises the scrim and freezes the density,
|
||||
// which makes it shell state by the same rule as the screen and the layer
|
||||
// pair. It owns only its dialog; the scrim above is the rail's.
|
||||
//
|
||||
// Declared LAST on purpose. While a prompt is up the bar is on Overlay too,
|
||||
// and there is no layer above Overlay to escape to, so the dialog stays on
|
||||
// top by being the later surface. In practice it is later regardless — its
|
||||
// window only exists while a request is open, so it is always created after
|
||||
// the bar's — but the declaration order says so without relying on that.
|
||||
PolkitPrompt {
|
||||
id: polkit
|
||||
|
||||
screen: shell.focusedScreen
|
||||
}
|
||||
|
||||
// Primary application launcher — SUPER_L. Migrated out of
|
||||
// widgets/launcher/; the ten remaining variants are still evaluation copies
|
||||
// and stay in shell.qml. Declared after the bar for the same reason the
|
||||
// prompt is: while it is open the bar is on Overlay too, and there is no
|
||||
// layer above Overlay to escape to.
|
||||
AppLauncher {
|
||||
id: launcher
|
||||
|
||||
screen: shell.focusedScreen
|
||||
}
|
||||
|
||||
// GUI password prompt for `sudo -A`. Not the polkit agent — sudo cannot use
|
||||
// one — but it renders the same dialog. See the file for the flow.
|
||||
AskpassPrompt {
|
||||
id: askpass
|
||||
|
||||
screen: shell.focusedScreen
|
||||
}
|
||||
}
|
||||
+39
-15
@@ -4,18 +4,41 @@ import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.widgets.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Primary application launcher (variant 8). The full-screen layer-shell adapter
|
||||
// owns focus, DesktopEntries and execution; ApplicationLauncherContent remains
|
||||
// an Item so the complete visual state can be rendered headlessly.
|
||||
// Primary application launcher — the one on SUPER_L.
|
||||
//
|
||||
// Migrated from widgets/launcher/ApplicationLauncher.qml. Two things changed in
|
||||
// the move, both because HyprChromeShell now owns the state its surfaces share:
|
||||
//
|
||||
// * no scrim of its own. The shell raises the single ChromeBackdrop for any
|
||||
// of its causes — expanded rail, polkit prompt, this — so opening the
|
||||
// launcher over an already-expanded rail reuses the scrim that is there
|
||||
// rather than laying a second dim on top of it.
|
||||
// * `active` is read by the shell, which uses it to raise that scrim, to
|
||||
// place this on the focused monitor, and to decide who gets the keyboard.
|
||||
//
|
||||
// The full-screen layer-shell adapter owns focus, DesktopEntries and execution;
|
||||
// AppLauncherContent stays an Item so the whole visual state can be rendered
|
||||
// headlessly (tests/AppLauncherHeadless.qml).
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
property bool active: false
|
||||
|
||||
function toggle() { root.active = !root.active; }
|
||||
// Which output to appear on. Driven by the shell, which puts it on the
|
||||
// focused monitor — a launcher belongs where the user is looking, which is
|
||||
// not necessarily where the rail lives.
|
||||
property var screen: null
|
||||
|
||||
function toggle() { root.active = !root.active; }
|
||||
function close() { root.active = false; }
|
||||
|
||||
// The name is legacy: this was "variant 8" of eleven, and both SUPER_L (via
|
||||
// open_launcher.sh) and SUPER CTRL 8 still dispatch quickshell:launcher8.
|
||||
// Renaming it means editing hosts/terra/home/hyprland.nix AND the script
|
||||
// together, and neither takes effect until a deploy — so the shortcut would
|
||||
// be dead in the running session in between. Kept as-is deliberately.
|
||||
GlobalShortcut {
|
||||
name: "launcher8"
|
||||
description: "Toggle dense application command index"
|
||||
@@ -25,7 +48,9 @@ Scope {
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
screen: root.screen
|
||||
visible: root.active
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
@@ -71,19 +96,18 @@ Scope {
|
||||
search: content.query
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// Click-off dismissal. The scrim itself belongs to the shell and takes
|
||||
// no input (its mask is empty), so the catcher lives here: a
|
||||
// transparent full-surface MouseArea UNDER the content, which is what
|
||||
// keeps clicks on the launcher itself from closing it.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
color: Theme.surface
|
||||
opacity: 0.72
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.active = false
|
||||
}
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
ApplicationLauncherContent {
|
||||
AppLauncherContent {
|
||||
id: content
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: 1080
|
||||
height: 620
|
||||
@@ -95,7 +119,7 @@ Scope {
|
||||
onSelectionRequested: index => win.selectedIndex = win.clampSelection(index)
|
||||
onMoveRequested: delta => win.move(delta)
|
||||
onLaunchRequested: index => win.launch(index)
|
||||
onDismissRequested: root.active = false
|
||||
onDismissRequested: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -5,12 +5,11 @@ import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Shapes
|
||||
import qs.widgets.bar
|
||||
import qs.widgets.theme
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Headlessly renderable visual core for the primary application launcher.
|
||||
// Runtime concerns (DesktopEntries, layer shell, launching) stay in
|
||||
// ApplicationLauncher.qml; this component only renders state and emits intent.
|
||||
// Runtime concerns (DesktopEntries, layer shell, focus, launching) stay in
|
||||
// AppLauncher.qml; this component only renders state and emits intent.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
@@ -70,7 +69,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
StatusBarPanel {
|
||||
LauncherPanel {
|
||||
anchors.fill: parent
|
||||
panelId: "008"
|
||||
title: "APPLICATION COMMAND INDEX"
|
||||
@@ -78,7 +77,7 @@ Item {
|
||||
chamfer: 18
|
||||
|
||||
// Query module.
|
||||
StatusBarPanel {
|
||||
LauncherPanel {
|
||||
id: queryPanel
|
||||
x: 18
|
||||
y: 34
|
||||
@@ -184,7 +183,7 @@ Item {
|
||||
}
|
||||
|
||||
// Search result table.
|
||||
StatusBarPanel {
|
||||
LauncherPanel {
|
||||
id: resultPanel
|
||||
x: 18
|
||||
y: 118
|
||||
@@ -347,7 +346,7 @@ Item {
|
||||
}
|
||||
|
||||
// Selected application inspector.
|
||||
StatusBarPanel {
|
||||
LauncherPanel {
|
||||
id: inspector
|
||||
x: 700
|
||||
y: 34
|
||||
@@ -569,7 +568,7 @@ Item {
|
||||
}
|
||||
|
||||
// Dense command footer.
|
||||
StatusBarPanel {
|
||||
LauncherPanel {
|
||||
x: 18
|
||||
y: 550
|
||||
width: 1044
|
||||
@@ -0,0 +1,44 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
// Non-visual, reusable app-search model shared by every launcher variant.
|
||||
// Set `search`; read `apps` (a ranked, filtered list of DesktopEntry).
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property string search: ""
|
||||
|
||||
// `keywords`/`categories` come through as string lists, so coerce every
|
||||
// field to a string before matching (String([]) joins with commas).
|
||||
function haystack(a) {
|
||||
return (String(a.name || "") + " " + String(a.genericName || "") + " " + String(a.comment || "") + " " + String(a.keywords || "")).toLowerCase();
|
||||
}
|
||||
|
||||
readonly property var apps: {
|
||||
const all = DesktopEntries.applications.values.filter(a => !a.noDisplay);
|
||||
const q = root.search.trim().toLowerCase();
|
||||
|
||||
if (q.length === 0)
|
||||
return all.slice().sort((x, y) => String(x.name).localeCompare(String(y.name)));
|
||||
|
||||
const matches = all.filter(a => root.haystack(a).includes(q));
|
||||
|
||||
// Prefix matches on the visible name rank first, then alphabetical.
|
||||
return matches.slice().sort((x, y) => {
|
||||
const xs = String(x.name).toLowerCase().startsWith(q) ? 0 : 1;
|
||||
const ys = String(y.name).toLowerCase().startsWith(q) ? 0 : 1;
|
||||
if (xs !== ys)
|
||||
return xs - ys;
|
||||
return String(x.name).localeCompare(String(y.name));
|
||||
});
|
||||
}
|
||||
|
||||
function launch(index) {
|
||||
const list = root.apps;
|
||||
if (index >= 0 && index < list.length) {
|
||||
list[index].execute();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Chamfered panel chrome for the launcher: outline, corner accent lines, and
|
||||
// the optional header strip (id chip / title / meta / tick marks). Content is
|
||||
// supplied as children by the call site.
|
||||
//
|
||||
// A sibling of BarPanel rather than a use of it: BarPanel is almost entirely
|
||||
// density machinery (summary slot, animated height, state pair, transitions)
|
||||
// for a rail that expands and collapses, and the launcher has exactly one
|
||||
// density. Same reasoning as PolkitPanel — see that file.
|
||||
//
|
||||
// Carried over from widgets/bar/StatusBarPanel.qml, which the legacy launcher
|
||||
// variants still use. Restyle this one freely; it is read only by the launcher.
|
||||
Item {
|
||||
id: panel
|
||||
|
||||
property string panelId: ""
|
||||
property string title: ""
|
||||
property string meta: ""
|
||||
property bool showHeader: true
|
||||
property int chamfer: 13
|
||||
property int offsetY: 2
|
||||
property int accentLineThickness: 3
|
||||
|
||||
Shape {
|
||||
id: panelShape
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
fillColor: Theme.surface
|
||||
strokeColor: Theme.hair
|
||||
strokeWidth: 1
|
||||
startX: 0; startY: panel.offsetY
|
||||
PathLine { x: panelShape.width - panel.chamfer; y: panel.offsetY }
|
||||
PathLine { x: panelShape.width; y: panel.chamfer }
|
||||
PathLine { x: panelShape.width; y: panelShape.height }
|
||||
PathLine { x: panel.chamfer; y: panelShape.height }
|
||||
PathLine { x: 0; y: panelShape.height - panel.chamfer }
|
||||
PathLine { x: 0; y: panel.offsetY }
|
||||
}
|
||||
|
||||
// Upper left accent line
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
startX: 0; startY: 0
|
||||
PathLine { x: Math.min(49, panelShape.width / 3); y: 0 }
|
||||
PathLine { x: Math.min(49, panelShape.width / 3); y: panel.accentLineThickness }
|
||||
PathLine { x: 0; y: panel.accentLineThickness }
|
||||
PathLine { x: 0; y: 0 }
|
||||
}
|
||||
|
||||
// Lower right accent line
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
startX: panelShape.width; startY: panelShape.height
|
||||
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height }
|
||||
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness }
|
||||
PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness }
|
||||
PathLine { x: panelShape.width; y: panelShape.height }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: panel.showHeader
|
||||
x: 1; y: 22
|
||||
width: parent.width - 2
|
||||
height: 1
|
||||
color: Theme.text
|
||||
opacity: 0.12
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: panel.showHeader
|
||||
x: 5; y: 7
|
||||
width: panel.panelId.length > 2 ? 29 : 24
|
||||
height: 11
|
||||
color: Theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: panel.panelId
|
||||
color: Theme.surface
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 8
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: panel.showHeader
|
||||
x: 40; y: 7
|
||||
width: parent.width - 105
|
||||
text: panel.title
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 9
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.1
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Inlined rather than reusing DenseBarContent's MicroText, which is an
|
||||
// inline component and therefore not visible from another file.
|
||||
Text {
|
||||
visible: panel.showHeader && panel.meta.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
y: 6
|
||||
text: panel.meta
|
||||
width: Math.min(80, parent.width / 4)
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 6
|
||||
font.letterSpacing: 0.7
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
visible: panel.showHeader
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
y: 14
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: 5
|
||||
Rectangle { required property int index; width: 4; height: 2; color: Theme.accent }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Panel chrome for the authentication dialog.
|
||||
//
|
||||
// Deliberately NOT BarPanel. The rail's panel exists to carry two renderings of
|
||||
// the same data and cross-fade between them as the rail changes density, and
|
||||
// almost all of its size is that machinery: the summary slot, the animated
|
||||
// height, the state pair, the transitions. A modal has exactly one density and
|
||||
// never collapses, so inheriting all of that would mean carrying dead weight
|
||||
// and, worse, tying the dialog's look to a component whose real job is the rail
|
||||
// — every restyle here would have to be justified against the panels up there.
|
||||
//
|
||||
// What it does keep is the silhouette, because that is the shell's visual
|
||||
// signature rather than the rail's: two cut corners (top-right, bottom-left)
|
||||
// with detached accent caps outside them, an accent rule under the header slug,
|
||||
// its mirror at the lower right, and a header strip of slug / title / meta.
|
||||
//
|
||||
// This is the file to edit to restyle the prompt. Nothing else reads it.
|
||||
Item {
|
||||
id: panel
|
||||
|
||||
property string panelId: ""
|
||||
property string title: ""
|
||||
property string meta: ""
|
||||
|
||||
property int chamfer: 16
|
||||
property int padding: 14
|
||||
property int outlineWidth: 1
|
||||
property int accentLineThickness: 3
|
||||
|
||||
readonly property int headerHeight: 30
|
||||
|
||||
// Gap between the header rule and the body.
|
||||
property int headerGap: 10
|
||||
readonly property int headerPadding: 10
|
||||
|
||||
// Detached corner caps: the corner each chamfer removed, put back outside
|
||||
// the panel as an accent triangle whose hypotenuse faces the cut. capGap is
|
||||
// the perpendicular distance from the cut, so the per-axis shift is it over
|
||||
// root 2 — the cap moves along the cut's normal, not along an axis.
|
||||
property real capGap: 4
|
||||
readonly property real capOffset: panel.capGap / Math.SQRT2
|
||||
|
||||
// Never let the two cuts cross, which would turn the outline inside out on
|
||||
// a panel shorter than twice the chamfer.
|
||||
readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1))
|
||||
|
||||
// The accent rule under the slug is sized to the slug, not to the panel.
|
||||
readonly property real accentLineWidth: Math.min(panel.width, slugChip.width + panel.headerPadding * 2)
|
||||
|
||||
default property alias content: body.data
|
||||
|
||||
implicitHeight: Math.round(body.y + body.height + panel.padding)
|
||||
|
||||
Shape {
|
||||
id: panelShape
|
||||
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
// Outline: square except for the two cut corners.
|
||||
ShapePath {
|
||||
fillColor: Theme.surface
|
||||
strokeColor: Theme.hair
|
||||
strokeWidth: panel.outlineWidth
|
||||
|
||||
startX: 0; startY: 0
|
||||
PathLine { x: panelShape.width - panel.activeChamfer; y: 0 }
|
||||
PathLine { x: panelShape.width; y: panel.activeChamfer }
|
||||
PathLine { x: panelShape.width; y: panelShape.height }
|
||||
PathLine { x: panel.activeChamfer; y: panelShape.height }
|
||||
PathLine { x: 0; y: panelShape.height - panel.activeChamfer }
|
||||
PathLine { x: 0; y: 0 }
|
||||
}
|
||||
|
||||
// Cap on the top-right cut.
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
|
||||
startX: panelShape.width - panel.activeChamfer + panel.capOffset
|
||||
startY: -panel.capOffset
|
||||
PathLine { x: panelShape.width + panel.capOffset; y: panel.activeChamfer - panel.capOffset }
|
||||
PathLine { x: panelShape.width + panel.capOffset; y: -panel.capOffset }
|
||||
PathLine { x: panelShape.width - panel.activeChamfer + panel.capOffset; y: -panel.capOffset }
|
||||
}
|
||||
|
||||
// Cap on the bottom-left cut, the same triangle mirrored.
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
|
||||
startX: panel.activeChamfer - panel.capOffset
|
||||
startY: panelShape.height + panel.capOffset
|
||||
PathLine { x: -panel.capOffset; y: panelShape.height - panel.activeChamfer + panel.capOffset }
|
||||
PathLine { x: -panel.capOffset; y: panelShape.height + panel.capOffset }
|
||||
PathLine { x: panel.activeChamfer - panel.capOffset; y: panelShape.height + panel.capOffset }
|
||||
}
|
||||
|
||||
// Accent rule under the slug.
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
|
||||
startX: 0; startY: 0
|
||||
PathLine { x: panel.accentLineWidth; y: 0 }
|
||||
PathLine { x: panel.accentLineWidth; y: panel.accentLineThickness }
|
||||
PathLine { x: 0; y: panel.accentLineThickness }
|
||||
PathLine { x: 0; y: 0 }
|
||||
}
|
||||
|
||||
// Its mirror at the lower right.
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
|
||||
startX: panelShape.width; startY: panelShape.height
|
||||
PathLine { x: panelShape.width - panel.accentLineWidth; y: panelShape.height }
|
||||
PathLine { x: panelShape.width - panel.accentLineWidth; y: panelShape.height - panel.accentLineThickness }
|
||||
PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness }
|
||||
PathLine { x: panelShape.width; y: panelShape.height }
|
||||
}
|
||||
}
|
||||
|
||||
// Header: slug chip, title, and the meta text pinned right.
|
||||
Row {
|
||||
id: headerRow
|
||||
|
||||
x: panel.headerPadding
|
||||
y: Math.round((panel.headerHeight - height) / 2)
|
||||
spacing: panel.headerPadding + 6
|
||||
|
||||
Rectangle {
|
||||
id: slugChip
|
||||
|
||||
width: slugText.implicitWidth + 8
|
||||
height: slugText.implicitHeight + 4
|
||||
color: Theme.accent
|
||||
|
||||
Text {
|
||||
id: slugText
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: panel.panelId
|
||||
color: Theme.surface
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: slugChip.verticalCenter
|
||||
text: panel.title
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.1
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: panel.headerPadding + 2
|
||||
y: Math.round((panel.headerHeight - implicitHeight) / 2)
|
||||
visible: panel.meta.length > 0
|
||||
text: panel.meta
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 8
|
||||
font.letterSpacing: 0.7
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Header rule.
|
||||
Rectangle {
|
||||
x: 1
|
||||
y: panel.headerHeight
|
||||
width: parent.width - 2
|
||||
height: 1
|
||||
color: Theme.text
|
||||
opacity: 0.12
|
||||
}
|
||||
|
||||
// Body. Measured by childrenRect, so a child must carry its own size and
|
||||
// must NOT anchor to this slot.
|
||||
Item {
|
||||
id: body
|
||||
|
||||
x: panel.padding
|
||||
y: panel.headerHeight + panel.headerGap
|
||||
width: Math.max(0, panel.width - panel.padding * 2)
|
||||
height: childrenRect.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Polkit
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Polkit authentication agent for the hyprchrome shell.
|
||||
//
|
||||
// Instantiating PolkitAgent IS the registration — it registers a listener for
|
||||
// this logind session in componentComplete(), so there is nothing to start and
|
||||
// nothing to call. Two consequences:
|
||||
//
|
||||
// * Only ONE agent may hold a session. hyprpolkitagent must not be running
|
||||
// (hosts/terra/home/hyprland.nix autostart), or registration fails and this
|
||||
// dialog silently never appears. `isRegistered` is the check.
|
||||
// * `path` is write-once — the binary refuses a later assignment with
|
||||
// "cannot change path after it has been set." Set it here or not at all.
|
||||
//
|
||||
// Concurrent requests SUPERSEDE each other — they do not queue. Verified
|
||||
// against a live trace of two simultaneous `pkexec` calls: both logged
|
||||
// "activating authentication request" back to back, each with its own cookie
|
||||
// and its own "setting up session", with no wait for the first to finish.
|
||||
// `agent.flow` simply becomes the newest request.
|
||||
//
|
||||
// The consequence is that the earlier request is ORPHANED: its PAM session is
|
||||
// live and polkit is still waiting on it, but nothing in QML can reach it any
|
||||
// more, so its caller hangs until it gives up and polkit cancels — which
|
||||
// surfaces as quickshell's "the cancelled request was not found in the queue".
|
||||
// This dialog therefore shows the newest request and loses the older one. See
|
||||
// the flow-change handler below; fixing it properly means holding superseded
|
||||
// flows in QML and re-presenting them, which is only worth doing if concurrent
|
||||
// authorization prompts turn out to happen in practice.
|
||||
//
|
||||
// Everything below re-latches per flow instead of caching it.
|
||||
//
|
||||
// The visual core lives in PolkitPromptContent so it can be rendered headlessly
|
||||
// and staged in DebugWindow; this file owns the agent, the surface and focus.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Which output the dialog appears on. Driven by the shell, which puts it on
|
||||
// the focused monitor rather than on the rail's — a password prompt belongs
|
||||
// where the user is looking. Left unset it falls back to whatever screen
|
||||
// quickshell picks, which is right for a single-monitor session.
|
||||
property var screen: null
|
||||
|
||||
// Where the flow's identity list is currently pointed. Held here rather
|
||||
// than read back off the flow because the content addresses identities by
|
||||
// index and AuthFlow addresses them by object.
|
||||
readonly property var flow: agent.flow
|
||||
|
||||
// Whether this shell actually holds the session's agent. Exposed because
|
||||
// failure is invisible from the outside: an unregistered agent simply never
|
||||
// shows a dialog, which looks exactly like "no one asked for authorization".
|
||||
readonly property alias registered: agent.isRegistered
|
||||
|
||||
// Whether a request is being presented. Both surfaces read it, so it is
|
||||
// decided once here rather than each deriving it — the scrim and the dialog
|
||||
// must come and go on the same frame.
|
||||
//
|
||||
// isCompleted is checked as well as null: the flow reports its terminal
|
||||
// state before the agent drops it, and neither surface should linger for
|
||||
// those frames over a request that has already been decided.
|
||||
readonly property bool prompting: root.flow !== null && !root.flow.isCompleted
|
||||
|
||||
// Reset per REQUEST, not per window show.
|
||||
//
|
||||
// A second request supersedes the first by swapping `flow` while the dialog
|
||||
// is already up, so the window never hides in between. Keying the reset off
|
||||
// the surface's visibility therefore skips that swap entirely and the new
|
||||
// request inherits whatever was typed for the old one — a password entered
|
||||
// for one action left sitting in the box for a different action. The flow
|
||||
// object changing is the event that actually means "new request".
|
||||
// Do NOT cancel the superseded flow here. It is tempting — a superseded
|
||||
// request is unreachable but still live, so its caller hangs until killed,
|
||||
// and cancelling would at least fail it fast. Tried, and it makes things
|
||||
// strictly worse: cancelling a flow that is no longer the agent's active
|
||||
// one tears down state the CURRENT request still needs, and quickshell then
|
||||
// logs
|
||||
//
|
||||
// QObject::connect(AuthFlow, PolkitAgentImpl): invalid nullptr parameter
|
||||
//
|
||||
// leaving the live request with a broken agent and no dialog at all. So the
|
||||
// superseded request is dismissed and the one the user can actually see
|
||||
// never appears. Leaving it orphaned costs one hung caller; cancelling it
|
||||
// costs the prompt as well.
|
||||
onFlowChanged: {
|
||||
if (root.flow) {
|
||||
content.clearResponse();
|
||||
content.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
function identityIndex(flow) {
|
||||
if (!flow || !flow.selectedIdentity)
|
||||
return 0;
|
||||
for (let i = 0; i < flow.identities.length; i++) {
|
||||
if (flow.identities[i] === flow.selectedIdentity)
|
||||
return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
PolkitAgent {
|
||||
id: agent
|
||||
|
||||
// Default is /org/quickshell/PolkitAgent; named explicitly because it
|
||||
// cannot be changed after startup and a second shell would collide.
|
||||
path: "/org/quickshell/PolkitAgent"
|
||||
|
||||
onIsRegisteredChanged: {
|
||||
if (agent.isRegistered)
|
||||
console.info("polkit: agent registered at", agent.path);
|
||||
else
|
||||
console.warn("polkit: agent lost its registration — this session now has no polkit agent");
|
||||
}
|
||||
}
|
||||
|
||||
// Registration is ASYNCHRONOUS. It is started in the agent's
|
||||
// componentComplete but only lands a DBus round trip later — measured at
|
||||
// under 250ms here, still false at Component.onCompleted. So neither an
|
||||
// immediate check nor the change handler above can report a total failure:
|
||||
// an agent that never registers stays false from construction onward and
|
||||
// changes nothing, which is silence rather than an error. Hence a deadline.
|
||||
//
|
||||
// Hot reload is fine: quickshell hands the listener to the new generation
|
||||
// ("taking over listener from previous generation") and isRegistered goes
|
||||
// true again, verified on a live reload.
|
||||
//
|
||||
// Do NOT turn this into a rebuild-and-retry loop. Tried, with the agent in
|
||||
// a Loader so a fresh one could be constructed. It cannot work: the subject
|
||||
// polkit means is the SESSION, this process already holds a listener for
|
||||
// it, and so every rebuilt agent fails identically with
|
||||
//
|
||||
// ...PolicyKit1.Error.Failed:
|
||||
// An authentication agent already exists for the given subject
|
||||
//
|
||||
// Nothing QML can do releases that listener. The one time registration did
|
||||
// fail across a reload, the cause was upstream state already corrupted by
|
||||
// cancelling a superseded flow (see the flow handler above) — not the
|
||||
// reload itself, and not something a retry would have recovered.
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: true
|
||||
|
||||
onTriggered: {
|
||||
if (!agent.isRegistered)
|
||||
console.warn("polkit: agent still unregistered after 2s — another agent (hyprpolkitagent, polkit-gnome, cosmic-osd) is probably holding this session");
|
||||
}
|
||||
}
|
||||
|
||||
// No scrim of its own. The shell owns the single ChromeBackdrop and raises
|
||||
// it for either cause — an expanded rail or an open prompt — so a prompt
|
||||
// arriving over an already-expanded rail reuses the scrim that is already
|
||||
// there instead of stacking a second one on top of it. `prompting` above is
|
||||
// what the shell reads to decide. See HyprChromeShell.
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
screen: root.screen
|
||||
visible: root.prompting
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// A real modal — unlike the rest of the rail, this one must take the
|
||||
// keyboard, or the password goes to whatever window was focused.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: Theme.textAlpha(0)
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
// No scrim here — ChromeBackdrop above draws it. This surface stays
|
||||
// transparent but unmasked, so it still swallows clicks across the
|
||||
// whole output: a polkit request is answered or explicitly cancelled,
|
||||
// and losing one to a stray click on the wallpaper would leave the
|
||||
// caller waiting with no visible reason.
|
||||
|
||||
PolkitPromptContent {
|
||||
id: content
|
||||
|
||||
// A third of the way down rather than centred: a password prompt
|
||||
// reads better above the middle, and on a tall output dead-centre
|
||||
// puts it below the natural resting line of the eye.
|
||||
//
|
||||
// The panel's own CENTRE lands on the third, so the dialog grows
|
||||
// symmetrically about that line as the message wraps or a pam_info
|
||||
// line appears. Floored at the same margin the width leaves, so a
|
||||
// tall prompt on a short output cannot be pushed off the top.
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: Math.max(32, Math.round(parent.height / 3 - height / 2))
|
||||
width: 520
|
||||
|
||||
message: root.flow ? root.flow.message : ""
|
||||
actionId: root.flow ? root.flow.actionId : ""
|
||||
iconName: root.flow ? root.flow.iconName : ""
|
||||
identities: root.flow ? root.flow.identities : []
|
||||
selectedIdentity: root.identityIndex(root.flow)
|
||||
responseRequired: root.flow ? root.flow.isResponseRequired : false
|
||||
inputPrompt: root.flow ? root.flow.inputPrompt : ""
|
||||
responseVisible: root.flow ? root.flow.responseVisible : false
|
||||
supplementaryMessage: root.flow ? root.flow.supplementaryMessage : ""
|
||||
supplementaryIsError: root.flow ? root.flow.supplementaryIsError : false
|
||||
failed: root.flow ? root.flow.failed : false
|
||||
|
||||
onSubmitted: value => {
|
||||
if (root.flow)
|
||||
root.flow.submit(value);
|
||||
}
|
||||
|
||||
onCancelled: {
|
||||
if (root.flow)
|
||||
root.flow.cancelAuthenticationRequest();
|
||||
}
|
||||
|
||||
// AuthFlow refuses a null identity, so the index is bounds-checked
|
||||
// here rather than trusting the view.
|
||||
onIdentityRequested: index => {
|
||||
if (root.flow && index >= 0 && index < root.flow.identities.length)
|
||||
root.flow.selectedIdentity = root.flow.identities[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe the box on a rejected attempt. `failed` flags the attempt, not
|
||||
// the request — polkit lets PAM retry, and the flow stays live with a
|
||||
// fresh prompt, so the field has to be cleared without closing.
|
||||
Connections {
|
||||
target: root.flow
|
||||
enabled: root.flow !== null
|
||||
|
||||
function onFailedChanged() {
|
||||
if (root.flow.failed)
|
||||
content.clearResponse();
|
||||
}
|
||||
|
||||
// Re-focus when the conversation asks for something. This is load
|
||||
// bearing, not defensive: a flow arrives with isResponseRequired
|
||||
// FALSE and an empty inputPrompt — PAM has not asked yet — so the
|
||||
// window becomes visible while the field is still disabled, and the
|
||||
// focusInput() below it cannot land. The prompt shows up a moment
|
||||
// later, and that is the edge that must take the keyboard. The same
|
||||
// handler covers a second factor and a post-failure retry.
|
||||
function onIsResponseRequiredChanged() {
|
||||
if (root.flow.isResponseRequired)
|
||||
content.focusInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.HyprChrome.Theme
|
||||
|
||||
// Headlessly renderable visual core of the polkit authentication prompt.
|
||||
//
|
||||
// Nothing here imports Quickshell.Services.Polkit: every field an AuthFlow
|
||||
// exposes arrives as a plain property and every action leaves as a signal, so
|
||||
// the whole dialog can be rendered offscreen (tools/quickshell-preview) and
|
||||
// staged in DebugWindow without a real authorization request. PolkitPrompt.qml
|
||||
// owns the agent and does the mapping.
|
||||
//
|
||||
// `identities` is read structurally — each entry only needs `displayName` — so
|
||||
// the adapter can hand over the flow's QList<Identity*> unchanged while the
|
||||
// headless test passes plain JS objects.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// ---- flow state, mirrored ----
|
||||
property string message: ""
|
||||
property string actionId: ""
|
||||
property string iconName: ""
|
||||
property bool showIcon: true
|
||||
|
||||
// Who may authenticate. One entry is the common case and renders as a
|
||||
// plain line; the picker only appears when polkit actually offers a
|
||||
// choice (a user in several admin groups, or root plus wheel).
|
||||
property var identities: []
|
||||
property int selectedIdentity: 0
|
||||
|
||||
// PAM conversation. `responseVisible` is polkit's echo flag — it is NOT
|
||||
// always false: a smartcard PIN prompt or a security-question stack asks
|
||||
// for echoed input, and masking those makes the prompt unusable.
|
||||
property bool responseRequired: false
|
||||
property string inputPrompt: ""
|
||||
property bool responseVisible: false
|
||||
|
||||
// pam_info / pam_error text, and whether the last attempt was rejected.
|
||||
property string supplementaryMessage: ""
|
||||
property bool supplementaryIsError: false
|
||||
property bool failed: false
|
||||
|
||||
property alias response: responseInput.text
|
||||
|
||||
signal submitted(string value)
|
||||
signal cancelled
|
||||
signal identityRequested(int index)
|
||||
|
||||
function focusInput() { responseInput.forceActiveFocus(); }
|
||||
function clearResponse() { responseInput.text = ""; }
|
||||
|
||||
implicitWidth: 520
|
||||
implicitHeight: panel.implicitHeight
|
||||
|
||||
// Escape reaches here by propagating up the focus chain from the TextInput,
|
||||
// which does not consume it — so cancelling works whether or not the input
|
||||
// currently has focus.
|
||||
Keys.onEscapePressed: event => {
|
||||
root.cancelled();
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
component MicroText: Text {
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 8
|
||||
font.letterSpacing: 0.9
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
PolkitPanel {
|
||||
id: panel
|
||||
|
||||
width: root.width
|
||||
panelId: "PKT"
|
||||
title: "AUTHORIZATION REQUIRED"
|
||||
// The action id is the one piece that says WHAT is being authorized
|
||||
// independently of the (localizable, often vague) message.
|
||||
meta: root.actionId
|
||||
|
||||
// The body slot decides the width and the layout's implicitHeight
|
||||
// becomes its height, so a wrapped message or an extra pam_info line
|
||||
// grows the panel instead of being clipped.
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
// ---- what is being asked ----
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
IconImage {
|
||||
visible: root.showIcon && root.iconName !== ""
|
||||
implicitSize: 32
|
||||
source: root.showIcon && root.iconName !== ""
|
||||
? Quickshell.iconPath(root.iconName, "dialog-password")
|
||||
: ""
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
text: root.message
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 13
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
// ---- identity ----
|
||||
// Single identity: stated, not offered. Several: chips, because a
|
||||
// combo box would be the only QtQuick.Controls widget in the rail.
|
||||
MicroText {
|
||||
Layout.fillWidth: true
|
||||
visible: root.identities.length === 1
|
||||
text: "AS " + (root.identities.length === 1
|
||||
? root.identities[0].displayName : "")
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: root.identities.length > 1
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.identities
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: chip.index === root.selectedIdentity
|
||||
|
||||
width: chipLabel.implicitWidth + 14
|
||||
height: chipLabel.implicitHeight + 8
|
||||
color: chip.current ? Theme.accent : "transparent"
|
||||
border.width: 1
|
||||
border.color: chip.current ? Theme.accent : Theme.disabled
|
||||
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: chip.modelData.displayName
|
||||
color: chip.current ? Theme.surface : Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.identityRequested(chip.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the conversation ----
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 34
|
||||
color: Theme.selection
|
||||
border.width: 1
|
||||
border.color: root.failed ? Theme.hot : Theme.hair
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: ">_"
|
||||
color: root.responseRequired ? Theme.accent : Theme.disabled
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 15
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: responseInput
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
enabled: root.responseRequired
|
||||
color: Theme.text
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.surface
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 14
|
||||
font.letterSpacing: 1
|
||||
clip: true
|
||||
|
||||
echoMode: root.responseVisible
|
||||
? TextInput.Normal : TextInput.Password
|
||||
passwordCharacter: "▪"
|
||||
// Qt reveals the last typed character for a moment by
|
||||
// default. On a screen-visible layer-shell overlay
|
||||
// that is a shoulder-surfing hole, so: never.
|
||||
passwordMaskDelay: 0
|
||||
|
||||
onAccepted: {
|
||||
if (root.responseRequired)
|
||||
root.submitted(responseInput.text);
|
||||
}
|
||||
|
||||
// Placeholder: TextInput has none of its own, and the
|
||||
// PAM prompt ("Password:", "PIN:") is the only label
|
||||
// this field gets.
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: responseInput.text.length === 0
|
||||
text: root.inputPrompt
|
||||
color: Theme.disabled
|
||||
font: responseInput.font
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pam_info / pam_error ----
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.supplementaryMessage !== ""
|
||||
wrapMode: Text.Wrap
|
||||
text: root.supplementaryMessage
|
||||
color: root.supplementaryIsError ? Theme.hot : Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 0.7
|
||||
}
|
||||
|
||||
// ---- key hints ----
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 14
|
||||
|
||||
MicroText { text: "ENTER AUTHENTICATE" }
|
||||
MicroText { text: "ESC CANCEL" }
|
||||
Item { Layout.fillWidth: true }
|
||||
MicroText {
|
||||
text: root.responseVisible ? "ECHO ON" : ""
|
||||
color: Theme.hot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Quickshell
|
||||
import qs.HyprChrome.Widgets.Greeter
|
||||
|
||||
// greetd greeter entry point: `qs -p greeter.qml`, as the greeter user. See
|
||||
// services/desktop/quickshell-greeter.nix.
|
||||
Scope {
|
||||
Greeter {}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
|
||||
// Full-screen scrim behind the bar: dims the desktop and lays the dense bar's
|
||||
// drafting grid over it while the rail is expanded.
|
||||
//
|
||||
// Sits on the TOP layer while the bar itself is on OVERLAY. Two surfaces on the
|
||||
// same layer stack by creation order, which is not something to rely on; one
|
||||
// layer apart is a guarantee — above ordinary windows, below the bar.
|
||||
//
|
||||
// It reserves nothing and takes no input: the mask is an empty Region, so
|
||||
// clicks land on whatever is underneath rather than on the scrim.
|
||||
PanelWindow {
|
||||
id: backdrop
|
||||
|
||||
property bool active: true
|
||||
property real dim: 0.55
|
||||
property int gridSpacing: 120
|
||||
|
||||
// The dense bar drew this grid at 0.018 against its own near-black panel.
|
||||
// Over a 55% scrim on top of lit windows that is invisible, so it is a
|
||||
// knob rather than a constant.
|
||||
property real gridOpacity: 0.1
|
||||
|
||||
// The accent with its saturation pulled back: warm enough to read as part
|
||||
// of the palette, not so loud that a full-screen grid competes with the
|
||||
// bar. Derived rather than a literal so it tracks a palette change.
|
||||
// Registration crosses sit on every other intersection of the grid.
|
||||
property color crossColor: Theme.muted
|
||||
property real crossOpacity: 0.35
|
||||
property int crossSize: 20
|
||||
|
||||
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
|
||||
// widths can straddle a 1px rule symmetrically, so an even pixel count
|
||||
// would push every mark half a pixel off the grid it registers against.
|
||||
property int crossThickness: 2
|
||||
readonly property int crossWeight: Math.max(1, backdrop.crossThickness) * 2 - 1
|
||||
|
||||
property color gridColor: Qt.hsla(Theme.accent.hslHue,
|
||||
Theme.accent.hslSaturation * 0.45,
|
||||
Theme.accent.hslLightness,
|
||||
1)
|
||||
|
||||
visible: scrim.opacity > 0
|
||||
|
||||
WlrLayershell.namespace: "hyprchrome-scrim"
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
readonly property int crossColumns: Math.ceil(backdrop.width / (backdrop.gridSpacing * 2)) + 1
|
||||
readonly property int crossRows: Math.ceil(backdrop.height / (backdrop.gridSpacing * 2)) + 1
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
mask: Region {}
|
||||
|
||||
Item {
|
||||
id: scrim
|
||||
|
||||
anchors.fill: parent
|
||||
opacity: backdrop.active ? 1 : 0
|
||||
|
||||
// Matched to the bar's own collapse so the scrim and the panels resolve
|
||||
// together rather than one trailing the other.
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surface
|
||||
opacity: backdrop.dim
|
||||
}
|
||||
|
||||
// Faint drafting grid; no gradient and deliberately subordinate to
|
||||
// whatever is showing through it.
|
||||
Repeater {
|
||||
model: Math.ceil(scrim.width / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
x: index * backdrop.gridSpacing
|
||||
width: 1
|
||||
height: scrim.height
|
||||
color: backdrop.gridColor
|
||||
opacity: backdrop.gridOpacity
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Math.ceil(scrim.height / backdrop.gridSpacing)
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
y: index * backdrop.gridSpacing
|
||||
width: scrim.width
|
||||
height: 1
|
||||
color: backdrop.gridColor
|
||||
opacity: backdrop.gridOpacity
|
||||
}
|
||||
}
|
||||
|
||||
// Register marks on every other line, so they land on a 2x grid rather
|
||||
// than on every crossing — sparse enough to read as drafting registration
|
||||
// rather than as texture.
|
||||
Repeater {
|
||||
model: backdrop.crossColumns * backdrop.crossRows
|
||||
|
||||
Item {
|
||||
required property int index
|
||||
|
||||
readonly property int column: index % backdrop.crossColumns
|
||||
readonly property int row: Math.floor(index / backdrop.crossColumns)
|
||||
|
||||
// Marks on every other rule in both directions, so they line up
|
||||
// in columns as well as rows. Both offsets are whole multiples of
|
||||
// gridSpacing, so every mark lands on a real intersection.
|
||||
//
|
||||
// Math.floor, not /2: the item offset and the bars inside it must
|
||||
// round the same way, or an even crossSize sits half a pixel off the
|
||||
// rule it marks.
|
||||
x: column * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
|
||||
width: backdrop.crossSize
|
||||
height: backdrop.crossSize
|
||||
opacity: backdrop.crossOpacity
|
||||
|
||||
Rectangle {
|
||||
// Placed with the same Math.floor the item's own offset uses.
|
||||
// anchors.verticalCenter halves the height unfloored, so an even
|
||||
// crossSize put the 1px bar half a pixel off the rule it marks.
|
||||
y: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
|
||||
width: parent.width
|
||||
height: backdrop.crossWeight
|
||||
color: backdrop.crossColor
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
|
||||
width: backdrop.crossWeight
|
||||
height: parent.height
|
||||
color: backdrop.crossColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import qs.hyprchrome.widgets
|
||||
import qs.hyprchrome.widgets.host
|
||||
import qs.hyprchrome.widgets.vitals
|
||||
import qs.hyprchrome.widgets.tray
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Monitor the rail lives on. Falls back to the FIRST connected screen when
|
||||
// the name matches nothing, so the bar still appears on a single-monitor
|
||||
// session or after a cable swap (DebugWindow falls back to the last one
|
||||
// instead — it wants the secondary).
|
||||
property string screenName: "DP-2"
|
||||
|
||||
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
|
||||
readonly property var targetScreen: {
|
||||
const screens = Quickshell.screens;
|
||||
if (screens.length === 0)
|
||||
return null;
|
||||
for (let i = 0; i < screens.length; i++) {
|
||||
if (screens[i].name === root.screenName)
|
||||
return screens[i];
|
||||
}
|
||||
return screens[0];
|
||||
}
|
||||
|
||||
// Density is a property of the BAR: every panel follows it, so the whole rail
|
||||
// expands and collapses as one. Panels keep their own animation; only the
|
||||
// decision is centralised here.
|
||||
property bool expanded: true
|
||||
|
||||
function toggle() {
|
||||
root.expanded = !root.expanded;
|
||||
}
|
||||
|
||||
// SUPER A — see hosts/terra/home/hyprland.nix.
|
||||
GlobalShortcut {
|
||||
name: "chrome"
|
||||
description: "Expand or collapse the hyprchrome bar"
|
||||
onPressed: root.toggle()
|
||||
}
|
||||
|
||||
// Scrim first: it is a layer below the bar, so stacking does not depend on
|
||||
// creation order, but keeping the declaration order the same as the visual
|
||||
// order costs nothing.
|
||||
ChromeBackdrop {
|
||||
screen: root.targetScreen
|
||||
active: root.expanded
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: window
|
||||
|
||||
screen: root.targetScreen
|
||||
visible: root.targetScreen !== null
|
||||
|
||||
// One layer above the scrim, so the stacking is guaranteed rather than
|
||||
// dependent on surface creation order. See ChromeBackdrop.
|
||||
// Own namespace so a layerrule can exempt the rail from Hyprland's layer
|
||||
// animation without also catching the launchers, which share the default
|
||||
// "quickshell" namespace and do want their fade.
|
||||
WlrLayershell.namespace: "hyprchrome-bar"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
|
||||
property int margin: 12
|
||||
|
||||
// The surface never resizes: it is always tall enough for the expanded
|
||||
// rail, and only the exclusive zone tracks the current state. Resizing a
|
||||
// layer surface makes Hyprland animate the change, which showed up as the
|
||||
// panels twitching a pixel or two the moment the collapse finished.
|
||||
//
|
||||
// The zone still follows the target height, so tiled windows reflow once
|
||||
// per toggle, at the start, and slide while the panels animate.
|
||||
readonly property real expandedContent: Math.max(hostPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2
|
||||
readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2
|
||||
|
||||
implicitHeight: Math.round(window.expandedContent)
|
||||
|
||||
exclusionMode: ExclusionMode.Normal
|
||||
exclusiveZone: Math.round(window.contentHeight)
|
||||
|
||||
// Only the panels take input. Without this the surface would keep eating
|
||||
// clicks across its full height while the rail is collapsed.
|
||||
mask: Region {
|
||||
item: panelRow
|
||||
}
|
||||
|
||||
anchors { top: true; left: true; right: true; }
|
||||
|
||||
color: "transparent"
|
||||
|
||||
RowLayout {
|
||||
id: panelRow
|
||||
x: window.margin
|
||||
y: window.margin
|
||||
width: window.width - window.margin * 2
|
||||
spacing: 0
|
||||
|
||||
HostPanel {
|
||||
id: hostPanel
|
||||
|
||||
expanded: root.expanded
|
||||
// Vitals butts against its right edge; the left end of the row is free.
|
||||
rightChamfer: false
|
||||
|
||||
toggleOnClick: false
|
||||
Layout.preferredWidth: 350
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
VitalsPanel {
|
||||
id: vitalsPanel
|
||||
|
||||
expanded: root.expanded
|
||||
// Host on the left, the spacer on the right — and a spacer is not a
|
||||
// panel, so that edge keeps its cut.
|
||||
leftChamfer: false
|
||||
|
||||
toggleOnClick: false
|
||||
Layout.preferredWidth: 500
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
// Slack lives between the left group and the tray, so the tray sits
|
||||
// flush right whatever the other panels measure.
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
TrayPanel {
|
||||
id: trayPanel
|
||||
|
||||
expanded: root.expanded
|
||||
toggleOnClick: false
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,21 @@ import qs.widgets.osd
|
||||
import qs.widgets.systray
|
||||
import qs.widgets.theme
|
||||
import qs.widgets.vitals
|
||||
import qs.hyprchrome
|
||||
import qs.hyprchrome.widgets
|
||||
import qs.hyprchrome.widgets.debug
|
||||
import qs.hyprchrome.widgets.host
|
||||
import qs.hyprchrome.widgets.vitals
|
||||
import qs.HyprChrome
|
||||
import qs.HyprChrome.Widgets
|
||||
import qs.HyprChrome.Widgets.Bar.Debug
|
||||
import qs.HyprChrome.Widgets.Bar.Host
|
||||
import qs.HyprChrome.Widgets.Bar.Vitals
|
||||
|
||||
Scope {
|
||||
// Dense multi-monitor status rail; visual core is headlessly renderable.
|
||||
HyprChromeBar {}
|
||||
HyprChromeShell {}
|
||||
|
||||
// App launcher variants — 1–11; variant 8 remains the primary HUD.
|
||||
// App launcher variants still under evaluation, on SUPER CTRL 1–11.
|
||||
// Variant 8 — the primary launcher on SUPER_L — has moved into
|
||||
// HyprChrome/Widgets/Launcher and is instantiated by HyprChromeShell,
|
||||
// because the shell owns the scrim, the focused monitor and the keyboard
|
||||
// arbitration it now shares with the polkit prompt.
|
||||
LauncherStack {} // 1 — left vertical list
|
||||
LauncherGrid {} // 2 — centered icon grid
|
||||
LauncherSpotlight {} // 3 — top-center command bar
|
||||
@@ -28,12 +32,12 @@ Scope {
|
||||
LauncherDock {} // 5 — deck rising from the bottom bar
|
||||
LauncherSlant {} // 6 — angular / sheared panel
|
||||
LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot)
|
||||
ApplicationLauncher {} // 8 — dense HUD command index (primary)
|
||||
BladeLauncher {} // 9 — asymmetric blade matrix
|
||||
OrbitLauncher {} // 10 — radial targeting arena
|
||||
CyberDock {} // 11 — cyberpunk bottom cartridge dock
|
||||
|
||||
Notifications {}
|
||||
|
||||
VolumeOsd {}
|
||||
|
||||
// Host vitals HUD — toggle with SUPER CTRL V.
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import QtQuick
|
||||
import qs.widgets.launcher
|
||||
import qs.HyprChrome.Widgets.Launcher
|
||||
|
||||
ApplicationLauncherContent {
|
||||
AppLauncherContent {
|
||||
width: 1080
|
||||
height: 620
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Widgets.Greeter
|
||||
|
||||
// Offscreen render of the login panel after a rejected password.
|
||||
//
|
||||
// ./tools/quickshell-preview/render.sh \
|
||||
// tests/LoginPanelHeadless.qml \
|
||||
// .artifacts/quickshell-preview/login-panel.png 700 340
|
||||
LoginContent {
|
||||
width: 640
|
||||
|
||||
hostName: "terra"
|
||||
sessionName: "hyprland"
|
||||
now: new Date(2026, 8, 18, 21, 47, 38)
|
||||
user: "darman"
|
||||
response: "hunter2"
|
||||
|
||||
message: "Authentication failure"
|
||||
messageIsError: true
|
||||
failed: true
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import QtQuick
|
||||
import qs.HyprChrome.Widgets.Polkit
|
||||
|
||||
// Offscreen render of the polkit prompt with a failed first attempt and two
|
||||
// eligible identities — the state that exercises every optional element at
|
||||
// once (picker, pam_error text, rejected-attempt border).
|
||||
//
|
||||
// ./tools/quickshell-preview/render.sh \
|
||||
// tests/PolkitPromptHeadless.qml \
|
||||
// .artifacts/quickshell-preview/polkit-prompt.png 560 320
|
||||
PolkitPromptContent {
|
||||
width: 520
|
||||
|
||||
message: "Authentication is required to install or remove software"
|
||||
actionId: "org.freedesktop.packagekit.package-install"
|
||||
iconName: "system-software-install"
|
||||
showIcon: false
|
||||
|
||||
identities: [
|
||||
{ id: "1000", displayName: "darman", isGroup: false },
|
||||
{ id: "0", displayName: "root", isGroup: false }
|
||||
]
|
||||
selectedIdentity: 0
|
||||
|
||||
responseRequired: true
|
||||
inputPrompt: "Password:"
|
||||
responseVisible: false
|
||||
response: "hunter2"
|
||||
|
||||
supplementaryMessage: "Authentication failure. 2 attempts remaining."
|
||||
supplementaryIsError: true
|
||||
failed: true
|
||||
}
|
||||
Generated
+60
-43
@@ -14,11 +14,11 @@
|
||||
"uv2nix": "uv2nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787577519,
|
||||
"narHash": "sha256-YNAXQTgR26RJiX2vtYjk6OtBu2jMWeq4qUN6sUrt6Lc=",
|
||||
"lastModified": 1789662015,
|
||||
"narHash": "sha256-CveW/4U/zNIRfLpH6vMv8qisfotft8XJC47KqAhViZ0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "authentik-nix",
|
||||
"rev": "30c37930450d7a5fefa8ffec613f037fc75c3071",
|
||||
"rev": "9a176c0a1889921f3ed19d29a047bafaf20b9a24",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -30,16 +30,16 @@
|
||||
"authentik-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784731584,
|
||||
"narHash": "sha256-/HdXzjjvuSW7zjbCNJKm3Fj8gvIwfrDf8mOYev0yuIg=",
|
||||
"lastModified": 1788978241,
|
||||
"narHash": "sha256-tAdDYHIur6ewVZlRz6JPtLivqctYLLtD0S8uehuuxSM=",
|
||||
"owner": "goauthentik",
|
||||
"repo": "authentik",
|
||||
"rev": "0c67ea476be6319f1b2a41cb0f5ed128af37b99b",
|
||||
"rev": "dce85a5b64a429206199e9ffddc602060643f17a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "goauthentik",
|
||||
"ref": "version/2026.5.6",
|
||||
"ref": "version/2026.8.2",
|
||||
"repo": "authentik",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -47,11 +47,11 @@
|
||||
"client-ts-generator-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784638510,
|
||||
"narHash": "sha256-NfwEWQ/SRjgeUz+F/7uoWAMwk7OqdF2+686krhvJn2M=",
|
||||
"lastModified": 1787926240,
|
||||
"narHash": "sha256-CNazk55jeMBdP/5cf9scshRGCiKALdERny8Oues1zcY=",
|
||||
"owner": "goauthentik",
|
||||
"repo": "client-ts",
|
||||
"rev": "5850af5867bef6fd4291731797d21b704c7f189d",
|
||||
"rev": "26b3e23c928e22e4aa66223b5b996e9e68047f0f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -101,11 +101,11 @@
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"lastModified": 1788450739,
|
||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -142,11 +142,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787377438,
|
||||
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
|
||||
"lastModified": 1789267039,
|
||||
"narHash": "sha256-LWiBv9yAYFi2LPbUhDGHPGKYskJQjj2fw12OlyO1uQo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
|
||||
"rev": "ec172013fa62135f58fb58dd17ae9651e8f39727",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -176,6 +176,22 @@
|
||||
"url": "https://git.mgaction.town/darman/hypr-chrome.git"
|
||||
}
|
||||
},
|
||||
"livesync-bridge": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1787571662,
|
||||
"narHash": "sha256-btLnQNbFzCPaSVcY9rtiPdYeXrZjoK9AYvfA9+ovsIc=",
|
||||
"owner": "vrtmrz",
|
||||
"repo": "livesync-bridge",
|
||||
"rev": "c3760beaa0851214da4860903445d7f6420ca025",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "vrtmrz",
|
||||
"repo": "livesync-bridge",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"media-manager": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
@@ -218,11 +234,11 @@
|
||||
},
|
||||
"nix-flatpak": {
|
||||
"locked": {
|
||||
"lastModified": 1783368811,
|
||||
"narHash": "sha256-0H8jDwR4Kegb3heaTrH1ftbgKfZVDT8JE+46uXxDy/Q=",
|
||||
"lastModified": 1789496567,
|
||||
"narHash": "sha256-f9ze1ph2u0lk/Hm7/w/OvC0ESL1z0LbQ5UDgcOwQzLM=",
|
||||
"owner": "gmodena",
|
||||
"repo": "nix-flatpak",
|
||||
"rev": "20d42f0ee98c9fe9f85e8d1de474f1409ed10d05",
|
||||
"rev": "07e8980c2fe421c93c0749f6246db34422827f67",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -270,11 +286,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787728766,
|
||||
"narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=",
|
||||
"lastModified": 1789567195,
|
||||
"narHash": "sha256-ymvPUaBvMmGvxsPbmrocpxO4XK+H+K12YjgKIyG3Rwk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-anywhere",
|
||||
"rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e",
|
||||
"rev": "1c2f124e970fed2a49bd14ce0a8b4e9bff74d3b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -291,11 +307,11 @@
|
||||
"nixos-unstable": "nixos-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787826771,
|
||||
"narHash": "sha256-gWkyr3I/cg4SHWGkAoUo24+BQaqoi+S0T2JxDjsA+pw=",
|
||||
"lastModified": 1789641344,
|
||||
"narHash": "sha256-vhB2KdEO1VlEfkiVoQAbMkKnTe1qxyJK3nbUsOOP4ok=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixos-images",
|
||||
"rev": "f6714acc84ce92df7286c89a571ad1e946057a5b",
|
||||
"rev": "16d7721cb350e7f1d0c49a757a3dcd11e80d91af",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -323,11 +339,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1786862985,
|
||||
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
|
||||
"lastModified": 1788316716,
|
||||
"narHash": "sha256-bc7rSpXIdn9QWGNqfWcPZWOhEVF8NoeAZkWq0XWnf/k=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
|
||||
"rev": "3ed67ec0a4d3c7ab4ae1f04f8ee8df07bfa506a2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -339,11 +355,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1785031560,
|
||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
||||
"lastModified": 1788057806,
|
||||
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
||||
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -354,11 +370,11 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1787814960,
|
||||
"narHash": "sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ=",
|
||||
"lastModified": 1789632929,
|
||||
"narHash": "sha256-RjR8AyvGlWuw16XRj7C1YDEw4E27ciuyPeB2nDMzgTU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c27cdad491a991b11ed731760aa2ef8db0cb0410",
|
||||
"rev": "a32edd7654519351e48e80372a928df336394670",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -370,11 +386,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1787753485,
|
||||
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||
"lastModified": 1789654592,
|
||||
"narHash": "sha256-vrwAiXmz+0hs/IWXZalFG/Ws6xQRcW+gClUDlfvcfjQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||
"rev": "ecc58f32d1069a3e3f8a991e2233d365689f748d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -490,6 +506,7 @@
|
||||
"disko": "disko",
|
||||
"home-manager": "home-manager",
|
||||
"hypr-chrome": "hypr-chrome",
|
||||
"livesync-bridge": "livesync-bridge",
|
||||
"mediamanager-nix": "mediamanager-nix",
|
||||
"nix-flatpak": "nix-flatpak",
|
||||
"nixos-anywhere": "nixos-anywhere",
|
||||
@@ -507,11 +524,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786629091,
|
||||
"narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=",
|
||||
"lastModified": 1789691124,
|
||||
"narHash": "sha256-k+I+R6uwHX3VcJ7326qLV6vCahZUgsVl+i8sSU/Stxk=",
|
||||
"owner": "Mic92",
|
||||
"repo": "sops-nix",
|
||||
"rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f",
|
||||
"rev": "1e73e8f7176d65e1b55e324de099bbfff4b2c574",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -584,11 +601,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786615403,
|
||||
"narHash": "sha256-U++y7nM/6xiEcWI7q4fQoPZjPvRaTwkSqzBOVoEBjUE=",
|
||||
"lastModified": 1788001239,
|
||||
"narHash": "sha256-AELmsXPI546MhbC/ZXC7WRUkCz7d4rqKTHUmliIgPpI=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "4b59abb2ae1896d2a0e1abfc47fbc9bf985ea730",
|
||||
"rev": "7f9c6b613d2e749e54854b1d60ab6a2192db889e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -31,27 +31,32 @@
|
||||
url = "github:strangeglyph/mediamanager-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
# Headless CouchDB<->filesystem sync for Obsidian LiveSync
|
||||
# (hosts/mars/livesync-bridge.nix); not a flake/not in nixpkgs, so plain
|
||||
# source pinned by flake.lock. Pin carefully — it's a small third-party
|
||||
# project with open storage->couchdb bugs, so an unreviewed bump could
|
||||
# silently change how notes get written back.
|
||||
livesync-bridge = {
|
||||
url = "github:vrtmrz/livesync-bridge";
|
||||
flake = false;
|
||||
};
|
||||
authentik-nix.url = "github:nix-community/authentik-nix";
|
||||
nix-flatpak.url = "github:gmodena/nix-flatpak";
|
||||
# Own Hyprland plugin (border + title bar), public repo, fetched over
|
||||
# https (no credentials needed, unlike tome below). `nixpkgs.follows` is
|
||||
# what makes its packaged build ABI-correct — Hyprland plugins are
|
||||
# ABI-locked to the exact Hyprland build they load into, so it has to be
|
||||
# built against THIS flake's own nixpkgs, not whatever hypr-chrome's own
|
||||
# flake.lock happens to pin standalone.
|
||||
# Own Hyprland plugin (border + title bar), public repo over https.
|
||||
# `nixpkgs.follows` is required since Hyprland plugins are ABI-locked to
|
||||
# the exact Hyprland build — it must share this flake's nixpkgs, not
|
||||
# whatever hypr-chrome's own lock pins standalone.
|
||||
hypr-chrome = {
|
||||
url = "git+https://git.mgaction.town/darman/hypr-chrome.git";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
# Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app.
|
||||
# Private repo on our own gitea; fetched over ssh with darman's ambient key,
|
||||
# same as any other git flake input. `flake = false`: it's a plain source
|
||||
# tree, not itself a flake. See pkgs/tome.nix.
|
||||
# Private repo on our own gitea, fetched over ssh with darman's ambient
|
||||
# key; plain source tree (`flake = false`), see pkgs/tome.nix.
|
||||
#
|
||||
# NOTE: the credential-less installer-iso can't fetch this (git+ssh needs
|
||||
# darman's key), so `./scripts/deploy install terra localhost` will fail
|
||||
# at nixos-install (post-disko) while this input is present. Known
|
||||
# tradeoff — re-removed this once before (4f79ec7) for the same reason.
|
||||
# NOTE: the credential-less installer-iso can't fetch this, so
|
||||
# `./scripts/deploy install terra localhost` fails at nixos-install
|
||||
# (post-disko) while this input is present — a known tradeoff.
|
||||
tome = {
|
||||
url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git";
|
||||
flake = false;
|
||||
@@ -111,8 +116,8 @@
|
||||
];
|
||||
};
|
||||
|
||||
# mars — on-site x86_64 box, single-purpose: Hermes Agent only.
|
||||
# See hosts/mars/*.
|
||||
# mars — on-site x86_64 box: Hermes Agent, plus the LAN web apps luna
|
||||
# hosts herself (hosts/mars/luna-sites.nix). See hosts/mars/*.
|
||||
mars = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = { inherit inputs; };
|
||||
@@ -124,10 +129,9 @@
|
||||
];
|
||||
};
|
||||
|
||||
# mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP. Boots from an SD image:
|
||||
# mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP; SD image via:
|
||||
# nix build .#nixosConfigurations.mercury.config.system.build.sdImage
|
||||
# (aarch64 build — needs binfmt/qemu on this x86 host, or a remote/aarch64
|
||||
# builder; substitutes most paths from cache.nixos.org.)
|
||||
# Needs binfmt/qemu for the aarch64 build on this x86 host (or a remote aarch64 builder).
|
||||
mercury = nixpkgs.lib.nixosSystem {
|
||||
system = "aarch64-linux";
|
||||
specialArgs = { inherit inputs; };
|
||||
@@ -197,16 +201,12 @@
|
||||
];
|
||||
};
|
||||
|
||||
# Bootable USB recovery installer with our SSH key + sshd + DHCP. Clones
|
||||
# the (now public) homelab repo fresh at every boot to /root/homelab —
|
||||
# always current master, so the same USB stick stays useful across
|
||||
# install/rescue occasions without ever needing a rebuild. No
|
||||
# rsync/copy-the-repo-over step: boot it, ssh in,
|
||||
# `cd /root/homelab && ./scripts/deploy install ...`.
|
||||
# Reusable for any host's manual-USB install path (jupiter, terra, ...).
|
||||
# Build the ISO:
|
||||
# nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage
|
||||
# dd it to a USB stick, boot the target from it, SSH in, ./deploy install.
|
||||
# Bootable USB recovery installer with our SSH key + sshd + DHCP; clones
|
||||
# the public homelab repo fresh at every boot to /root/homelab, so the
|
||||
# same stick stays current without a rebuild. Reusable for any host's
|
||||
# manual-USB install path.
|
||||
# Build: nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage,
|
||||
# dd to USB, boot the target, ssh in, ./scripts/deploy install ...
|
||||
installer-iso = nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
@@ -221,34 +221,18 @@
|
||||
console.keyMap = "de"; # matches common.nix's real hosts
|
||||
environment.systemPackages = [ pkgs.git ];
|
||||
|
||||
# findiso= is a SCRIPT-stage-1 feature (stage-1-init.sh) only. The
|
||||
# systemd initrd — the default since 26.05 — has no findiso path
|
||||
# at all: it mounts /iso straight from
|
||||
# /dev/disk/by-label/<volumeID> (iso-image.nix), which only exists
|
||||
# when the ISO is the physical boot medium. Booted as a kernel +
|
||||
# initrd off the ESP with the iso as a plain file elsewhere, that
|
||||
# label never appears and stage 1 times out into an emergency
|
||||
# shell (mounts /sysroot fine, then fails /sysroot/nix/.ro-store).
|
||||
# Script stage 1 instead loop-mounts the file findiso= points at
|
||||
# and symlinks it to /dev/root — which is the whole mechanism this
|
||||
# install path relies on. So force it off here.
|
||||
# The systemd initrd (default since 26.05) has no findiso= path — only
|
||||
# the legacy script stage-1 does — so this install method needs it off.
|
||||
boot.initrd.systemd.enable = false;
|
||||
|
||||
# installation-cd-minimal leaves experimental-features unset, so
|
||||
# the ISO's nix.conf has no `nix-command`/`flakes` at all (unlike
|
||||
# the nixos-images kexec installer, which sets
|
||||
# extra-experimental-features itself — which is why the same
|
||||
# `install <config> localhost` branch works after kexec-local but
|
||||
# not here). Without this, both `nix run .#disko` and
|
||||
# `nixos-install --flake` die with "experimental Nix feature
|
||||
# 'nix-command' is disabled".
|
||||
# installation-cd-minimal ships with experimental-features unset;
|
||||
# without this, both `nix run .#disko` and `nixos-install --flake`
|
||||
# die with "experimental Nix feature 'nix-command' is disabled".
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
|
||||
# Fresh clone of a PUBLIC repo — no credentials baked into the
|
||||
# ISO. require_tracked() in scripts/deploy still works fine here
|
||||
# (this IS a real git checkout, unlike the old baked-`self`
|
||||
# approach), but retry manually with `systemctl restart
|
||||
# homelab-checkout` if DHCP was still coming up at boot.
|
||||
# Fresh clone of the public repo (no credentials baked in) so
|
||||
# scripts/deploy's require_tracked() sees a real checkout; retry
|
||||
# with `systemctl restart homelab-checkout` if DHCP wasn't up yet.
|
||||
systemd.services.homelab-checkout = {
|
||||
description = "Clone the homelab repo to /root/homelab";
|
||||
after = [ "network-online.target" ];
|
||||
@@ -265,34 +249,24 @@
|
||||
'';
|
||||
};
|
||||
|
||||
# Finishes a local_install_prepare_and_reboot() run (scripts/deploy)
|
||||
# unattended: that function stages this ISO, points a systemd-boot
|
||||
# one-shot entry at it with `homelab.install=<config>` on the kernel
|
||||
# cmdline, and reboots. Once booted here, this re-runs the exact same
|
||||
# `./scripts/deploy install <config> localhost` command — now genuinely
|
||||
# inside the installer (hostname homelab-installer), so is_live_installer
|
||||
# takes the disko+nixos-install branch instead of preparing again.
|
||||
# A manual boot of this ISO with no such cmdline param is a no-op.
|
||||
# Completes an unattended local_install_prepare_and_reboot() run:
|
||||
# re-runs `./scripts/deploy install <config> localhost`, now genuinely
|
||||
# inside the installer so it takes the disko+nixos-install branch.
|
||||
# No-op if homelab.install= isn't on the kernel cmdline.
|
||||
systemd.services.homelab-auto-install = {
|
||||
description = "Auto-run the homelab install if homelab.install= was passed on the kernel cmdline";
|
||||
after = [ "homelab-checkout.service" ];
|
||||
requires = [ "homelab-checkout.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
# Full system PATH, not the restricted default a `path = [...]`
|
||||
# produces: this unit execs `./scripts/deploy`, whose
|
||||
# `#!/usr/bin/env bash` needs bash, and which then reaches for
|
||||
# nix / nixos-install / git / sudo / efibootmgr. The default
|
||||
# service PATH gave "env: 'bash': No such file or directory"
|
||||
# (status 127) before the script even started.
|
||||
# /run/current-system/sw/bin carries all of it on the installer;
|
||||
# /run/wrappers/bin for sudo. mkForce because NixOS otherwise
|
||||
# derives environment.PATH from `path` and that line would win.
|
||||
# Needs the full system PATH: scripts/deploy execs bash then shells
|
||||
# out to nix/nixos-install/git/sudo/efibootmgr, none of which a
|
||||
# restricted `path = [...]` PATH provides. mkForce overrides NixOS's
|
||||
# default PATH derivation from `path`.
|
||||
#
|
||||
# HOME too: systemd sets no $HOME for a service without User=
|
||||
# (systemd.exec(5): SetLoginEnvironment= defaults false), and
|
||||
# scripts/deploy runs under `set -u`, so a bare $HOME aborted the
|
||||
# whole run with an "unbound variable" that read like a bug.
|
||||
# HOME too: systemd sets no $HOME without User= (SetLoginEnvironment=
|
||||
# defaults false), and scripts/deploy runs under `set -u`, so a
|
||||
# missing $HOME aborted with a confusing "unbound variable".
|
||||
environment = {
|
||||
HOME = "/root";
|
||||
PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin";
|
||||
@@ -304,17 +278,11 @@
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Persist this whole run to a file that OUTLIVES the install.
|
||||
# The systemd journal is on the installer's tmpfs and dies with
|
||||
# the reboot, and by the time anything interesting fails disko
|
||||
# has already wiped the OS disk — so a failed attempt used to
|
||||
# leave nothing to debug. local_install_prepare_and_reboot()
|
||||
# (scripts/deploy) passes the STAGING partition's PARTUUID as
|
||||
# homelab.logpart=; that partition holds the iso and is on a
|
||||
# different disk from the one disko wipes, so it survives. The
|
||||
# actual install runs inside do_install() below so one tee at
|
||||
# the end captures all of it. Every step here is best-effort:
|
||||
# logging must never be the thing that breaks an install.
|
||||
# Persist this run to a file that outlives the install: the journal
|
||||
# dies with the reboot and disko wipes the OS disk before a failure
|
||||
# can be read back. homelab.logpart= points at the staging partition
|
||||
# (survives the wipe); every step here is best-effort so logging
|
||||
# itself can't break an install.
|
||||
logfile=""
|
||||
logpart=$(grep -o 'homelab\.logpart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
|
||||
if [ -n "$logpart" ]; then
|
||||
@@ -324,9 +292,8 @@
|
||||
if mount -o rw "$dev" /run/homelab-log 2>/dev/null; then
|
||||
logdir=/run/homelab-log
|
||||
elif where=$(findmnt -fno TARGET "$dev" 2>/dev/null) && [ -n "$where" ]; then
|
||||
# stage-1's findiso already holds this partition mounted
|
||||
# (that is how it reached the iso) — write into the existing
|
||||
# mount rather than trying to stack a second one on it.
|
||||
# stage-1's findiso already has this partition mounted (how it
|
||||
# reached the iso) — reuse that mount instead of a second one.
|
||||
mount -o remount,rw "$where" 2>/dev/null || true
|
||||
logdir="$where"
|
||||
fi
|
||||
@@ -346,12 +313,10 @@
|
||||
fi
|
||||
|
||||
do_install() {
|
||||
# The host key scripts/deploy seeds /etc/ssh with (so sops can
|
||||
# decrypt on boot #1) cannot live in this ISO: it is built from
|
||||
# a PUBLIC repo and the private keys are deliberately off-repo.
|
||||
# local_install_prepare_and_reboot() therefore drops it on the
|
||||
# boot partition and passes that partition's PARTUUID here.
|
||||
# That copy dies with the disko wipe a few minutes later.
|
||||
# The host key (so sops can decrypt on first boot) can't live in
|
||||
# this public-repo ISO; local_install_prepare_and_reboot() drops it
|
||||
# on the boot partition instead and passes that PARTUUID here — the
|
||||
# copy dies with disko's wipe minutes later.
|
||||
keypart=$(grep -o 'homelab\.keypart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
|
||||
if [ -n "$keypart" ]; then
|
||||
mkdir -p /run/homelab-key
|
||||
@@ -372,12 +337,10 @@
|
||||
fi
|
||||
fi
|
||||
|
||||
# On a box whose old bootloader had no one-shot (Limine on
|
||||
# terra), scripts/deploy got us here via a temporary UEFI
|
||||
# entry + BootNext (arm_efi_bootnext). BootNext is already
|
||||
# spent, but the entry itself would linger in NVRAM pointing
|
||||
# at a partition disko is about to reformat. Drop it now, so
|
||||
# even an install that fails later leaves NVRAM clean.
|
||||
# On bootloaders with no one-shot (Limine on terra), scripts/deploy
|
||||
# got here via a temporary UEFI entry + BootNext (arm_efi_bootnext);
|
||||
# BootNext is spent but the entry would linger pointing at a
|
||||
# partition disko is about to wipe, so remove it now.
|
||||
for n in $(efibootmgr 2>/dev/null \
|
||||
| sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\?[[:space:]]Homelab Installer[[:space:]].*/\1/p'); do
|
||||
echo "removing temporary UEFI entry Boot$n"
|
||||
@@ -407,175 +370,171 @@
|
||||
};
|
||||
};
|
||||
|
||||
# VM test for `./scripts/deploy kexec-local`. Run:
|
||||
# nix build .#checks.x86_64-linux.kexec-local -L
|
||||
#
|
||||
# Worth having because kexec-local is the one command that cannot be
|
||||
# rehearsed on real hardware: it jumps the machine you are typing at, and
|
||||
# a failure looks exactly like a slow boot. It regression-tests the
|
||||
# subtle one — kexec-run.sh backgrounds `sleep 6 && kexec -e` and returns,
|
||||
# so anything that cleans up the staging dir on exit deletes the binary
|
||||
# that performs the jump and the box silently never leaves the old kernel.
|
||||
#
|
||||
# After the jump the test driver's backdoor is gone with the old kernel,
|
||||
# so the installer is driven over a forwarded ssh port instead (the same
|
||||
# approach nixos-images uses in its own kexec test).
|
||||
checks.${system}.kexec-local =
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball;
|
||||
sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519";
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "deploy-kexec-local";
|
||||
# VM test for `./scripts/deploy kexec-local` (nix build .#checks.x86_64-linux.kexec-local -L)
|
||||
# — the one command that can't be rehearsed on real hardware since it jumps
|
||||
# the machine you're on. Regression-tests kexec-run.sh's backgrounded
|
||||
# `sleep 6 && kexec -e`: cleaning up the staging dir on exit would delete
|
||||
# the jump binary and the box would silently stay on the old kernel.
|
||||
checks.${system} = {
|
||||
kexec-local =
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball;
|
||||
sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519";
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "deploy-kexec-local";
|
||||
|
||||
nodes.machine = { modulesPath, ... }: {
|
||||
imports = [ (modulesPath + "/profiles/minimal.nix") ];
|
||||
virtualisation.vlans = [ ];
|
||||
# kexec-local refuses to run if RAM < 3x the installer image, and
|
||||
# the staging dir needs ~3x the tarball on /var/tmp.
|
||||
virtualisation.memorySize = 4 * 1024;
|
||||
virtualisation.diskSize = 12 * 1024;
|
||||
virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }];
|
||||
nodes.machine = { modulesPath, ... }: {
|
||||
imports = [ (modulesPath + "/profiles/minimal.nix") ];
|
||||
virtualisation.vlans = [ ];
|
||||
# kexec-local refuses to run if RAM < 3x the installer image, and
|
||||
# the staging dir needs ~3x the tarball on /var/tmp.
|
||||
virtualisation.memorySize = 4 * 1024;
|
||||
virtualisation.diskSize = 12 * 1024;
|
||||
virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }];
|
||||
|
||||
services.openssh.enable = true;
|
||||
users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ];
|
||||
services.openssh.enable = true;
|
||||
users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ];
|
||||
|
||||
# Everything the script shells out to, minus nix — the test uses the
|
||||
# HOMELAB_KEXEC_* hook so no build happens inside the VM.
|
||||
environment.systemPackages = with pkgs; [
|
||||
bash gnutar coreutils findutils util-linux cpio gzip
|
||||
];
|
||||
system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ];
|
||||
# Everything the script shells out to, minus nix — the test uses the
|
||||
# HOMELAB_KEXEC_* hook so no build happens inside the VM.
|
||||
environment.systemPackages = with pkgs; [
|
||||
bash gnutar coreutils findutils util-linux cpio gzip
|
||||
];
|
||||
system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ];
|
||||
|
||||
environment.etc."deploy".source = ./scripts/deploy;
|
||||
environment.etc."deploy".source = ./scripts/deploy;
|
||||
};
|
||||
|
||||
testScript = /* python */ ''
|
||||
import os, shutil, subprocess, tempfile, time
|
||||
|
||||
start_all()
|
||||
machine.wait_for_unit("sshd.service")
|
||||
|
||||
# ssh refuses a private key that is group/world readable, and nix
|
||||
# store paths are 0444 — copy it out and tighten the mode.
|
||||
keydir = tempfile.mkdtemp()
|
||||
key = os.path.join(keydir, "id_ed25519")
|
||||
shutil.copyfile("${sshKey}", key)
|
||||
os.chmod(key, 0o600)
|
||||
|
||||
def ssh(cmd, check=True, stdout=None):
|
||||
return subprocess.run(
|
||||
[ "${pkgs.openssh}/bin/ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=1",
|
||||
"-i", key,
|
||||
"-p", "2222", "root@127.0.0.1", "--" ] + cmd,
|
||||
text=True, check=check, stdout=stdout)
|
||||
|
||||
machine.succeed("install -Dm755 /etc/deploy /root/deploy")
|
||||
|
||||
# systemd-run starts units with a bare PATH lacking
|
||||
# /run/current-system/sw/bin, so bash (and tar/findmnt/nohup)
|
||||
# can't resolve — set it explicitly.
|
||||
env = (
|
||||
" --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin"
|
||||
" --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
|
||||
" --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
|
||||
" --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
|
||||
)
|
||||
# Same values for the foreground (non-systemd-run) invocation below.
|
||||
envsh = (
|
||||
"HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
|
||||
" HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
|
||||
" HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
|
||||
)
|
||||
|
||||
# Marker on a tmpfs: it must NOT survive the jump, proving we really
|
||||
# booted a new kernel rather than just restarting a service.
|
||||
machine.succeed("touch /run/pre-kexec-marker")
|
||||
host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip()
|
||||
|
||||
while ssh(["true"], check=False).returncode != 0:
|
||||
time.sleep(1)
|
||||
|
||||
# Refuses without --yes when stdin isn't a tty; needs the same env to
|
||||
# reach the confirmation prompt, else it dies early on the nix build
|
||||
# and proves nothing.
|
||||
out = machine.fail(f"{envsh} /root/deploy kexec-local </dev/null 2>&1")
|
||||
assert "using prebuilt kexec installer" in out, \
|
||||
f"never reached the prompt, so the refusal proves nothing:\n{out}"
|
||||
|
||||
# systemd-run so the call returns immediately: the script stays
|
||||
# alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`.
|
||||
machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes")
|
||||
|
||||
print("waiting for the jump...")
|
||||
deadline = time.time() + 300
|
||||
while ssh(["true"], check=False).returncode == 0:
|
||||
# Surface a dead unit immediately instead of stalling until the
|
||||
# deadline and blaming "never left the old kernel".
|
||||
st = ssh(["systemctl", "is-active", "kexec-local"],
|
||||
check=False, stdout=subprocess.PIPE).stdout or ""
|
||||
if st.strip() in ("failed", "inactive"):
|
||||
# NB: not `log` — the driver already binds that name to its
|
||||
# AbstractLogger and the type check rejects the shadowing.
|
||||
unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"],
|
||||
check=False, stdout=subprocess.PIPE).stdout or ""
|
||||
raise AssertionError(
|
||||
f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}")
|
||||
assert time.time() < deadline, "machine never left the old kernel"
|
||||
time.sleep(1)
|
||||
|
||||
print("waiting for the installer...")
|
||||
deadline = time.time() + 300
|
||||
while ssh(["true"], check=False).returncode != 0:
|
||||
assert time.time() < deadline, "installer never came up"
|
||||
time.sleep(1)
|
||||
|
||||
# It really is the RAM installer, not the old system.
|
||||
host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip()
|
||||
assert host == "nixos-installer", f"hostname is {host}, not nixos-installer"
|
||||
|
||||
assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \
|
||||
"old /run survived — this was not a fresh kernel"
|
||||
|
||||
# The host key is carried across (kexec-run.sh copies /etc/ssh into
|
||||
# the appended initrd), which is why `kexec` does no ssh-keygen -R.
|
||||
host_key_after = ssh(
|
||||
["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE
|
||||
).stdout.strip()
|
||||
assert host_key_before == host_key_after, \
|
||||
f"host key changed: {host_key_before} != {host_key_after}"
|
||||
|
||||
machine.crash()
|
||||
'';
|
||||
};
|
||||
|
||||
testScript = /* python */ ''
|
||||
import os, shutil, subprocess, tempfile, time
|
||||
|
||||
start_all()
|
||||
machine.wait_for_unit("sshd.service")
|
||||
|
||||
# ssh refuses a private key that is group/world readable, and nix
|
||||
# store paths are 0444 — copy it out and tighten the mode.
|
||||
keydir = tempfile.mkdtemp()
|
||||
key = os.path.join(keydir, "id_ed25519")
|
||||
shutil.copyfile("${sshKey}", key)
|
||||
os.chmod(key, 0o600)
|
||||
|
||||
def ssh(cmd, check=True, stdout=None):
|
||||
return subprocess.run(
|
||||
[ "${pkgs.openssh}/bin/ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=1",
|
||||
"-i", key,
|
||||
"-p", "2222", "root@127.0.0.1", "--" ] + cmd,
|
||||
text=True, check=check, stdout=stdout)
|
||||
|
||||
machine.succeed("install -Dm755 /etc/deploy /root/deploy")
|
||||
|
||||
# systemd-run starts units with a bare PATH that lacks
|
||||
# /run/current-system/sw/bin, so `#!/usr/bin/env bash` cannot even
|
||||
# resolve bash, let alone tar/findmnt/nohup. Set it explicitly.
|
||||
env = (
|
||||
" --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin"
|
||||
" --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
|
||||
" --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
|
||||
" --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
|
||||
)
|
||||
# Same values for the foreground (non-systemd-run) invocation below.
|
||||
envsh = (
|
||||
"HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
|
||||
" HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
|
||||
" HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
|
||||
)
|
||||
|
||||
# Marker on a tmpfs: it must NOT survive the jump, proving we really
|
||||
# booted a new kernel rather than just restarting a service.
|
||||
machine.succeed("touch /run/pre-kexec-marker")
|
||||
host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip()
|
||||
|
||||
while ssh(["true"], check=False).returncode != 0:
|
||||
time.sleep(1)
|
||||
|
||||
# Refuses without --yes when stdin is not a tty (read gets EOF).
|
||||
# Must reach the confirmation prompt, so it needs the same env —
|
||||
# otherwise it just dies early on the nix build and proves nothing.
|
||||
out = machine.fail(f"{envsh} /root/deploy kexec-local </dev/null 2>&1")
|
||||
assert "using prebuilt kexec installer" in out, \
|
||||
f"never reached the prompt, so the refusal proves nothing:\n{out}"
|
||||
|
||||
# systemd-run so the call returns immediately: the script stays
|
||||
# alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`.
|
||||
machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes")
|
||||
|
||||
print("waiting for the jump...")
|
||||
deadline = time.time() + 300
|
||||
while ssh(["true"], check=False).returncode == 0:
|
||||
# Surface a dead unit immediately instead of stalling until the
|
||||
# deadline and blaming "never left the old kernel".
|
||||
st = ssh(["systemctl", "is-active", "kexec-local"],
|
||||
check=False, stdout=subprocess.PIPE).stdout or ""
|
||||
if st.strip() in ("failed", "inactive"):
|
||||
# NB: not `log` — the driver already binds that name to its
|
||||
# AbstractLogger and the type check rejects the shadowing.
|
||||
unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"],
|
||||
check=False, stdout=subprocess.PIPE).stdout or ""
|
||||
raise AssertionError(
|
||||
f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}")
|
||||
assert time.time() < deadline, "machine never left the old kernel"
|
||||
time.sleep(1)
|
||||
|
||||
print("waiting for the installer...")
|
||||
deadline = time.time() + 300
|
||||
while ssh(["true"], check=False).returncode != 0:
|
||||
assert time.time() < deadline, "installer never came up"
|
||||
time.sleep(1)
|
||||
|
||||
# It really is the RAM installer, not the old system.
|
||||
host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip()
|
||||
assert host == "nixos-installer", f"hostname is {host}, not nixos-installer"
|
||||
|
||||
assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \
|
||||
"old /run survived — this was not a fresh kernel"
|
||||
|
||||
# The host key is carried across (kexec-run.sh copies /etc/ssh into
|
||||
# the appended initrd), which is why `kexec` does no ssh-keygen -R.
|
||||
host_key_after = ssh(
|
||||
["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE
|
||||
).stdout.strip()
|
||||
assert host_key_before == host_key_after, \
|
||||
f"host key changed: {host_key_before} != {host_key_after}"
|
||||
|
||||
machine.crash()
|
||||
'';
|
||||
# VM test for hosts/mars/luna-sites.nix (header of luna-sites-test.nix):
|
||||
# nix build .#checks.x86_64-linux.luna-sites -L
|
||||
luna-sites = import ./hosts/mars/luna-sites-test.nix {
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
};
|
||||
};
|
||||
|
||||
# `nix develop` — hot-reload loop for dotfiles/quickshell.
|
||||
#
|
||||
# hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`,
|
||||
# which COPIES the tree into the store, so ~/.config/quickshell is a
|
||||
# read-only symlink into /nix/store and every QML tweak costs a
|
||||
# nixos-rebuild. quickshell DOES hot-reload on file save — but only for
|
||||
# the files it is watching, which are those frozen store copies. Pointing
|
||||
# it at the working tree with `qs -p` restores edit-save-see, no rebuild.
|
||||
# hosts/terra/home.nix ships the shell as a store copy (`xdg.configFile`),
|
||||
# which only hot-reloads its own frozen files; pointing at the working
|
||||
# tree with `qs -p` restores edit-save-see without a rebuild.
|
||||
#
|
||||
# quickshell keys instance identity on the CONFIG PATH, so a working-tree
|
||||
# instance and the store-backed one are two different instances that would
|
||||
# both map layer-shell bars onto every output. Hence a swap, not a second
|
||||
# instance — and the swap starts dev FIRST, killing the packaged shell
|
||||
# only once dev is confirmed up, so a QML error in the working tree leaves
|
||||
# you on your normal bar instead of no bar at all.
|
||||
# quickshell keys instance identity on the config path, so the
|
||||
# working-tree and store-backed shells are different instances that
|
||||
# would both claim every output — hence a swap, not a second instance.
|
||||
# The swap starts dev first and only kills the packaged shell once dev
|
||||
# is confirmed up, so a QML error leaves you on your normal bar.
|
||||
#
|
||||
# Every kill is scoped to one config (`qs kill` = default only, `qs kill
|
||||
# -p` = that path only). A blanket kill would also take out unrelated
|
||||
# quickshell instances — pkgs/rishot.nix is one.
|
||||
# Every kill is scoped to one config (`qs kill` = default, `qs kill -p
|
||||
# <path>` = that path) since a blanket kill would also take out
|
||||
# unrelated instances like pkgs/rishot.nix.
|
||||
#
|
||||
# Deliberately NOT wired to direnv (no .envrc in this repo): programs.direnv
|
||||
# is enabled for this user, so a `use flake` would swap the running desktop
|
||||
# shell on every `cd` into the checkout, including over ssh.
|
||||
# Deliberately not wired to direnv: programs.direnv is enabled for this
|
||||
# user, so a `use flake` would swap the desktop shell on every `cd`
|
||||
# into the checkout, including over ssh.
|
||||
devShells.${system}.default =
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
@@ -632,10 +591,9 @@
|
||||
echo "qs-dev: live on $cfg — edits there now hot-reload"
|
||||
'';
|
||||
|
||||
# qs log -f prints everything the instance logs; WARN and ERROR are the
|
||||
# two that mean something is wrong with the QML in front of you. A
|
||||
# binding loop or a failed binding is a WARN and easy to miss when it
|
||||
# scrolls past inside a reload's worth of chatter.
|
||||
# qs log -f prints everything the instance logs; WARN/ERROR are what
|
||||
# mean something is actually wrong with the QML (a binding loop or
|
||||
# failed binding is a WARN, easy to miss in the reload chatter).
|
||||
qs-log = pkgs.writeShellScriptBin "qs-log" ''
|
||||
set -uo pipefail
|
||||
${preamble}
|
||||
@@ -645,12 +603,10 @@
|
||||
-a|--all) filter='.' ;;
|
||||
esac
|
||||
|
||||
# -t 1: `qs log -f` replays the whole backlog first, which would dump
|
||||
# every historical warning into the terminal on shell entry.
|
||||
#
|
||||
# `qs log -f` ends when the instance it attached to exits, and the dev
|
||||
# shell outlives individual instances — a QML error kills one, `qs-dev`
|
||||
# starts another. Re-attach instead of going quiet for the session.
|
||||
# -t 1: `qs log -f` otherwise replays the whole backlog on shell entry.
|
||||
# It also ends when the attached instance exits, and the dev shell
|
||||
# outlives individual instances (a QML error kills one, qs-dev starts
|
||||
# another) — so re-attach in a loop instead of going quiet for the session.
|
||||
while :; do
|
||||
if running "$cfg"; then
|
||||
${qs} log -p "$cfg" -t 1 -f 2>/dev/null | ${grep} --line-buffered -E "$filter" >&2
|
||||
|
||||
+4
-5
@@ -8,10 +8,9 @@
|
||||
programs.home-manager.enable = true;
|
||||
|
||||
# Matches terra's baseline (compinit, deduped/shared history, HISTFILE
|
||||
# under $HOME). home-manager owns ~/.zshrc + ~/.zshenv as real files, which
|
||||
# also means zsh's built-in zsh-newuser-install wizard never fires on
|
||||
# first interactive login (it only triggers when none of
|
||||
# .zshenv/.zprofile/.zshrc/.zlogin exist) — that used to happen on every
|
||||
# host except terra.
|
||||
# under $HOME). home-manager owning ~/.zshrc + ~/.zshenv as real files also
|
||||
# means zsh's newuser-install wizard never fires (it only triggers when
|
||||
# none of those dotfiles exist) — previously an issue on every host except
|
||||
# terra.
|
||||
programs.zsh.enable = true;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
../../services/media/seerr.nix
|
||||
../../services/media/immich.nix
|
||||
../../services/dev/gitea.nix
|
||||
../../services/dev/obsidian-livesync.nix
|
||||
];
|
||||
|
||||
# sabnzbd's unrar dependency is unfree; scope the allowance to just that
|
||||
@@ -39,16 +40,11 @@
|
||||
# systemd-boot for UEFI. If ZimaBlade boots legacy/BIOS, switch to grub.
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
# common.nix's cap of 5 comes from this box's own 34-generation incident,
|
||||
# but at ~5G free on a 29G eMMC even 5 is too many — override down to 2.
|
||||
# common.nix's default of 5 is still too many boot entries for a 29G eMMC — override down to 2.
|
||||
boot.loader.systemd-boot.configurationLimit = lib.mkForce 2;
|
||||
|
||||
# A `switch` pins the old generation as a GC root until the box reboots onto
|
||||
# the new one (booted-system vs current-system) — common.nix's nix.gc is
|
||||
# weekly, far too slow to catch that on a 29G eMMC. 2026-08-19: one switch
|
||||
# alone took 14G -> 19G used; only reboot (releases the old root) + this GC
|
||||
# brought it back to 14G. Run a full collect right after every boot instead
|
||||
# of waiting on the weekly timer.
|
||||
# A `switch` pins the old generation as a GC root until reboot; common.nix's weekly
|
||||
# nix.gc is too slow for a 29G eMMC, so collect garbage on every boot instead.
|
||||
systemd.services.gc-on-boot = {
|
||||
description = "Full nix-collect-garbage on every boot";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -69,46 +65,24 @@
|
||||
boot.kernelParams = [ "reboot=pci" ];
|
||||
|
||||
# ---- GPU (jellyfin hardware transcoding) ----
|
||||
# Apollo Lake N3450 / HD Graphics 500 (Gen9, pci 8086:5A85). The i915 KERNEL
|
||||
# driver binds on its own — /dev/dri/{card1,renderD128} exist without this —
|
||||
# but the libva USERSPACE driver only ships when hardware.graphics is on, and
|
||||
# nothing else here pulled it in. Without it VAAPI init fails with "unknown
|
||||
# libva error" and jellyfin-ffmpeg exits 251 on EVERY transcode, which the
|
||||
# client shows as generic playback failure: the server log only says "FFmpeg
|
||||
# exited with code 251", never that a driver is missing. Verified on the box:
|
||||
# the same h264_vaapi encode goes 251 -> 0 once iHD is on LIBVA_DRIVERS_PATH.
|
||||
#
|
||||
# iHD (intel-media-driver) is the right one for Gen9; i965 is for Gen8 and
|
||||
# older. Note the render node is 0666 but card1 is 0660 root:video, so the
|
||||
# group membership in services/media/jellyfin.nix matters for the card node.
|
||||
# Apollo Lake N3450 / HD Graphics 500 (Gen9). i915 binds on its own, but VAAPI needs
|
||||
# the iHD userspace driver (Gen9; i965 is Gen8-only) or jellyfin-ffmpeg exits 251 on
|
||||
# every transcode with no clearer error than "FFmpeg exited with code 251" in the log.
|
||||
hardware.graphics = {
|
||||
enable = true;
|
||||
extraPackages = [ pkgs.intel-media-driver ];
|
||||
};
|
||||
# ⚠️ This buys VAAPI only — jellyfin must be set to VAAPI, NOT QSV, in its
|
||||
# web UI (Dashboard -> Playback -> Transcoding). QSV needs an MFX runtime on
|
||||
# top of the libva driver: ffmpeg's `-init_hw_device qsv=qs@va` dies with
|
||||
# "Error creating a MFX session: -9" -> exit 171, the SECOND failure hiding
|
||||
# behind the first (fixing the missing driver only moved 251 -> 171).
|
||||
# There is no good way to provide it here: vpl-gpu-rt is Gen12+, and the
|
||||
# Gen9 runtime `intel-media-sdk` is marked INSECURE in nixpkgs (EOL, 5 CVEs
|
||||
# incl. local privilege escalation) — not worth it when VAAPI does the same
|
||||
# job on this chip at ~3.5x realtime for 1080p->720p.
|
||||
#
|
||||
# Also: 4K HDR (the 2160p HEVC/DV remuxes) can NOT be tone-mapped here.
|
||||
# tonemap_opencl needs OpenCL, which has no platform on this box, and
|
||||
# tonemap_vaapi is Gen11+ — both fail. Only a plain scale_vaapi=format=nv12
|
||||
# succeeds, which drops HDR without tone-mapping (washed-out picture).
|
||||
# Those files need to direct-play, or be kept as 1080p SDR versions.
|
||||
# ⚠️ Use VAAPI, not QSV, in jellyfin's UI — QSV needs an MFX runtime not safely
|
||||
# available for this Gen9 chip (only insecure/EOL options) and fails with exit 171.
|
||||
# 4K HDR remuxes also can't be tone-mapped here (needs OpenCL or Gen11+); keep those
|
||||
# as 1080p SDR or let them direct-play.
|
||||
|
||||
# ---- NAS data array ----
|
||||
# Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T).
|
||||
# Mounted, NOT formatted; kept out of disko so it is never wiped.
|
||||
# ⚠️ RAID0 = no redundancy: either 16TB disk failing loses ALL data.
|
||||
boot.swraid.enable = true; # assemble the mdadm array at boot
|
||||
# Silences "mdmon service will crash" eval warning. RAID0 here uses native
|
||||
# superblocks so mdmon (external-metadata arrays only) never actually runs,
|
||||
# but the module warns unconditionally without SOME MAILADDR/PROGRAM set.
|
||||
# Existing ext4 on mdadm RAID0 (sda+sdb, md0, 29.1T) — mounted, not formatted, kept
|
||||
# out of disko. ⚠️ RAID0 has no redundancy: either disk failing loses ALL data.
|
||||
boot.swraid.enable = true;
|
||||
# Silences the "mdmon service will crash" eval warning — mdmon never actually runs
|
||||
# here (native superblocks, not external-metadata) but the module warns regardless.
|
||||
boot.swraid.mdadmConf = "MAILADDR root";
|
||||
fileSystems."/mnt/data" = {
|
||||
# fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0.
|
||||
@@ -117,69 +91,48 @@
|
||||
options = [ "nofail" ]; # don't block boot if the array is degraded/absent
|
||||
};
|
||||
|
||||
# `nofail` above is necessary but NOT sufficient — any mount layered on the
|
||||
# array (prowlarr/seerr binds) is RequiredBy local-fs.target and will fail it
|
||||
# regardless, and emergency mode on this box is a dead end: root is locked, so
|
||||
# sulogin drops you at a prompt you cannot answer, with no ssh. 2026-08-06: a
|
||||
# drive that failed to enumerate after the rack move did exactly this —
|
||||
# "Timed out waiting for device /dev/disk/by-uuid/dadbff6f-…" -> Dependency
|
||||
# failed for Local File Systems -> Reached target Emergency Mode, twice.
|
||||
# Boot as far as possible instead and leave the failed units to be read over
|
||||
# ssh. The array-backed services carry RequiresMountsFor=/mnt/data so they
|
||||
# still refuse to start rather than writing to the eMMC.
|
||||
# `nofail` alone isn't enough — mounts layered on the array (prowlarr/seerr binds)
|
||||
# are RequiredBy local-fs.target and can still trip Emergency Mode, which is a dead
|
||||
# end here (root locked, no ssh). Boot as far as possible instead; the array-backed
|
||||
# services carry RequiresMountsFor=/mnt/data so they still won't write to the eMMC.
|
||||
systemd.enableEmergencyMode = false;
|
||||
|
||||
# ---- Heavy state moved off the eMMC ----
|
||||
# A deploy holds TWO full closures (~9G each) on a 29G disk at once, so the
|
||||
# OS disk has no room for state that grows on its own. 2026-08-09: it hit 0
|
||||
# bytes free with both gen 39 and gen 40 resident, and postgres died on
|
||||
# "No space left on device" — note ext4 reserves 5% for root, so non-root
|
||||
# services see zero while df still shows ~300M free.
|
||||
#
|
||||
# Paths live under /mnt/data/AppData like every other service's state. Both
|
||||
# settings below are jupiter-only on purpose: services/containers.nix stays
|
||||
# engine- and host-agnostic (mercury runs pihole on podman with no array).
|
||||
# A deploy holds two full closures (~9G each) on this 29G disk at once, so state
|
||||
# that grows on its own can't live there — moved under /mnt/data/AppData like every
|
||||
# other service's state. Settings below are jupiter-only; services/containers.nix
|
||||
# stays engine/host-agnostic (mercury runs podman with no array).
|
||||
|
||||
# podman: CI images dominate and keep growing — the gitea runner's
|
||||
# act-latest is 1.7G, and the act-22.04 label in services/dev/gitea.nix
|
||||
# pulls another ~1.7G the first time a job requests it.
|
||||
# runroot stays on /run: it is per-boot tmpfs state, not a growing store.
|
||||
# runroot stays on /run (per-boot tmpfs, doesn't grow); graphroot moves to the array
|
||||
# since the gitea runner's CI images alone run several GB.
|
||||
virtualisation.containers.storage.settings.storage = {
|
||||
driver = "overlay";
|
||||
graphroot = "/mnt/data/AppData/containers/storage";
|
||||
runroot = "/run/containers/storage";
|
||||
};
|
||||
|
||||
# immich's postgres cluster. Version component mirrors the upstream default
|
||||
# (`/var/lib/postgresql/${psqlSchema}`) so a major bump gets its own dir
|
||||
# instead of silently reusing the old cluster's files.
|
||||
# ⚠️ This puts the DB in the SAME failure domain as the photos it indexes:
|
||||
# /mnt/data is RAID0, so either 16TB disk now loses both, where before an
|
||||
# eMMC failure and an array failure each took only one. Chosen deliberately
|
||||
# — the two are useless apart — but neither is backed up.
|
||||
# immich's postgres cluster. Version-qualified path (matches upstream default) so a
|
||||
# major bump gets a fresh dir instead of reusing the old cluster's files.
|
||||
# ⚠️ Puts the DB in the same RAID0 failure domain as the photos it indexes —
|
||||
# deliberate (the two are useless apart) but neither is backed up.
|
||||
services.postgresql.dataDir =
|
||||
"/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}";
|
||||
|
||||
# /mnt/data/AppData is drwx--x--- darman:users, so postgres needs group
|
||||
# "users" just to TRAVERSE into its own dataDir — exactly the reason immich
|
||||
# has the same line. The cluster dir itself keeps the mode it was initdb'd
|
||||
# with (0750 postgres:postgres) — postgres only accepts 0700, or 0750 when
|
||||
# the cluster was created with group access, and refuses to start otherwise.
|
||||
# /mnt/data/AppData is drwx--x--- darman:users, so postgres needs the "users" group
|
||||
# just to traverse into its dataDir (same reason immich needs it) — postgres itself
|
||||
# refuses to start unless the cluster dir is 0700 or 0750.
|
||||
users.users.postgres.extraGroups = [ "users" ];
|
||||
|
||||
# Neither path is under /var/lib, so no module creates it: the postgresql
|
||||
# module's own tmpfiles entry only adjusts a dataDir that already exists,
|
||||
# the same way immich's mediaLocation rule does.
|
||||
# Neither path is under /var/lib, so no module creates it automatically — same
|
||||
# reason immich needs its own mediaLocation tmpfiles rule.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /mnt/data/AppData/postgresql 0750 postgres postgres -"
|
||||
"d /mnt/data/AppData/containers 0700 root root -"
|
||||
];
|
||||
|
||||
# graphroot is not a systemd path dependency the way dataDir is, so nothing
|
||||
# derives a mount ordering from it. Without these, podman would recreate an
|
||||
# empty store on the eMMC under the mountpoint when the array is late or
|
||||
# absent, and the runner would re-pull every image into it.
|
||||
# (podman-clonarr already carries this from services/media/clonarr.nix.)
|
||||
# Without this, podman would recreate an empty store on the eMMC if the array mounts
|
||||
# late or is absent, and the runner would re-pull every image.
|
||||
# (podman-clonarr already sets this in services/media/clonarr.nix.)
|
||||
systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
|
||||
|
||||
+28
-28
@@ -1,14 +1,8 @@
|
||||
{ config, ... }:
|
||||
|
||||
# sops-nix secret wiring (real host only; not imported by vm.nix).
|
||||
# Encrypted values live in ../../secrets/jupiter.yaml, decrypted at activation to
|
||||
# /run/secrets/<name>.
|
||||
#
|
||||
# The host decrypts with its OWN SSH host key (age identity derived via
|
||||
# ssh-to-age, recipient listed in ../../.sops.yaml). The key is pre-generated on
|
||||
# the laptop and shipped once at install as /etc/ssh/ssh_host_ed25519_key
|
||||
# (nixos-anywhere --extra-files) — so decryption works on boot #1 and there is
|
||||
# no separate sops-only key to manage.
|
||||
# sops-nix secret wiring (real host only; not imported by vm.nix). Decrypts with the
|
||||
# host's own SSH host key (ssh-to-age), shipped once at install via nixos-anywhere
|
||||
# --extra-files, so there's no separate sops-only key to manage.
|
||||
{
|
||||
sops.defaultSopsFile = ../../secrets/jupiter.yaml;
|
||||
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
|
||||
@@ -26,18 +20,14 @@
|
||||
# Headscale pre-auth key for tailscale auto-registration (see configuration.nix).
|
||||
sops.secrets.tailscale_authkey = { };
|
||||
|
||||
# Immich's OIDC client secret, from its Authentik application (a SEPARATE
|
||||
# app from headscale's and headplane's — see hosts/neptun/secrets.nix).
|
||||
# Referenced as settings.oauth.clientSecret._secret in
|
||||
# services/media/immich.nix; the module resolves it through systemd
|
||||
# LoadCredential, which reads as root before dropping privileges, so the
|
||||
# sops default of root:root 0400 is correct — do NOT set `owner`.
|
||||
# Immich's OIDC client secret (separate Authentik app from headscale/headplane, see
|
||||
# hosts/neptun/secrets.nix). Resolved via systemd LoadCredential as root before
|
||||
# privilege drop, so sops's default root:root 0400 is correct — do NOT set `owner`.
|
||||
sops.secrets.immich_oauth_client_secret = { };
|
||||
|
||||
# Gitea Actions runner registration token (services/dev/gitea.nix). Gitea
|
||||
# generates this itself once Actions is enabled — it is not a password
|
||||
# chosen up front. Rendered into a `TOKEN=...` env file because
|
||||
# gitea-actions-runner takes an EnvironmentFile, not a raw secret path.
|
||||
# Gitea Actions runner registration token — gitea generates this itself once Actions
|
||||
# is enabled. Rendered into an env file since gitea-actions-runner takes an
|
||||
# EnvironmentFile, not a raw secret path.
|
||||
sops.secrets.gitea_runner_token = { };
|
||||
sops.templates."gitea-runner.env".content =
|
||||
"TOKEN=${config.sops.placeholder.gitea_runner_token}";
|
||||
@@ -53,15 +43,11 @@
|
||||
owner = "gitea";
|
||||
};
|
||||
|
||||
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) —
|
||||
# migrated off the reused ini in services/media/sabnzbd.nix into
|
||||
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this
|
||||
# migration (provisioned for mediamanager's future use, services/experimental/
|
||||
# mediamanager.nix — not currently imported by any host); reused here as the
|
||||
# same single source of truth rather than duplicating it.
|
||||
# owner = sabnzbd: the module's preStart (replace-secret) runs as the
|
||||
# service's own User=/Group=, and sops secrets default to root:root 0400 —
|
||||
# without this, replace-secret gets Permission denied reading /run/secrets.
|
||||
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) for
|
||||
# services/media/sabnzbd.nix; sabnzbd_api_key is shared with
|
||||
# services/experimental/mediamanager.nix rather than duplicated.
|
||||
# owner = sabnzbd because the module's preStart runs as that user, and sops secrets
|
||||
# default to root:root 0400.
|
||||
sops.secrets.sabnzbd_web_username.owner = "sabnzbd";
|
||||
sops.secrets.sabnzbd_web_password.owner = "sabnzbd";
|
||||
sops.secrets.sabnzbd_api_key.owner = "sabnzbd";
|
||||
@@ -69,4 +55,18 @@
|
||||
sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd";
|
||||
sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd";
|
||||
|
||||
# CouchDB admin account for Obsidian LiveSync — rendered into an [admins] ini
|
||||
# fragment instead of services.couchdb.adminPass, which would put the plaintext in
|
||||
# the world-readable store.
|
||||
# owner = couchdb on both: couchdb re-reads the ini as its own user after privilege
|
||||
# drop, and without this sops's default root:root 0400 leaves it with no admin
|
||||
# configured (every request 401s).
|
||||
sops.secrets.couchdb_admin_password.owner = "couchdb";
|
||||
sops.templates."couchdb-admins.ini" = {
|
||||
owner = "couchdb";
|
||||
content = ''
|
||||
[admins]
|
||||
obsidian = ${config.sops.placeholder.couchdb_admin_password}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
# mars — on-site x86_64 box, single-purpose: runs Hermes Agent only.
|
||||
# See hermes-agent.nix for what that is and why it moved here from jupiter.
|
||||
# mars — on-site x86_64 box for Hermes Agent (luna), plus the web apps she
|
||||
# hosts herself. See hermes-agent.nix for what Hermes is and why it moved here
|
||||
# from jupiter, and luna-sites.nix for the app hosting.
|
||||
{
|
||||
imports = [
|
||||
./hardware-configuration.nix
|
||||
./disk-config.nix # disko: OS-disk partitions + filesystems
|
||||
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
|
||||
./hermes-agent.nix
|
||||
./livesync-bridge.nix
|
||||
./luna-sites.nix # luna's LAN web apps: http://mars.sol/<name>/
|
||||
../../common.nix # shared base: user / ssh / nix / firewall
|
||||
../../services/containers.nix
|
||||
../../services/vpn/tailscale.nix
|
||||
@@ -22,13 +25,12 @@
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
# jupiter's samba share (services/network/samba.nix) — mounted on demand so
|
||||
# mars doesn't stall boot/login when jupiter is off or unreachable. This is
|
||||
# also where Hermes's shared dropbox lives now (hermes-agent.nix). Modes are
|
||||
# tighter than terra's equivalent mount (0770 not 0755, gid=hermes not
|
||||
# gid=users) since the hermes-agent container (uid 986, gid 983 — no podman
|
||||
# userns remapping, see services/network/pihole.nix) needs group write into
|
||||
# it, not just darman.
|
||||
# jupiter's samba share (services/network/samba.nix), mounted on demand so
|
||||
# mars doesn't stall when jupiter is off — also where Hermes's shared
|
||||
# dropbox lives (hermes-agent.nix). Tighter modes than terra's equivalent
|
||||
# mount (0770/gid=hermes, not 0755/gid=users) since the hermes-agent
|
||||
# container (uid 986/gid 983, no podman userns remapping) needs group
|
||||
# write here, not just darman.
|
||||
fileSystems."/mnt/jupiter" = {
|
||||
device = "//jupiter/data";
|
||||
fsType = "cifs";
|
||||
@@ -40,11 +42,9 @@
|
||||
"dir_mode=0770"
|
||||
"nofail"
|
||||
"x-systemd.automount" # lazy-mount so boot doesn't stall if jupiter's down
|
||||
# NO idle-timeout here (unlike terra's equivalent mount): hermes-agent's
|
||||
# podman-hermes-agent.service RequiresMountsFor this path, so an idle
|
||||
# auto-unmount tears the container down with it — confirmed the hard
|
||||
# way, it killed the service ~60-70s after every start with no crash
|
||||
# or error, just "Unmounting /mnt/jupiter" right before the stop.
|
||||
# NO idle-timeout here (unlike terra's): podman-hermes-agent.service
|
||||
# RequiresMountsFor this path, so an idle auto-unmount silently kills
|
||||
# the container with it — confirmed the hard way (~60-70s per start).
|
||||
"x-systemd.mount-timeout=10s"
|
||||
"_netdev"
|
||||
];
|
||||
|
||||
+217
-284
@@ -1,84 +1,46 @@
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
# Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix,
|
||||
# see its git history / b5fa599 / 713d91d for the terra->jupiter->mars
|
||||
# lineage). mars is dedicated to this one service, on-site, with no big
|
||||
# data array of its own — unlike jupiter it has nothing under /mnt/data, so
|
||||
# state lives on the local OS disk and the shared dropbox rides jupiter's
|
||||
# samba share as a CIFS client instead of being served locally.
|
||||
# Hermes Agent runs on mars, which has no big data array — state lives on the
|
||||
# local OS disk, and the shared dropbox reaches jupiter's array as a CIFS
|
||||
# client instead of being served locally.
|
||||
#
|
||||
# Runs the OFFICIAL published image (docker.io/nousresearch/hermes-agent —
|
||||
# real and actively maintained, contrary to what the checked-out repo's own
|
||||
# README/docker-compose.yml suggested; verified directly on Docker Hub) as a
|
||||
# plain podman container. It never sets HERMES_MANAGED or writes .managed, so
|
||||
# Hermes fully self-manages config.yaml, profiles, memories and skills at
|
||||
# runtime — no redeploy needed except to bump the pinned digest below.
|
||||
# Runs the official docker.io/nousresearch/hermes-agent image (verified on
|
||||
# Docker Hub) as a plain podman container. It never sets HERMES_MANAGED, so
|
||||
# Hermes fully self-manages config.yaml, profiles, memories and skills.
|
||||
#
|
||||
# Security posture:
|
||||
# - Reachable paths: its own local state dir, the small shared "dropbox"
|
||||
# (via the jupiter samba mount) for darman to hand files to Hermes, and
|
||||
# `git`/`tea`, logged in as the `luna` gitea account (PR-tier only —
|
||||
# see services/dev/gitea.nix). No working copy of this repo is
|
||||
# provisioned for her: an earlier version cloned one into
|
||||
# ${hermesHome}/workspace/homelab, dropped again because nothing ever
|
||||
# told her at runtime where it was (she self-manages config/profiles/
|
||||
# memories, so a host-side path in this file never reached her) — she
|
||||
# searched /opt/data/homelab and /workspace, found neither, and
|
||||
# concluded she had no repo at all. She can clone one herself if she
|
||||
# wants; the credentials below are what actually grants the access.
|
||||
# Nothing else on jupiter's array or the host is reachable if a
|
||||
# command goes wrong or gets injected via Telegram/tool output.
|
||||
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
|
||||
# TELEGRAM_ALLOWED_USERS.
|
||||
# - Runs as a rootful podman container (services/containers.nix) with its
|
||||
# OWN numeric uid/gid — not darman, who is in the "hermes" group for
|
||||
# host-level debugging only (`hermes ...` alias below, needs sudo since
|
||||
# the container itself runs under root's podman, not darman's rootless
|
||||
# one).
|
||||
# - git/tea access is direct CLI, not a narrow wrapper: darman explicitly
|
||||
# chose this over a purpose-built MCP server (tried first, scrapped —
|
||||
# see git history) in favor of simplicity. The backstop is entirely
|
||||
# server-side: gitea's branch protection on `master` (only darman can
|
||||
# push/merge/approve there) is what actually keeps a bad or injected
|
||||
# command from reaching the base branch, not anything client-side here.
|
||||
# Security posture: reachable paths are only Hermes's own state dir, the
|
||||
# shared dropbox, and git/tea as the PR-tier `luna` gitea account (see
|
||||
# services/dev/gitea.nix) — no working copy of this repo is provisioned, and
|
||||
# nothing else on jupiter's array or host is reachable if a command goes
|
||||
# wrong or gets injected via Telegram/tool output. It runs its own Telegram
|
||||
# bot with an explicit TELEGRAM_ALLOWED_USERS, and as a rootful podman
|
||||
# container under its own uid/gid (not darman's). git/tea access is direct
|
||||
# CLI rather than a wrapper; the real backstop is server-side gitea branch
|
||||
# protection on `master` (only darman can push/merge/approve), not anything
|
||||
# client-side here.
|
||||
#
|
||||
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on
|
||||
# jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth
|
||||
# provider registered, and 0.0.0.0 (not loopback) is required so neptun's
|
||||
# Caddy can reach it over tailscale0 — reachability itself stays LAN-closed
|
||||
# (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a
|
||||
# trustedInterface, services/vpn/tailscale.nix). Public route: neptun's
|
||||
# hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this
|
||||
# over the tailnet. mars runs no Caddy of its own (single-purpose box), so
|
||||
# there is no LAN vhost — reach the dashboard directly via mars's tailnet
|
||||
# name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging.
|
||||
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik like jupiter's; it
|
||||
# fails closed without a registered auth provider. Binds 0.0.0.0:9119 (not
|
||||
# loopback) so neptun's Caddy can reach it over tailscale0, but stays
|
||||
# LAN-closed since there's no firewall rule opening it — reach it directly at
|
||||
# mars.orbit.sol:9119 or via the public hermes.mgaction.town vhost on neptun.
|
||||
# Uses upstream's generic self-hosted OIDC plugin against the same Authentik
|
||||
# application (slug `hermes`) as before.
|
||||
#
|
||||
# Uses upstream's generic self-hosted OIDC plugin, same Authentik
|
||||
# application as before (slug `hermes`) — the client ID/secret didn't need
|
||||
# to change since the public redirect URI (hermes.mgaction.town) didn't.
|
||||
#
|
||||
# Data migration: this starts with a FRESH state dir. jupiter's instance was
|
||||
# itself reset to fresh on 2026-08-21 (see its old hermes-agent.nix), so
|
||||
# there was nothing irreplaceable to carry forward; if that turns out to be
|
||||
# wrong, jupiter's old data is backed up at
|
||||
# /mnt/data/AppData/hermes.bak-2026-08-21 and can be rsynced into
|
||||
# ${hermesHome} below before the first switch on mars.
|
||||
# Starts with a fresh state dir — jupiter's instance was already reset to
|
||||
# fresh on 2026-08-21, so nothing needed carrying forward. Its old data is
|
||||
# backed up at /mnt/data/AppData/hermes.bak-2026-08-21 if that's ever wrong.
|
||||
let
|
||||
stateDir = "/var/lib/hermes";
|
||||
hermesHome = "${stateDir}/.hermes";
|
||||
# Shared drop-in folder: darman can put files here from any host. Lives on
|
||||
# jupiter's array (reachable at /mnt/jupiter, the samba mount below) rather
|
||||
# than locally, so it's the same physical location it always was — only
|
||||
# the container reading it moved. Mounted under /opt/data so it falls
|
||||
# inside Hermes's own sealed write-safe root (HERMES_WRITE_SAFE_ROOT=
|
||||
# /opt/data) rather than a path its own tooling would treat as untrusted.
|
||||
# Shared drop-in folder for darman to hand files to Hermes, on jupiter's
|
||||
# array (CIFS mount below) rather than locally. Mounted under /opt/data so
|
||||
# it's inside Hermes's own write-safe root (HERMES_WRITE_SAFE_ROOT).
|
||||
dropboxDir = "/mnt/jupiter/AppData/hermes-dropbox";
|
||||
|
||||
# Pinned by digest (captured 2026-08-21 via `podman image inspect
|
||||
# docker.io/nousresearch/hermes-agent:latest --format '{{.Digest}}'` on
|
||||
# jupiter) rather than floating `:latest`, so a redeploy is reproducible —
|
||||
# bumping Hermes is an explicit edit here, not silent drift on next pull.
|
||||
hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259";
|
||||
# v2026.9.14, pinned by index digest rather than floating
|
||||
# :latest, so bumping Hermes is an explicit edit here, not silent drift.
|
||||
hermesImage = "docker.io/nousresearch/hermes-agent@sha256:99641e57ec762c59e54cb44aa6746b7fc68c18b3c5ddb088af54234c613d9294";
|
||||
|
||||
# Kept identical to jupiter's instance purely so nothing else needs to
|
||||
# change if state ever gets migrated over.
|
||||
@@ -90,15 +52,10 @@ let
|
||||
# is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data.
|
||||
giteaHost = "git.mgaction.town";
|
||||
|
||||
# luna's webhook filters, mounted READ-ONLY below. They live in the nix store
|
||||
# rather than being written into hermesHome because hermesHome IS
|
||||
# HERMES_WRITE_SAFE_ROOT: a filter dropped there is a loop guard sitting
|
||||
# inside the writable root of the agent it constrains, and she could edit
|
||||
# it back out. Deleting it would fail closed (Hermes treats a missing
|
||||
# script as "ignore"), but rewriting it to always-allow would silently
|
||||
# restore the reply loop. Read-only from the store makes that impossible
|
||||
# and keeps the guard versioned in git — same reasoning as the git/tea
|
||||
# binaries mounted below.
|
||||
# luna's webhook filters, mounted READ-ONLY from the nix store rather than
|
||||
# written into hermesHome: that IS her write-safe root, so a writable copy
|
||||
# would let her edit her own loop guard back out. A missing script fails
|
||||
# closed (Hermes ignores it); read-only from the store rules out a rewrite.
|
||||
prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" (
|
||||
builtins.readFile ./gitea-pr-comment-filter.py
|
||||
);
|
||||
@@ -106,13 +63,10 @@ let
|
||||
builtins.readFile ./gitea-pr-review-filter.py
|
||||
);
|
||||
|
||||
# The route prompts. These are NOT mounted into the container: the route
|
||||
# config below embeds them as strings, and jq reads them from these store
|
||||
# paths host-side with --rawfile. Keeping them in files rather than inline
|
||||
# nix strings is still what makes that work — they are ~60 lines of markdown
|
||||
# full of apostrophes and {placeholders} that would otherwise have to
|
||||
# survive nix string escaping on the way into a shell command. --rawfile
|
||||
# crosses all of that untouched, and they stay diffable in git.
|
||||
# Route prompts: not mounted into the container, but embedded as strings by
|
||||
# the route config below via jq --rawfile, which lets ~60 lines of markdown
|
||||
# full of apostrophes/{placeholders} skip nix string escaping and stay
|
||||
# diffable in git.
|
||||
prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" (
|
||||
builtins.readFile ./gitea-pr-comment-prompt.md
|
||||
);
|
||||
@@ -120,96 +74,81 @@ let
|
||||
builtins.readFile ./gitea-pr-review-prompt.md
|
||||
);
|
||||
|
||||
# Mnemosyne memory provider: third-party plugin, not in the image.
|
||||
mnemosyneEnv = pkgs.callPackage ../../pkgs/mnemosyne-env.nix { };
|
||||
|
||||
# Wire event names (X-GitHub-Event) each route accepts — NOT the
|
||||
# subscription names the gitea hooks in services/dev/gitea.nix use. The two
|
||||
# namespaces collide; see the long comment on the route unit below.
|
||||
prCommentEvents = [ "issue_comment" ];
|
||||
prReviewEvents = [ "pull_request_comment" "pull_request_rejected" ];
|
||||
|
||||
# Toolsets granted to both routes' agent runs.
|
||||
#
|
||||
# Hermes defaults webhook runs to a deliberately narrow set (web_search,
|
||||
# web_extract, vision_analyze, clarify) because a webhook payload is
|
||||
# third-party content. That default cannot clone, edit or push, so neither
|
||||
# prompt was executable under it: the run would be woken, read the comment,
|
||||
# and have no way to act on it.
|
||||
#
|
||||
# This list REPLACES the platform default for these routes rather than
|
||||
# merging with it, so anything the default provided has to be re-listed —
|
||||
# "web" is here for that reason, not because the prompts ask for research.
|
||||
#
|
||||
# Upstream's stated boundary is that `hermes webhook subscribe` has no
|
||||
# --toolsets flag, so "an agent creating its own subscription at runtime
|
||||
# cannot self-grant terminal". That boundary does NOT hold here and must not
|
||||
# be relied on: webhook_subscriptions.json lives under /opt/data, which is
|
||||
# HERMES_WRITE_SAFE_ROOT, so luna can edit her own grant — she already did
|
||||
# once, which is why this moved into nix. What this buys is that the grant
|
||||
# is deliberate, reviewable and re-asserted on every restart, not that it is
|
||||
# unforgeable. The real backstop stays server-side: gitea's branch
|
||||
# protection on master.
|
||||
# Toolsets granted to both routes' agent runs. Hermes's webhook default
|
||||
# (web_search, web_extract, vision_analyze, clarify) has no shell/file/edit
|
||||
# access, so neither prompt could act without this — and it REPLACES the
|
||||
# default rather than merging, hence "web" being re-listed. luna could in
|
||||
# principle self-grant via webhook_subscriptions.json (it's under her own
|
||||
# HERMES_WRITE_SAFE_ROOT, and she has edited it before), so this only makes
|
||||
# the grant reviewable and reasserted on restart, not unforgeable — the
|
||||
# real backstop stays gitea's branch protection on master.
|
||||
routeToolsets = [ "terminal" "file" "web" ];
|
||||
|
||||
# hermesHome as the CONTAINER sees it (the bind mount below). Anything
|
||||
# written host-side that gets READ back inside the container must use this
|
||||
# prefix, not hermesHome — see the credential.helper below, which was
|
||||
# broken exactly that way from 3c1f3e5 until 2026-08-23.
|
||||
# hermesHome as the CONTAINER sees it. Anything written host-side that gets
|
||||
# READ back inside the container must use this prefix, not hermesHome.
|
||||
containerHome = "/opt/data";
|
||||
in
|
||||
{
|
||||
# Browsing convenience (ssh access to the bind-mounted local state) — does
|
||||
# NOT touch the container, which keeps using HERMES_UID/GID above
|
||||
# regardless of what's declared here.
|
||||
# ssh browsing convenience only — the container still uses HERMES_UID/GID
|
||||
# above regardless of this.
|
||||
users.groups.hermes.gid = 983;
|
||||
users.users.darman.extraGroups = [ "hermes" ];
|
||||
|
||||
# `hermes <args>` on mars == `sudo podman exec -it hermes-agent hermes <args>`.
|
||||
# sudo is required: virtualisation.oci-containers runs rootful (system)
|
||||
# podman, a separate namespace from darman's own rootless `podman`/`docker`
|
||||
# — darman's "hermes"/"docker" group membership only grants filesystem
|
||||
# access to the bind-mounted state dir, not to root's container socket.
|
||||
# `hermes <args>` == `sudo podman exec -it hermes-agent hermes <args>`. sudo
|
||||
# is needed because oci-containers runs rootful podman, a separate
|
||||
# namespace from darman's own rootless one.
|
||||
programs.zsh.shellAliases.hermes = "sudo podman exec -it hermes-agent hermes";
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${stateDir} 0750 root hermes -"
|
||||
];
|
||||
|
||||
# podman requires the bind-mount source to already exist (no auto-create),
|
||||
# and the dropbox lives on the CIFS mount below — mkdir there works fine
|
||||
# over cifs, no server-side (jupiter) config needed.
|
||||
# podman needs the bind-mount sources to exist first; the dropbox lives on
|
||||
# the CIFS mount below, which is fine to mkdir into directly.
|
||||
#
|
||||
# Also provisions luna's git/tea access: writes a git credential-store file
|
||||
# and runs `tea logins add` INTO hermesHome (i.e. paths that appear at
|
||||
# /opt/data/... once the container is up). Both run on the HOST as root,
|
||||
# before the container starts, and both therefore have to chown what they
|
||||
# write themselves — see the chown at the end of the script. Do NOT assume
|
||||
# the image's cont-init fixes ownership under hermesHome: it does not
|
||||
# recurse into what this oneshot drops there, even though it runs after it.
|
||||
# Also provisions luna's git/tea access as root, before the container
|
||||
# starts, and chowns what it writes itself — the image's cont-init only
|
||||
# fixes ownership of hermesHome's top level, not what this oneshot drops
|
||||
# into it. No longer clones the repo for her (see the header); the version
|
||||
# that did left a stale ${hermesHome}/workspace/homelab that this does not
|
||||
# clean up.
|
||||
#
|
||||
# It deliberately does NOT clone the repo for her any more (see the
|
||||
# header). The stale ${hermesHome}/workspace/homelab left behind by the
|
||||
# version that did is not cleaned up here either — it just stops being
|
||||
# managed, and stops being updated. Remove it by hand if you want it gone.
|
||||
#
|
||||
# Delete-then-add for the tea login (not a "does it exist" check): tea can
|
||||
# leave a login entry behind even when `add` reports failure (e.g. a token
|
||||
# missing a scope errors out AFTER the entry is written — observed
|
||||
# directly against the real instance during the first version of this
|
||||
# setup). Delete-then-add is idempotent either way and picks up a rotated
|
||||
# Delete-then-add for the tea login, not an existence check: tea can leave
|
||||
# a login entry behind even when `add` itself reports failure, so
|
||||
# delete-then-add is the only idempotent option and picks up a rotated
|
||||
# token for free.
|
||||
#
|
||||
# `tea logins add` is the only network call here, and ordering matters:
|
||||
# switch-to-configuration restarts NetworkManager in the same pass as this
|
||||
# unit, and on 2026-09-11 that raced badly enough to hang the unit for
|
||||
# minutes and take the whole container down. Hence network-online.target,
|
||||
# the bounded probe below, and TimeoutStartSec as a backstop.
|
||||
systemd.services.hermes-agent-prepare-dirs = {
|
||||
description = "Create Hermes state dirs + luna's git/tea access before the container starts";
|
||||
before = [ "podman-hermes-agent.service" ];
|
||||
wantedBy = [ "podman-hermes-agent.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
||||
path = [ pkgs.git pkgs.tea ];
|
||||
path = [ pkgs.git pkgs.tea pkgs.curl pkgs.coreutils ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
# Everything here is either local or bounded to ~30s by the probe loop, so
|
||||
# anything past two minutes is a hang, not slowness.
|
||||
serviceConfig.TimeoutStartSec = "120";
|
||||
script = ''
|
||||
mkdir -p ${hermesHome}
|
||||
mkdir -p ${dropboxDir}
|
||||
# Parent for the read-only filters bind-mounted at
|
||||
# /opt/data/scripts/gitea-pr-*-filter.py. /opt/data is itself a bind
|
||||
# mount of hermesHome, so this directory has to exist HOST-side before
|
||||
# podman can mount a file inside it.
|
||||
# Parent dir for the read-only filters bind-mounted below; must exist
|
||||
# host-side first since /opt/data is itself a bind mount of hermesHome.
|
||||
mkdir -p ${hermesHome}/scripts
|
||||
|
||||
export HOME=${hermesHome}
|
||||
@@ -223,38 +162,52 @@ in
|
||||
install -m 0600 /dev/null ${hermesHome}/.git-credentials
|
||||
printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \
|
||||
> ${hermesHome}/.git-credentials
|
||||
# containerHome, NOT hermesHome: git reads this .gitconfig from INSIDE
|
||||
# the container, where the host path does not exist. Nothing host-side
|
||||
# consumes these credentials any more (the clone that used to is gone),
|
||||
# so the container's view is the only one that has to be right.
|
||||
# containerHome, not hermesHome: git reads this .gitconfig from inside
|
||||
# the container, and nothing host-side needs it any more.
|
||||
git config --global credential.helper "store --file=${containerHome}/.git-credentials"
|
||||
git config --global user.name "luna"
|
||||
git config --global user.email "luna@${giteaHost}"
|
||||
|
||||
tea logins delete luna 2>/dev/null || true
|
||||
GITEA_SERVER_TOKEN="$(cat "$token_file")" tea logins add \
|
||||
--name luna --url "https://${giteaHost}" --no-version-check
|
||||
# A bare TCP connect to an interface still coming up can hang ~3min on
|
||||
# kernel SYN retries, and tea has no timeout flag, so probe first with a
|
||||
# hard per-attempt timeout. /api/v1/version is unauthenticated (tests
|
||||
# reachability only). Probing before touching the login (rather than
|
||||
# retrying the add) protects it: delete-then-add isn't atomic, so an add
|
||||
# that fails on a down network would leave luna with no login at all.
|
||||
gitea_up=0
|
||||
for attempt in 1 2 3; do
|
||||
if curl -fsS --max-time 5 -o /dev/null "https://${giteaHost}/api/v1/version"; then
|
||||
gitea_up=1
|
||||
break
|
||||
fi
|
||||
echo "${giteaHost} unreachable (attempt $attempt/3); retrying in 5s" >&2
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Hand everything written above to the container's uid/gid. This does
|
||||
# NOT happen by itself: the image's cont-init only chowns hermesHome's
|
||||
# top level and its own state, so root-owned 0600 files dropped here by
|
||||
# this oneshot (.git-credentials, and tea's config.yml — tea writes it
|
||||
# 0600 too) are simply unreadable to uid ${hermesUid}. Symptom is not an
|
||||
# error but an absence: git reports no credential helper and tea reports
|
||||
# no login, i.e. "they're missing". Confirmed on the real instance
|
||||
# 2026-08-23 — cont-init ran AFTER these files were written and left
|
||||
# them root-owned regardless.
|
||||
if [ "$gitea_up" = 1 ]; then
|
||||
# Reachable but still failing means a real problem (revoked/under-
|
||||
# scoped token) — stays fatal since it won't fix itself on reboot.
|
||||
tea logins delete luna 2>/dev/null || true
|
||||
GITEA_SERVER_TOKEN="$(cat "$token_file")" timeout 60 tea logins add \
|
||||
--name luna --url "https://${giteaHost}" --no-version-check
|
||||
else
|
||||
# Not fatal: everything else here is local, and podman-hermes-agent
|
||||
# Requires= this unit — failing here would take Telegram/dashboard
|
||||
# down over a transient blip instead of just the tea CLI.
|
||||
echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2
|
||||
fi
|
||||
|
||||
# Hand written files to the container's uid/gid: the image's cont-init
|
||||
# only chowns hermesHome's top level, so root-owned files dropped here
|
||||
# (confirmed on 2026-08-23) are otherwise unreadable to Hermes.
|
||||
#
|
||||
# `if`, not `[ -d x ] && chown`: this script runs under `set -e`, where
|
||||
# a false test as the left side of an && list takes the whole list's
|
||||
# non-zero status and aborts the unit.
|
||||
# `if`, not `[ -d x ] && chown`: this script runs under `set -e`, and a
|
||||
# false test on the left of && would abort the whole unit.
|
||||
chown ${hermesUid}:${hermesGid} \
|
||||
${hermesHome}/.gitconfig \
|
||||
${hermesHome}/.git-credentials
|
||||
# Same cont-init caveat as the files above: the directory is created
|
||||
# here as root, and Hermes reads its scripts as uid ${hermesUid}. The
|
||||
# mounted filters themselves are world-readable 0444 from the store, so
|
||||
# only the directory needs handing over.
|
||||
# Same cont-init caveat: this dir is created as root, and Hermes reads
|
||||
# scripts as uid ${hermesUid}.
|
||||
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts
|
||||
|
||||
if [ -d ${hermesHome}/.config ]; then
|
||||
@@ -278,18 +231,18 @@ in
|
||||
"${hermesHome}:/opt/data"
|
||||
"${dropboxDir}:/opt/data/dropbox"
|
||||
|
||||
# git/tea for luna: the image doesn't ship `tea` (and shouldn't be
|
||||
# trusted to have a known-good `git` either), so both come from this
|
||||
# host's Nix store instead — mounted read-only at fixed PATH-visible
|
||||
# locations. /nix/store itself has to come along too since both
|
||||
# binaries are dynamically linked against paths inside it; the store
|
||||
# is read-only content-addressed build output, not a source of
|
||||
# secrets, so mounting the whole thing read-only costs nothing beyond
|
||||
# the two specific binaries actually being reachable.
|
||||
# Read-only: see prCommentFilter above. Hermes resolves route scripts
|
||||
# under ~/.hermes/scripts, which is /opt/data/scripts in here. The route
|
||||
# prompts are NOT mounted — they are embedded in the route config the
|
||||
# unit below writes, so nothing inside the container reads them.
|
||||
# luna's Obsidian vault, synced with CouchDB on jupiter by
|
||||
# livesync-bridge.nix. Under /opt/data so she can write notes, not just
|
||||
# read them; the bridge runs as this same uid/gid so no chown is needed.
|
||||
"/var/lib/livesync-bridge/vault:/opt/data/vault"
|
||||
|
||||
# git/tea for luna: the image ships neither (and its own git shouldn't
|
||||
# be trusted), so both come from this host's Nix store, read-only.
|
||||
# /nix/store must come along too since both binaries are dynamically
|
||||
# linked against it.
|
||||
# Filters mounted read-only (see prCommentFilter above), where Hermes
|
||||
# resolves route scripts (~/.hermes/scripts). Prompts are NOT mounted —
|
||||
# they're embedded directly in the route config the unit below writes.
|
||||
"${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro"
|
||||
"${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro"
|
||||
|
||||
@@ -302,16 +255,12 @@ in
|
||||
HERMES_GID = hermesGid;
|
||||
TZ = "Europe/Berlin";
|
||||
|
||||
# Point git/tea at the config the prepare-dirs oneshot wrote into
|
||||
# hermesHome (visible here as /opt/data/...) — the credential-store
|
||||
# helper, the luna gitea login, and (implicitly, via HOME not being
|
||||
# overridden) darman's Hermes state stays wherever it already was.
|
||||
# Points git/tea at the config prepare-dirs wrote into hermesHome
|
||||
# (visible here as /opt/data/...).
|
||||
GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig";
|
||||
XDG_CONFIG_HOME = "/opt/data/.config";
|
||||
# HERMES_TIMEZONE is the highest-priority source hermes_time.py checks
|
||||
# (ahead of config.yaml's `timezone` key) — the container has no host
|
||||
# /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in
|
||||
# 9403122 on jupiter; carried forward here).
|
||||
# Highest-priority source hermes_time.py checks; without it the
|
||||
# container defaults to UTC (no /etc/localtime bind-mount).
|
||||
HERMES_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
# Dashboard + Authentik OIDC gate — see the file-level comment above.
|
||||
@@ -319,14 +268,11 @@ in
|
||||
HERMES_DASHBOARD_HOST = "0.0.0.0"; # must be tailscale0-reachable, not just loopback
|
||||
HERMES_DASHBOARD_OIDC_ISSUER = "https://auth.mgaction.town/application/o/hermes/";
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6";
|
||||
# uvicorn's proxy_headers=True (web_server.py) only trusts
|
||||
# X-Forwarded-Proto from forwarded_allow_ips, which defaults to
|
||||
# 127.0.0.1 — neptun's Caddy reaches this over the tailnet (a real
|
||||
# routed IP), so without this the dashboard sees the raw scheme (http)
|
||||
# and builds an http:// redirect_uri that Authentik rejects against its
|
||||
# registered https:// one. Safe to trust any peer here: 9119 is already
|
||||
# scoped to loopback + tailscale0 only (no LAN firewall rule), so
|
||||
# nothing untrusted can reach this process to begin with.
|
||||
# uvicorn only trusts X-Forwarded-Proto from forwarded_allow_ips
|
||||
# (default 127.0.0.1); neptun's Caddy reaches this over a real routed
|
||||
# tailnet IP, so without this it builds an http:// redirect_uri that
|
||||
# Authentik rejects. Safe to trust any peer: 9119 is already scoped to
|
||||
# loopback + tailscale0 only.
|
||||
FORWARDED_ALLOW_IPS = "*";
|
||||
};
|
||||
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
|
||||
@@ -342,35 +288,19 @@ in
|
||||
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
|
||||
};
|
||||
|
||||
# The two Gitea webhook routes, written as config rather than created with
|
||||
# `hermes webhook subscribe`.
|
||||
# The two Gitea webhook routes, written as config (not via `hermes webhook
|
||||
# subscribe`, which has no --toolsets flag — see routeToolsets above).
|
||||
# Gitea posts directly to Hermes with X-Hub-Signature-256 and
|
||||
# X-GitHub-Event, which is what Hermes validates against and reads the
|
||||
# event name from.
|
||||
#
|
||||
# Gitea posts straight at Hermes (jupiter's gitea-hermes-webhook-provision
|
||||
# registers one hook per route at http://mars.orbit.sol:8644/webhooks/<name>)
|
||||
# — there is no relay in between. Gitea's addDefaultHeaders sends
|
||||
# X-Hub-Signature-256 in GitHub's exact format AND X-GitHub-Event,
|
||||
# unconditionally, for every webhook type, which is precisely what Hermes
|
||||
# validates and reads the event name from.
|
||||
# Written host-side into hermesHome (bind-mounted at /opt/data), so the
|
||||
# webhook adapter hot-reloads it on the next delivery — no container
|
||||
# restart needed.
|
||||
#
|
||||
# WHY NOT `hermes webhook subscribe`: it has no --toolsets flag, and without
|
||||
# a toolset override a webhook run gets Hermes's constrained default
|
||||
# (web_search, web_extract, vision_analyze, clarify) — no shell, no file
|
||||
# access, so neither prompt below can actually be carried out. Upstream's
|
||||
# documented answer is to write the `toolsets` key into
|
||||
# webhook_subscriptions.json by hand. Doing that by hand does not survive
|
||||
# this unit, which re-provisions on every start, so the whole route
|
||||
# definition moves here instead and the CLI is not used at all. See
|
||||
# routeToolsets above for what that costs.
|
||||
#
|
||||
# This writes the file HOST-side. hermesHome is bind-mounted at /opt/data,
|
||||
# so the container sees the same inode, and the webhook adapter hot-reloads
|
||||
# the file (mtime-gated) on the next delivery — no container restart, and no
|
||||
# `podman exec` quoting chain between nix and the prompt text.
|
||||
#
|
||||
# Events are WIRE names (X-GitHub-Event). Gitea spells the same events three
|
||||
# different ways and two of the spellings collide — from
|
||||
# HookEventType.Event() in modules/webhook/type.go, and updateHookEvents in
|
||||
# routers/api/v1/utils/hook.go for the api column:
|
||||
# Events below are WIRE names (X-GitHub-Event), not the api names
|
||||
# gitea.nix's hooks use — gitea spells the same events three ways and two
|
||||
# spellings collide:
|
||||
#
|
||||
# HookEventType wire name (here) api name (gitea.nix)
|
||||
# --------------------------- ---------------------- --------------------
|
||||
@@ -380,46 +310,31 @@ in
|
||||
# pull_request_review_rejected pull_request_rejected pull_request_review
|
||||
# pull_request_review_approved pull_request_approved pull_request_review
|
||||
#
|
||||
# Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So
|
||||
# "pull_request_comment" HERE means a review and "issue_comment" HERE means
|
||||
# a comment — the exact inversion of how they read. X-GitHub-Event-Type
|
||||
# carries the HookEventType, but Hermes does not look at it. This file and
|
||||
# services/dev/gitea.nix therefore name the same event differently on
|
||||
# purpose; neither is a typo.
|
||||
# So "pull_request_comment" HERE means a review and "issue_comment" HERE
|
||||
# means a comment — neither this file nor gitea.nix has a typo.
|
||||
#
|
||||
# The api column is not a third alias but a coarser set: HasEvent
|
||||
# (models/webhook/webhook.go) collapses all three review types onto
|
||||
# pull_request_review, so the gitea hook cannot subscribe them separately.
|
||||
# Approvals arrive here as a result and are dropped by NOT being in
|
||||
# prReviewEvents — Hermes answers {"status": "ignored"} on the event match,
|
||||
# before the filter script and before any LLM call. Widening to approvals is
|
||||
# a mars-side change only: add "pull_request_approved" to prReviewEvents and
|
||||
# "pull_request_review_approved" to the filter's ALLOWED_REVIEW_TYPES.
|
||||
# api names collapse all three review types onto pull_request_review, so
|
||||
# approvals can't be subscribed separately — they arrive here and are
|
||||
# dropped by omission from prReviewEvents. Widen by adding
|
||||
# "pull_request_approved" here and to the filter's ALLOWED_REVIEW_TYPES.
|
||||
#
|
||||
# issue_comment on the wire covers comments on plain issues too; the hook
|
||||
# does not subscribe those, and the comment filter's is_pull check drops
|
||||
# them anyway if the hook is ever widened.
|
||||
# issue_comment on the wire also covers plain-issue comments; the comment
|
||||
# filter's is_pull check drops those if the hook is ever widened.
|
||||
#
|
||||
# deliver is "log", not a chat target: both prompts tell her to answer in
|
||||
# the pull request, so the PR comment IS the delivery.
|
||||
# deliver is "log", not a chat target — both prompts answer directly in the
|
||||
# pull request.
|
||||
#
|
||||
# `script` is the selection that MUST NOT be retunable at runtime.
|
||||
# gitea-pr-comment-filter.py drops luna's own comments before any LLM call,
|
||||
# which is what stops the reply loop: the prompt tells her to answer on the
|
||||
# PR, and her answer is itself a pull_request_comment. Both filters are
|
||||
# bind-mounted read-only from the store above so the agent cannot edit her
|
||||
# own guard out. Hermes resolves the name relative to ~/.hermes/scripts,
|
||||
# hence the bare filename.
|
||||
# `script` must not be retunable at runtime: the filter drops luna's own
|
||||
# comments before any LLM call (what stops the reply loop, since her PR
|
||||
# answer is itself a pull_request_comment), and is mounted read-only so she
|
||||
# can't edit her own guard out.
|
||||
#
|
||||
# What read-only does NOT buy: it protects the sources, and this unit
|
||||
# re-asserts prompt, filter, events and toolsets from them on every start,
|
||||
# so a restart restores the intended config. The live file is inside the
|
||||
# agent's own write-safe root, so a self-modification sticks until this unit
|
||||
# next runs.
|
||||
# Read-only protects the source only — this unit re-asserts prompt, filter,
|
||||
# events and toolsets on every start, so a live self-modification only
|
||||
# sticks until the next restart.
|
||||
#
|
||||
# Routes this unit does not name are left alone (the merge below is
|
||||
# per-key), so retiring an old one stays a deliberate one-off:
|
||||
# sudo podman exec hermes-agent hermes webhook remove <name>
|
||||
# Routes not named here are left alone (the merge below is per-key);
|
||||
# retire one with `sudo podman exec hermes-agent hermes webhook remove <name>`.
|
||||
systemd.services.hermes-agent-webhook-routes = {
|
||||
description = "Write Hermes's Gitea webhook route config";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -445,26 +360,19 @@ in
|
||||
tmp="$conf.new"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
|
||||
# --slurpfile below cannot read a file that does not exist. Creating it
|
||||
# empty is safe: this only ever happens before the first run, when there
|
||||
# are no routes to lose. If it exists but is not valid JSON, slurpfile
|
||||
# fails the unit loudly and leaves it untouched, which is the right
|
||||
# direction — better a failed unit than silently discarded routes.
|
||||
# --slurpfile needs the file to exist; empty is safe pre-first-run.
|
||||
# Invalid JSON fails the unit loudly and leaves it untouched — better a
|
||||
# failed unit than silently discarded routes.
|
||||
[ -e "$conf" ] || printf '%s\n' '{}' > "$conf"
|
||||
|
||||
# The secret reaches jq via --rawfile, never argv: /proc/<pid>/cmdline
|
||||
# is world-readable, so `--arg secret "$(cat ...)"` would publish it to
|
||||
# every user on the box for the lifetime of the process. Same reason the
|
||||
# prompts come in by path rather than by value.
|
||||
#
|
||||
# sops stores this one without a trailing newline (see secrets.nix), but
|
||||
# rtrimstr is kept anyway: a stray newline would silently change the key
|
||||
# the HMAC is computed with and fail every delivery afterwards.
|
||||
#
|
||||
# The emptiness guards are load-bearing. Without them a truncated secret
|
||||
# file or an unreadable prompt yields "", and the route is written with
|
||||
# an empty secret — which fails EVERY signature check while the unit
|
||||
# still reports success.
|
||||
# Secret goes to jq via --rawfile, never argv (cmdline is world
|
||||
# readable) — same reason the prompts come in by path, not value.
|
||||
# sops stores this without a trailing newline, but rtrimstr guards
|
||||
# against one anyway: it would silently change the HMAC key.
|
||||
# The emptiness guards are load-bearing: without them a truncated
|
||||
# secret or unreadable prompt yields "", and the route is written with
|
||||
# an empty secret that fails every signature check while reporting
|
||||
# success.
|
||||
jq -n \
|
||||
--slurpfile existing "$conf" \
|
||||
--rawfile rawSecret "$SECRET_FILE" \
|
||||
@@ -488,12 +396,9 @@ in
|
||||
deliver: "log",
|
||||
toolsets: $toolsets };
|
||||
|
||||
# created_at is cosmetic (hermes webhook list prints it) and is the
|
||||
# one key carried over from whatever is already there, so it keeps
|
||||
# reading as when the route first appeared rather than as the last
|
||||
# deploy. Everything else is replaced outright: a leftover key from
|
||||
# an earlier definition — or from a hand edit — would otherwise
|
||||
# survive here forever.
|
||||
# created_at is cosmetic and the only key carried over from any
|
||||
# existing route; everything else is replaced outright so a
|
||||
# leftover key from an earlier definition cannot survive here.
|
||||
def upsert($name; $r):
|
||||
.[$name] = ($r + { created_at: (.[$name].created_at // (now | todate)) });
|
||||
|
||||
@@ -507,14 +412,42 @@ in
|
||||
$reviewEvents; $reviewPrompt; "gitea-pr-review-filter.py"))
|
||||
' > "$tmp"
|
||||
|
||||
# 0600 because the file holds the HMAC secret in cleartext, and owned by
|
||||
# the container's uid because Hermes rewrites it itself whenever anything
|
||||
# calls `hermes webhook subscribe`. mv is an atomic rename within the
|
||||
# same directory, so a delivery landing mid-write never reads a half
|
||||
# 0600: holds the HMAC secret in cleartext. Owned by the container's
|
||||
# uid since Hermes rewrites this file itself on `webhook subscribe`.
|
||||
# mv is an atomic rename, so a delivery mid-write never sees a half
|
||||
# written config.
|
||||
chmod 0600 "$tmp"
|
||||
chown ${hermesUid}:${hermesGid} "$tmp"
|
||||
mv -f "$tmp" "$conf"
|
||||
'';
|
||||
};
|
||||
|
||||
# Hermes discovers memory providers under $HERMES_HOME/plugins; the target
|
||||
# is a store path, readable in the container via the /nix/store ro mount.
|
||||
# wantedBy, not requiredBy: a failure leaves Hermes on built-in memory.
|
||||
systemd.services.hermes-agent-mnemosyne-plugin = {
|
||||
description = "Link Mnemosyne provider into the Hermes plugin dir";
|
||||
before = [ "podman-hermes-agent.service" ];
|
||||
wantedBy = [ "podman-hermes-agent.service" ];
|
||||
after = [ "hermes-agent-prepare-dirs.service" ];
|
||||
requires = [ "hermes-agent-prepare-dirs.service" ];
|
||||
path = [ pkgs.coreutils ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
pluginsDir=${hermesHome}/plugins
|
||||
pluginDir=$pluginsDir/mnemosyne
|
||||
target=${mnemosyneEnv}/${mnemosyneEnv.sitePackages}/hermes_memory_provider
|
||||
[ -d "$target" ] || { echo "$target missing" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$pluginsDir"
|
||||
chown ${hermesUid}:${hermesGid} "$pluginsDir"
|
||||
ln -sfn "$target" "$pluginDir.new"
|
||||
mv -Tf "$pluginDir.new" "$pluginDir"
|
||||
chown -h ${hermesUid}:${hermesGid} "$pluginDir"
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
{ config, pkgs, inputs, ... }:
|
||||
|
||||
# livesync-bridge (vrtmrz) — mirrors an Obsidian LiveSync vault out of CouchDB
|
||||
# on jupiter (services/dev/obsidian-livesync.nix) into real markdown files
|
||||
# here, since Obsidian itself is a GUI-only Electron app and luna needs files.
|
||||
#
|
||||
# ⚠️ THE WRITE-BACK PATH IS THE RISKY ONE: upstream has open bugs where a
|
||||
# write is logged as uploaded but the database is never updated (#50), only
|
||||
# lowercase filenames sync from storage (#23), and files over ~30KB silently
|
||||
# stall (#46) — all fail quietly with no error in the log. Don't treat this
|
||||
# directory as durable for anything luna can't regenerate, and verify her
|
||||
# edits actually reach your devices. (E2EE itself is fine — it hard-errors on
|
||||
# a missing passphrase rather than failing silently.)
|
||||
#
|
||||
# EXPECTED NOISE ON FIRST SYNC: a stack trace per historically-deleted file —
|
||||
# CouchDB replays deletion tombstones against a directory where the file
|
||||
# never existed. Harmless, caught and logged, and stops once the initial
|
||||
# catch-up ends.
|
||||
#
|
||||
# Talks to CouchDB over the tailnet (jupiter.orbit.sol:5984) directly — mars
|
||||
# is a tailnet node, so neptun's public vhost/TLS/allowlist don't apply here.
|
||||
let
|
||||
stateDir = "/var/lib/livesync-bridge";
|
||||
appDir = "${stateDir}/app";
|
||||
vaultDir = "${stateDir}/vault";
|
||||
|
||||
# The same uid/gid hermes-agent runs as (hermes-agent.nix), so both peers
|
||||
# share files without depending on umask — two uids in a shared group only
|
||||
# works while every file stays group-writable, and one 0644 file from the
|
||||
# agent would silently stall sync.
|
||||
hermesUid = 986;
|
||||
|
||||
# `group` pairs the two peers — mismatched and the bridge starts but never
|
||||
# syncs.
|
||||
#
|
||||
# ⚠️ `database` must match the name entered in the Obsidian plugin exactly:
|
||||
# get it wrong and nothing errors, since the admin credential below lets
|
||||
# PouchDB just create the misnamed database and replicate an empty vault.
|
||||
peerGroup = "luna";
|
||||
database = "luna_wiki";
|
||||
in
|
||||
{
|
||||
# hermes-agent.nix declares the group (gid 983) but no user — the container
|
||||
# needs no host account, but this service does, so it's declared here.
|
||||
users.users.hermes = {
|
||||
uid = hermesUid;
|
||||
group = "hermes";
|
||||
isSystemUser = true;
|
||||
home = stateDir;
|
||||
description = "Hermes agent uid, shared with the livesync-bridge service";
|
||||
};
|
||||
|
||||
# Created here, not by the service, so they exist before anything needs
|
||||
# them: vaultDir before podman-hermes-agent starts (else podman creates it
|
||||
# as root:root), and appDir before ExecStartPre runs (WorkingDirectory
|
||||
# applies to it too).
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${vaultDir} 0770 hermes hermes -"
|
||||
"d ${appDir} 0750 hermes hermes -"
|
||||
"d ${stateDir}/deno 0750 hermes hermes -"
|
||||
];
|
||||
|
||||
# Rendered by sops (three inline secrets: CouchDB password + both
|
||||
# passphrases; the json format has no include mechanism).
|
||||
#
|
||||
# ⚠️ sops substitutes into the ALREADY-RENDERED json, so a secret with a
|
||||
# quote or backslash yields invalid config — the bridge then just sits with
|
||||
# zero peers logging "Could not parse configuration!" instead of exiting.
|
||||
# Keep all three values alphanumeric.
|
||||
sops.templates."livesync-bridge.json" = {
|
||||
owner = "hermes";
|
||||
content = builtins.toJSON {
|
||||
peers = [
|
||||
{
|
||||
type = "couchdb";
|
||||
name = "luna-remote";
|
||||
group = peerGroup;
|
||||
url = "http://jupiter.orbit.sol:5984";
|
||||
inherit database;
|
||||
username = "obsidian";
|
||||
password = config.sops.placeholder.couchdb_luna_password;
|
||||
passphrase = config.sops.placeholder.obsidian_luna_passphrase;
|
||||
# Same secret as the content passphrase — the plugin derives path
|
||||
# obfuscation from it too, but the bridge takes them as separate
|
||||
# fields. If paths come back as garbage while contents decode fine,
|
||||
# this is the field to check.
|
||||
obfuscatePassphrase = config.sops.placeholder.obsidian_luna_passphrase;
|
||||
# Reads the chunking tweaks the plugin stored in the remote, instead
|
||||
# of guessing sizes that then disagree with every other client.
|
||||
useRemoteTweaks = true;
|
||||
baseDir = "";
|
||||
}
|
||||
{
|
||||
type = "storage";
|
||||
name = "luna-vault";
|
||||
group = peerGroup;
|
||||
baseDir = vaultDir;
|
||||
# Catch up on anything that changed while the service was down.
|
||||
scanOfflineChanges = true;
|
||||
useChokidar = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.livesync-bridge = {
|
||||
description = "Obsidian LiveSync bridge (CouchDB <-> ${vaultDir})";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" "tailscaled.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = {
|
||||
# Persistent module + npm cache. Without a fixed DENO_DIR the service
|
||||
# re-downloads its whole dependency tree on every start.
|
||||
DENO_DIR = "${stateDir}/deno";
|
||||
# main.ts reads this instead of ./dat/config.json, which keeps the
|
||||
# secret out of the copied source tree entirely.
|
||||
LSB_CONFIG = config.sops.templates."livesync-bridge.json".path;
|
||||
LSB_HEALTH_FILE = "${stateDir}/health.json";
|
||||
HOME = stateDir;
|
||||
};
|
||||
|
||||
# Copies the pinned source out of the store and installs locked deps,
|
||||
# since deno.jsonc's `nodeModulesDir: manual` (byonm) needs to write
|
||||
# node_modules/ next to the sources — it can't run from /nix/store directly.
|
||||
#
|
||||
# The copy target is a FIXED path on purpose: Deno keys its localStorage
|
||||
# (where the bridge tracks per-file sync state) by the main module's
|
||||
# origin, so running straight from /nix/store would change that origin —
|
||||
# and reset the bridge to a full rescan of both peers — on every input bump.
|
||||
#
|
||||
# Guarded by a stamp file: a no-op on ordinary restarts, only a flake
|
||||
# input bump pays for the (networked) re-install.
|
||||
preStart = ''
|
||||
set -eu
|
||||
stamp=${stateDir}/.src
|
||||
if [ "$(cat "$stamp" 2>/dev/null || true)" != "${inputs.livesync-bridge}" ]; then
|
||||
# Contents only — appDir is this unit's WorkingDirectory, and
|
||||
# deleting the cwd out from under deno breaks the install below.
|
||||
find ${appDir} -mindepth 1 -delete
|
||||
cp -r ${inputs.livesync-bridge}/. ${appDir}/
|
||||
chmod -R u+w ${appDir}
|
||||
${pkgs.deno}/bin/deno install --frozen
|
||||
printf '%s' "${inputs.livesync-bridge}" > "$stamp"
|
||||
fi
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
User = "hermes";
|
||||
Group = "hermes";
|
||||
StateDirectory = "livesync-bridge";
|
||||
WorkingDirectory = appDir;
|
||||
# `deno task run` is `deno run -A main.ts`; invoked directly so the
|
||||
# task runner is not in the supervision path.
|
||||
ExecStart = "${pkgs.deno}/bin/deno run -A main.ts";
|
||||
# main.ts installs an unhandledrejection guard, but a genuinely dead
|
||||
# process should still come back rather than trip the start limit.
|
||||
Restart = "always";
|
||||
RestartSec = 30;
|
||||
# Group-writable output, so the two identities stay interchangeable if
|
||||
# the uid sharing above is ever unpicked.
|
||||
UMask = "0007";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Hosting your web apps on mars
|
||||
|
||||
You can run web apps as containers and publish them on the home network at
|
||||
`http://mars.sol/<name>/`, without anyone changing mars's configuration.
|
||||
Everything below takes effect immediately — no restart, no redeploy.
|
||||
|
||||
This file is mounted read-only and is rewritten on every restart. Save what
|
||||
you need from it to your memory.
|
||||
|
||||
## How it fits together
|
||||
|
||||
- `podman` in your shell does not run containers next to you. It talks,
|
||||
through `$CONTAINER_HOST`, to a separate unprivileged account on mars
|
||||
(`luna-apps`). Containers there keep running when you restart, and come
|
||||
back after mars reboots if they were started with `--restart=always`.
|
||||
- Caddy on mars routes `http://mars.sol/<name>/` to the port you name in
|
||||
`/opt/data/sites/<name>.json`. A service on mars checks that file and
|
||||
writes the outcome to `/opt/data/sites-status.txt`.
|
||||
|
||||
## Publish an app
|
||||
|
||||
1. Put the source under `/opt/data/apps/<name>/` with a `Containerfile` (or
|
||||
`Dockerfile`), and build it. The directory is uploaded, so this works from
|
||||
where you are:
|
||||
|
||||
podman build -t localhost/<name> /opt/data/apps/<name>
|
||||
|
||||
2. Run it. Publish its port on `127.0.0.1` only, using a host port between
|
||||
@portMin@ and @portMax@ that no other app uses (`podman ps` shows the
|
||||
taken ones):
|
||||
|
||||
podman run -d --name <name> --restart=always \
|
||||
-p 127.0.0.1:20001:8080 localhost/<name>
|
||||
|
||||
3. Register it:
|
||||
|
||||
echo '{"port": 20001}' > /opt/data/sites/<name>.json
|
||||
|
||||
4. Check that it took, then fetch it:
|
||||
|
||||
cat /opt/data/sites-status.txt
|
||||
curl -si http://127.0.0.1/<name>/
|
||||
|
||||
It is now at `http://mars.sol/<name>/` for anyone on the home network.
|
||||
|
||||
## Rules the registry enforces
|
||||
|
||||
- `<name>` is lowercase letters, digits and `-`, starts with a letter or
|
||||
digit, at most 32 characters. The file is `/opt/data/sites/<name>.json`.
|
||||
- The file holds exactly one JSON object, and only `port` is read.
|
||||
- `port` is an integer from @portMin@ to @portMax@. Anything else is rejected
|
||||
(that includes everything else already running on mars).
|
||||
- A rejected entry never affects the others. `sites-status.txt` says why.
|
||||
- If `sites-status.txt` starts with `ERROR`, that is a fault on mars's side,
|
||||
not in your entry — tell darman.
|
||||
|
||||
## Writing apps that work under /<name>/
|
||||
|
||||
Caddy strips `/<name>` before the request reaches your app, so the app itself
|
||||
sees `/`, `/style.css`, `/api/items`. The browser, however, is at
|
||||
`http://mars.sol/<name>/`, so every link, asset URL and fetch() in the page must
|
||||
keep that prefix:
|
||||
|
||||
- Prefer relative URLs: `style.css`, `./api/items` — not `/style.css`.
|
||||
- Or set the framework's public base URL to `/<name>/` (e.g. Vite's `base`).
|
||||
Avoid settings that ALSO expect the prefix on incoming requests (Next.js
|
||||
`basePath`); the prefix has already been removed by then.
|
||||
- The original prefix arrives in the `X-Forwarded-Prefix` header.
|
||||
- `http://mars.sol/<name>` redirects to `http://mars.sol/<name>/`.
|
||||
|
||||
## Files and data
|
||||
|
||||
- `-v /opt/data/...:/somewhere` does not work: those paths exist only inside
|
||||
your container, and `luna-apps` cannot see your files. Copy code into the
|
||||
image in the `Containerfile`.
|
||||
- Keep an app's state in a named volume: `-v <name>-data:/data`.
|
||||
- Pulling public images works (`podman pull docker.io/library/nginx`).
|
||||
- Do not copy tokens or anything else from `/opt/data` into an app. The apps
|
||||
cannot read your files; keep it that way.
|
||||
|
||||
## Update, inspect, remove
|
||||
|
||||
- Update: rebuild, `podman rm -f <name>`, run it again on the same port. The
|
||||
JSON file stays as it is.
|
||||
- Inspect: `podman ps -a`, `podman logs <name>`, `cat /opt/data/sites-status.txt`.
|
||||
- Remove: `rm /opt/data/sites/<name>.json`, then `podman rm -f <name>`, and
|
||||
optionally `podman rmi localhost/<name>` and `podman volume rm <name>-data`.
|
||||
|
||||
## Limits
|
||||
|
||||
- Home network only: plain `http://`, not reachable from the internet, not on
|
||||
mgaction.town.
|
||||
- There is no login in front of these apps. Anyone on the home network can
|
||||
use them, so do not publish anything that would be a problem to expose there.
|
||||
@@ -0,0 +1,186 @@
|
||||
# VM test for luna-sites.nix. Run:
|
||||
# nix build .#checks.x86_64-linux.luna-sites -L
|
||||
#
|
||||
# mars has no VM target, and nearly everything luna-sites does only exists at
|
||||
# runtime: a rootless podman socket reached through a proxy from another
|
||||
# container's uid, a path unit, a caddy reload, linger + podman-restart after
|
||||
# a reboot. So this drives it the way luna does — every podman and registry
|
||||
# command runs inside a stand-in for the Hermes container, as uid 986 — and
|
||||
# checks that bad entries are refused without taking good ones down.
|
||||
{ pkgs }:
|
||||
let
|
||||
# `contents` is symlinked into the image root and its closure ships as
|
||||
# layers, so the app image is self-contained under luna-apps. The stand-in
|
||||
# is NOT: hermes-agent mounts the host's /nix/store over the image's own,
|
||||
# which is why the node adds busybox to the VM's store below.
|
||||
busyboxImage = { name, extraCommands ? "", cmd }: pkgs.dockerTools.buildLayeredImage {
|
||||
inherit name;
|
||||
tag = "latest";
|
||||
contents = [ pkgs.busybox ];
|
||||
extraCommands = "mkdir -p tmp && chmod 1777 tmp\n" + extraCommands;
|
||||
config.Cmd = cmd;
|
||||
};
|
||||
|
||||
# Stand-in for docker.io/nousresearch/hermes-agent: a shell and nothing else.
|
||||
# The podman client comes from the store, mounted by luna-sites.nix exactly
|
||||
# as on mars.
|
||||
standin = busyboxImage {
|
||||
name = "hermes-standin";
|
||||
cmd = [ "/bin/sleep" "infinity" ];
|
||||
};
|
||||
|
||||
# The "app" luna builds on top of, loaded from the store since the VM has
|
||||
# no network. Runs under luna-apps, which has no /nix/store mount — hence
|
||||
# the closure baked into the image.
|
||||
app = busyboxImage {
|
||||
name = "testapp";
|
||||
extraCommands = "mkdir -p www && echo hello > www/index.html";
|
||||
cmd = [ "/bin/httpd" "-f" "-p" "8080" "-h" "/www" ];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "luna-sites";
|
||||
|
||||
nodes.mars = {
|
||||
imports = [ ./luna-sites.nix ];
|
||||
|
||||
virtualisation.memorySize = 2048;
|
||||
virtualisation.diskSize = 4096;
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
# The stand-in's /bin symlinks point into /nix/store, and the /nix/store
|
||||
# mount below replaces the image's copy with the VM's, which only holds
|
||||
# the system closure. Without this: "executable file `/bin/sleep` not
|
||||
# found". (The real Hermes image is not nix-built, so mars never hits it.)
|
||||
system.extraDependencies = [ pkgs.busybox ];
|
||||
|
||||
# What hermes-agent.nix provides, minus Hermes itself: same uid/gid, host
|
||||
# networking, hermesHome at /opt/data, /nix/store read-only.
|
||||
users.groups.hermes.gid = 983;
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/hermes 0750 root hermes -"
|
||||
"d /var/lib/hermes/.hermes 0750 986 983 -"
|
||||
];
|
||||
virtualisation.oci-containers.containers.hermes-agent = {
|
||||
image = "hermes-standin:latest";
|
||||
imageFile = standin;
|
||||
extraOptions = [ "--network=host" "--user=986:983" ];
|
||||
volumes = [
|
||||
"/var/lib/hermes/.hermes:/opt/data"
|
||||
"/nix/store:/nix/store:ro"
|
||||
];
|
||||
environment = {
|
||||
HERMES_UID = "986";
|
||||
HERMES_GID = "983";
|
||||
HOME = "/opt/data";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = /* python */ ''
|
||||
import shlex
|
||||
|
||||
status_file = "/var/lib/hermes/.hermes/sites-status.txt"
|
||||
|
||||
def luna(cmd):
|
||||
"""Run cmd the way luna would: inside her container, as uid 986."""
|
||||
return mars.succeed("podman exec hermes-agent sh -c " + shlex.quote(cmd))
|
||||
|
||||
def code(path):
|
||||
return mars.succeed(
|
||||
f"curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1{path}"
|
||||
).strip()
|
||||
|
||||
def status_line(entry):
|
||||
lines = mars.succeed(f"cat {status_file}").splitlines()
|
||||
found = [l for l in lines if l.split(" ", 1)[0] == entry]
|
||||
assert len(found) == 1, f"no single status line for {entry}:\n" + "\n".join(lines)
|
||||
return found[0]
|
||||
|
||||
start_all()
|
||||
mars.wait_for_unit("caddy.service")
|
||||
mars.wait_for_unit("podman-hermes-agent.service")
|
||||
|
||||
with subtest("caddy starts with nothing registered"):
|
||||
# The import glob matches no file on a fresh box; caddy must still run.
|
||||
assert code("/") == "404"
|
||||
|
||||
with subtest("luna's podman is luna-apps's rootless podman"):
|
||||
assert luna("id -u").strip() == "986"
|
||||
assert luna("podman info --format '{{.Host.Security.Rootless}}'").strip() == "true"
|
||||
readme = luna("cat /opt/data/sites-README.md")
|
||||
assert "20000" in readme and "@port" not in readme, "README placeholders not substituted"
|
||||
|
||||
with subtest("build and run an app, as luna would"):
|
||||
luna("podman load -i ${app}")
|
||||
luna(
|
||||
"mkdir -p /opt/data/apps/notes && "
|
||||
"printf 'FROM localhost/testapp:latest\\nRUN echo built > /www/built.txt\\n' "
|
||||
"> /opt/data/apps/notes/Containerfile"
|
||||
)
|
||||
luna("podman build -t localhost/notes /opt/data/apps/notes")
|
||||
luna("podman run -d --name notes --restart=always -p 127.0.0.1:20001:8080 localhost/notes")
|
||||
mars.wait_until_succeeds("curl -sf http://127.0.0.1:20001/built.txt")
|
||||
# Container root maps to luna-apps on the host: not root, not uid 986.
|
||||
mars.succeed("pgrep -u luna-apps -f 'httpd -f -p 8080'")
|
||||
|
||||
with subtest("registering routes /notes/ to it"):
|
||||
luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""")
|
||||
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built")
|
||||
# httpd has no /www/notes/, so the 200 above also proves the prefix is stripped.
|
||||
assert " ok " in status_line("notes.json")
|
||||
out = mars.succeed(
|
||||
"curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1/notes"
|
||||
)
|
||||
assert out.startswith("308 ") and out.endswith("/notes/"), out
|
||||
mars.succeed("stat -c %U:%a /var/lib/luna-sites/live/notes.caddy | grep -qx root:644")
|
||||
|
||||
with subtest("bad entries are rejected one by one"):
|
||||
luna("""echo '{"port": 9119}' > /opt/data/sites/dash.json""")
|
||||
luna("echo nope > /opt/data/sites/broken.json")
|
||||
luna(": > /opt/data/sites/empty.json")
|
||||
luna("""echo '{"port": 20002}{"port": 20003}' > /opt/data/sites/two.json""")
|
||||
luna("""echo '{"port": 20003.5}' > /opt/data/sites/frac.json""")
|
||||
luna("""echo '{"port": "20004"}' > /opt/data/sites/str.json""")
|
||||
luna("""echo '{"port": 20005}' > /opt/data/sites/Bad_Name.json""")
|
||||
luna("ln -s /etc/shadow /opt/data/sites/link.json")
|
||||
mars.wait_until_succeeds(f"grep -q '^link.json ' {status_file}")
|
||||
for entry, why in [
|
||||
("dash.json", "port 9119 is outside 20000-20999"),
|
||||
("broken.json", "not valid JSON"),
|
||||
("empty.json", "expected exactly one JSON object"),
|
||||
("two.json", "expected exactly one JSON object"),
|
||||
("frac.json", "port must be an integer"),
|
||||
("str.json", "port must be an integer"),
|
||||
("Bad_Name.json", "name must match"),
|
||||
("link.json", "not a regular file"),
|
||||
]:
|
||||
line = status_line(entry)
|
||||
assert " rejected " in line and why in line, line
|
||||
assert " ok " in status_line("notes.json")
|
||||
# A burst like the one above used to trip systemd's start limit, which
|
||||
# fails the path unit for good and silently ignores every later entry.
|
||||
mars.succeed("systemctl is-active luna-sites.path")
|
||||
assert code("/notes/built.txt") == "200"
|
||||
assert code("/dash/") == "404"
|
||||
mars.succeed("test \"$(ls /var/lib/luna-sites/live)\" = notes.caddy")
|
||||
# The status file is hers, and nothing root-written is left in her tree
|
||||
# (bar the README's mountpoint, which podman itself creates).
|
||||
mars.succeed(f"stat -c %u {status_file} | grep -qx 986")
|
||||
mars.fail("find /var/lib/hermes/.hermes -user root ! -name sites-README.md | grep .")
|
||||
|
||||
with subtest("removing the entry removes the route"):
|
||||
luna("rm /opt/data/sites/notes.json")
|
||||
mars.wait_until_succeeds("test \"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/notes/built.txt)\" = 404")
|
||||
|
||||
with subtest("apps and routes come back after a reboot"):
|
||||
luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""")
|
||||
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt")
|
||||
mars.shutdown()
|
||||
mars.start()
|
||||
mars.wait_for_unit("caddy.service")
|
||||
# Nobody logs in: linger starts luna-apps's manager, podman-restart the container.
|
||||
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built", timeout=180)
|
||||
mars.wait_for_unit("podman-hermes-agent.service")
|
||||
assert luna("podman ps --format '{{.Names}}'").split() == ["notes"]
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
# luna-sites — luna (the Hermes agent, hermes-agent.nix) hosts her own web apps
|
||||
# on mars, LAN-only, at http://mars.sol/<name>/, with no nix edit per app.
|
||||
#
|
||||
# luna, inside hermes-agent (uid 986)
|
||||
# │ podman … → $CONTAINER_HOST = /run/luna-podman/podman.sock (luna-apps:hermes 0660)
|
||||
# ▼ systemd-socket-proxyd, running AS luna-apps
|
||||
# luna-apps's rootless podman (its linger'd user manager) — her app containers
|
||||
#
|
||||
# /opt/data/sites/<name>.json {"port": N} hermesHome/sites, hers to write
|
||||
# ▼ luna-sites.path → luna-sites.service (root): validate, caddy validate, reload
|
||||
# /var/lib/luna-sites/live/<name>.caddy root-owned, imported by caddy
|
||||
# /opt/data/sites-status.txt what was accepted, and why not
|
||||
#
|
||||
# A registry of {name, port}, not raw Caddyfile snippets from her: a snippet
|
||||
# could proxy to anything on the box or break caddy on the next boot, while
|
||||
# the generator only ever emits one validated shape.
|
||||
#
|
||||
# Paths, not <name>.mars.sol: mars has no fixed DHCP lease, and pihole-FTL's
|
||||
# dnsmasq can't wildcard-CNAME without one.
|
||||
#
|
||||
# A podman socket, not ssh: gives her long-running processes outside her own
|
||||
# container (which dies on restart and holds her tokens) with no host shell.
|
||||
# It's not a strong boundary by itself — socket access is code execution as
|
||||
# luna-apps — but luna-apps can't enter /var/lib/hermes (0750 root:hermes), so
|
||||
# her apps can't reach her tokens.
|
||||
#
|
||||
# She learns all this from a read-only README mounted at
|
||||
# /opt/data/sites-README.md (luna-sites-README.md) — she self-manages her own
|
||||
# memory, so nothing else in this file reaches her.
|
||||
#
|
||||
# VM test: nix build .#checks.x86_64-linux.luna-sites -L (luna-sites-test.nix)
|
||||
let
|
||||
user = "luna-apps";
|
||||
# Pinned so the user manager's socket path below is known at build time.
|
||||
uid = 1001;
|
||||
userSocket = "/run/user/${toString uid}/podman/podman.sock";
|
||||
|
||||
hermes = config.virtualisation.oci-containers.containers.hermes-agent;
|
||||
hermesUid = hermes.environment.HERMES_UID;
|
||||
hermesGid = hermes.environment.HERMES_GID;
|
||||
# hermes-agent.nix's hermesHome — the container sees it as /opt/data.
|
||||
hermesHome = "/var/lib/hermes/.hermes";
|
||||
sitesDir = "${hermesHome}/sites";
|
||||
statusFile = "${hermesHome}/sites-status.txt";
|
||||
|
||||
stateDir = "/var/lib/luna-sites";
|
||||
liveDir = "${stateDir}/live";
|
||||
socketDir = "/run/luna-podman";
|
||||
|
||||
portMin = 20000;
|
||||
portMax = 20999;
|
||||
|
||||
readme = pkgs.replaceVars ./luna-sites-README.md {
|
||||
portMin = toString portMin;
|
||||
portMax = toString portMax;
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
../../services/containers.nix
|
||||
../../services/network/caddy.nix
|
||||
];
|
||||
|
||||
# ---- luna-apps: the account her apps run as ----
|
||||
users.users.${user} = {
|
||||
isNormalUser = true;
|
||||
inherit uid;
|
||||
description = "luna's hosted web apps (rootless podman)";
|
||||
# No interactive login; linger keeps its systemd user manager (and thus
|
||||
# the podman socket) running across reboots without a session.
|
||||
linger = true;
|
||||
autoSubUidGidRange = true; # rootless podman's user namespace
|
||||
hashedPassword = "!";
|
||||
shell = "${pkgs.shadow}/bin/nologin";
|
||||
};
|
||||
|
||||
# Rootless podman has no daemon to bring `--restart=always` containers back
|
||||
# after a reboot; the podman module enables this for every user, scoped
|
||||
# here to luna-apps.
|
||||
systemd.user.services.podman-restart = {
|
||||
wantedBy = [ "default.target" ];
|
||||
unitConfig.ConditionUser = user;
|
||||
};
|
||||
|
||||
# ---- the socket luna's container talks to ----
|
||||
# luna-apps's own socket lives under /run/user/1001 (0700), unreachable to
|
||||
# the container's uid; this re-exposes it to group hermes via a proxy that
|
||||
# itself runs as luna-apps, so it holds no more access than the socket.
|
||||
systemd.sockets.luna-apps-podman = {
|
||||
wantedBy = [ "sockets.target" ];
|
||||
listenStreams = [ "${socketDir}/podman.sock" ];
|
||||
socketConfig = {
|
||||
SocketUser = user;
|
||||
SocketGroup = "hermes";
|
||||
SocketMode = "0660";
|
||||
DirectoryMode = "0755";
|
||||
};
|
||||
};
|
||||
systemd.services.luna-apps-podman = {
|
||||
description = "Forward luna's podman socket to luna-apps's rootless podman";
|
||||
requires = [ "user@${toString uid}.service" ];
|
||||
after = [ "user@${toString uid}.service" ];
|
||||
serviceConfig = {
|
||||
User = user;
|
||||
ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd ${userSocket}";
|
||||
};
|
||||
};
|
||||
|
||||
# ---- luna's side ----
|
||||
# Merges into hermes-agent.nix's container definition.
|
||||
virtualisation.oci-containers.containers.hermes-agent = {
|
||||
volumes = [
|
||||
# Mounts the directory, not the socket file — a file bind mount would
|
||||
# pin the inode present at container start, before systemd creates the
|
||||
# socket. Read-only still permits connect().
|
||||
"${socketDir}:${socketDir}:ro"
|
||||
"${config.virtualisation.podman.package}/bin/podman:/usr/local/bin/podman:ro"
|
||||
"${readme}:/opt/data/sites-README.md:ro"
|
||||
];
|
||||
# Every podman command in there goes to luna-apps, never to the rootful
|
||||
# podman the container itself runs under.
|
||||
environment.CONTAINER_HOST = "unix://${socketDir}/podman.sock";
|
||||
};
|
||||
systemd.services.podman-hermes-agent = {
|
||||
wants = [ "luna-apps-podman.socket" ];
|
||||
after = [ "luna-apps-podman.socket" ];
|
||||
};
|
||||
|
||||
# ---- caddy ----
|
||||
# `:80` rather than http://mars.sol, so it answers whatever name the LAN
|
||||
# used to get here (mars, mars.sol, the IP). Until the generator's first run
|
||||
# the import glob matches nothing, which caddy only warns about.
|
||||
services.caddy.virtualHosts.":80".extraConfig = ''
|
||||
import ${liveDir}/*.caddy
|
||||
handle {
|
||||
respond "No app registered here. luna's apps live at /<name>/." 404
|
||||
}
|
||||
'';
|
||||
|
||||
# ---- registry → caddy ----
|
||||
# Fires on create/delete/rename/close-after-write of entries in sitesDir.
|
||||
# While sitesDir does not exist yet, systemd watches its parents instead.
|
||||
systemd.paths.luna-sites = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
pathConfig.PathChanged = sitesDir;
|
||||
};
|
||||
|
||||
systemd.services.luna-sites = {
|
||||
description = "Turn luna's site registry into caddy routes";
|
||||
# Also runs once at boot, for edits made while nothing was watching.
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# After caddy, so the reload below can't race caddy's own start; nothing
|
||||
# orders caddy after this unit, so that reload never waits on its own.
|
||||
after = [ "caddy.service" ];
|
||||
# No start rate limit: the default (5/10s) trips from just a handful of
|
||||
# quick writes and permanently disables luna-sites.path (unit-start-
|
||||
# limit-hit) until someone runs reset-failed. Bursts are absorbed by the
|
||||
# script's own debounce instead.
|
||||
startLimitIntervalSec = 0;
|
||||
path = [ pkgs.jq pkgs.util-linux pkgs.diffutils config.services.caddy.package ];
|
||||
# caddy validate wants somewhere to write its data/config dirs.
|
||||
environment = {
|
||||
HOME = "/tmp";
|
||||
XDG_DATA_HOME = "/tmp";
|
||||
XDG_CONFIG_HOME = "/tmp";
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
StateDirectory = "luna-sites";
|
||||
StateDirectoryMode = "0755"; # caddy (User=caddy) reads live/
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
# "-": hermesHome does not exist on a box Hermes has never started on;
|
||||
# the script checks for that itself.
|
||||
ReadWritePaths = [ "-${hermesHome}" ];
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# Runs as the container's uid, never root — she controls every path
|
||||
# under it, including swapping one for a symlink between a check here
|
||||
# and its use.
|
||||
as_luna() { setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups -- "$@"; }
|
||||
|
||||
if [ ! -d ${hermesHome} ]; then
|
||||
echo "${hermesHome} does not exist yet; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
# mkdir -p leaves an existing dir untouched, so this does not re-fire
|
||||
# the path unit on every run.
|
||||
as_luna mkdir -p ${sitesDir}
|
||||
rm -rf ${stateDir}/stage.*
|
||||
|
||||
report=$(mktemp)
|
||||
|
||||
reject() { printf '%-24s rejected %s\n' "$f" "$1" >> "$report"; }
|
||||
|
||||
# Written as her uid next to the target, then renamed into place, so
|
||||
# she never reads a half-written file.
|
||||
publish_report() {
|
||||
local tmp
|
||||
tmp=$(as_luna mktemp ${hermesHome}/.sites-status.XXXXXX)
|
||||
{
|
||||
printf '# luna-sites, %s. How this works: /opt/data/sites-README.md\n' "$(date -Is)"
|
||||
if [ -n "''${1:-}" ]; then printf '%s\n' "$1"; fi
|
||||
if [ -s "$report" ]; then cat "$report"; else echo "(no sites registered)"; fi
|
||||
} | as_luna tee "$tmp" >/dev/null
|
||||
as_luna mv -f "$tmp" ${statusFile}
|
||||
}
|
||||
|
||||
entries() {
|
||||
as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f %s %T@\n' | sort
|
||||
}
|
||||
|
||||
generate() {
|
||||
local stage entry type f name verdict port
|
||||
: > "$report"
|
||||
stage=$(mktemp -d ${stateDir}/stage.XXXXXX)
|
||||
chmod 0755 "$stage"
|
||||
|
||||
while IFS= read -r -d "" entry; do
|
||||
type=''${entry%% *}
|
||||
f=''${entry#* }
|
||||
name=''${f%.json}
|
||||
|
||||
if ! [[ $name =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
|
||||
reject "name must match [a-z0-9][a-z0-9-]{0,31}"
|
||||
continue
|
||||
fi
|
||||
# Refused rather than followed. The read below happens as her uid
|
||||
# either way, so this is about clear feedback, not safety.
|
||||
if [ "$type" != f ]; then
|
||||
reject "not a regular file"
|
||||
continue
|
||||
fi
|
||||
|
||||
verdict=$(as_luna head -c 4096 -- ${sitesDir}/"$f" | jq -rs \
|
||||
--argjson min ${toString portMin} --argjson max ${toString portMax} '
|
||||
if length != 1 or (.[0] | type) != "object" then "expected exactly one JSON object"
|
||||
else .[0].port as $p
|
||||
| if ($p | type) != "number" or $p != ($p | floor) then "port must be an integer"
|
||||
elif $p < $min or $p > $max then "port \($p) is outside \($min)-\($max)"
|
||||
else "ok \($p | floor)" end
|
||||
end
|
||||
' 2>/dev/null) || verdict="not valid JSON"
|
||||
|
||||
case $verdict in
|
||||
"ok "*) port=''${verdict#ok } ;;
|
||||
*) reject "$verdict"; continue ;;
|
||||
esac
|
||||
if ! [[ $port =~ ^[0-9]+$ ]]; then
|
||||
reject "port must be an integer"
|
||||
continue
|
||||
fi
|
||||
|
||||
# The only shape that is ever generated. Stripping the prefix means
|
||||
# the app sees `/`; X-Forwarded-Prefix tells it where it really is.
|
||||
{
|
||||
printf '# %s\n' "${sitesDir}/$f"
|
||||
printf 'redir /%s /%s/ 308\n' "$name" "$name"
|
||||
printf 'handle_path /%s/* {\n' "$name"
|
||||
printf '\treverse_proxy 127.0.0.1:%s {\n' "$port"
|
||||
printf '\t\theader_up X-Forwarded-Prefix /%s\n' "$name"
|
||||
printf '\t}\n}\n'
|
||||
} > "$stage/$name.caddy"
|
||||
printf '%-24s ok http://mars.sol/%s/ -> 127.0.0.1:%s\n' "$f" "$name" "$port" >> "$report"
|
||||
done < <(as_luna find ${sitesDir} -mindepth 1 -maxdepth 1 -name '*.json' -printf '%y %f\0' | sort -z)
|
||||
|
||||
# Nothing she controls reaches these files except a validated name and
|
||||
# an integer, so a failure here is a bug in this unit, not her entry.
|
||||
printf ':80 {\n\timport %s/*.caddy\n}\n' "$stage" > "$stage.Caddyfile"
|
||||
if ! caddy validate --adapter caddyfile --config "$stage.Caddyfile"; then
|
||||
rm -rf "$stage" "$stage.Caddyfile"
|
||||
publish_report "ERROR: the generated routes failed caddy validate, so nothing changed. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)."
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$stage.Caddyfile"
|
||||
|
||||
if [ -d ${liveDir} ] && diff -r ${liveDir} "$stage" >/dev/null; then
|
||||
rm -rf "$stage"
|
||||
else
|
||||
rm -rf ${stateDir}/previous
|
||||
if [ -d ${liveDir} ]; then mv ${liveDir} ${stateDir}/previous; fi
|
||||
mv "$stage" ${liveDir}
|
||||
# caddy's reload is all-or-nothing: on failure it keeps serving the
|
||||
# old routes, so put the old files back to match what is live.
|
||||
if systemctl is-active --quiet caddy.service && ! systemctl reload caddy.service; then
|
||||
rm -rf ${liveDir}
|
||||
if [ -d ${stateDir}/previous ]; then mv ${stateDir}/previous ${liveDir}; fi
|
||||
publish_report "ERROR: caddy refused the new routes, so the previous ones are still live. This is a bug in luna-sites, not in your entries - tell darman (journalctl -u luna-sites)."
|
||||
exit 1
|
||||
fi
|
||||
rm -rf ${stateDir}/previous
|
||||
fi
|
||||
publish_report
|
||||
}
|
||||
|
||||
# Debounce: any trigger landing while this oneshot is still activating
|
||||
# merges into the same start job, so one second collapses a burst of
|
||||
# writes (several files, an editor's write-then-rename) into one run.
|
||||
sleep 1
|
||||
|
||||
# That same merging means an entry written mid-run would otherwise wait
|
||||
# for the next unrelated trigger, so compare the registry before/after
|
||||
# and rerun if it changed — bounded, so a writer in a loop can't pin it.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
before=$(entries)
|
||||
generate
|
||||
if [ "$before" = "$(entries)" ]; then exit 0; fi
|
||||
echo "registry changed during run $attempt; regenerating"
|
||||
done
|
||||
echo "registry still changing after 5 runs; leaving the rest to the next trigger" >&2
|
||||
'';
|
||||
};
|
||||
}
|
||||
+33
-26
@@ -22,25 +22,16 @@
|
||||
password=${config.sops.placeholder.samba_password}
|
||||
'';
|
||||
|
||||
# Hermes Agent (hermes-agent.nix) — moved here from jupiter (see that
|
||||
# host's git history); same Telegram bot token, opencode key, and
|
||||
# Authentik OIDC client secret, so no new bot/app to provision.
|
||||
# Hermes Agent (hermes-agent.nix) — same Telegram bot token, opencode key,
|
||||
# and Authentik OIDC client secret as it used before moving here from
|
||||
# jupiter, so no new bot/app to provision.
|
||||
sops.secrets.opencode_go_api_key = { };
|
||||
sops.secrets.telegram_bot_token = { };
|
||||
sops.secrets.hermes_dashboard_oidc_client_secret = { };
|
||||
# Same value as in secrets/jupiter.yaml (the sending side), stored WITHOUT a
|
||||
# trailing newline — a stray newline would change the key the HMAC is
|
||||
# computed with and fail every delivery. `scripts/edit_secrets` writes a
|
||||
# bare value. hermes-agent.nix trims one anyway, belt and braces.
|
||||
#
|
||||
# This is NOT in the container's env any more. It used to be, because
|
||||
# hermes-agent-webhook-route ran `hermes webhook subscribe` inside the
|
||||
# container and read the secret back out of its environment — which meant
|
||||
# podman-hermes-agent had to be restarted first on rotation, or the
|
||||
# subscription silently pinned the stale value. The route config is now
|
||||
# written host-side (hermes-agent-webhook-routes reads this file directly),
|
||||
# so that ordering constraint is gone and the secret no longer sits in an
|
||||
# env var luna can read with `env`.
|
||||
# Same value as secrets/jupiter.yaml (the sending side), stored WITHOUT a
|
||||
# trailing newline — a stray newline would change the HMAC key and fail
|
||||
# every delivery. Written host-side by hermes-agent-webhook-routes, so it
|
||||
# no longer needs to sit in the container's env where luna could read it.
|
||||
sops.secrets.gitea_hermes_webhook_secret = {
|
||||
restartUnits = [ "hermes-agent-webhook-routes.service" ];
|
||||
};
|
||||
@@ -54,15 +45,31 @@
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
|
||||
'';
|
||||
|
||||
# luna's own gitea push token (services/dev/gitea.nix provisions the
|
||||
# account + PR-tier repo access on jupiter; this is the per-user token
|
||||
# generated once via `gitea admin user generate-access-token --username
|
||||
# luna --scopes write:repository,read:user` on jupiter — read:user is
|
||||
# required, `tea logins add` fails without it). Read directly by
|
||||
# hermes-agent.nix's prepare-dirs oneshot (default root:root owner is
|
||||
# fine — that oneshot already runs as root) to set up a git
|
||||
# credential-store file and a `tea` login, both written into hermesHome
|
||||
# so they're visible inside the container at /opt/data/....
|
||||
# restartUnits re-provisions both on rotation, without a full mars deploy.
|
||||
# luna's gitea push token (services/dev/gitea.nix provisions the account +
|
||||
# PR-tier access), generated once via `gitea admin user generate-access-token
|
||||
# --username luna --scopes write:repository,read:user` on jupiter — read:user
|
||||
# is required or `tea logins add` fails. restartUnits re-provisions the git
|
||||
# credential-store file and `tea` login on rotation, without a full deploy.
|
||||
sops.secrets.gitea_luna_token.restartUnits = [ "hermes-agent-prepare-dirs.service" ];
|
||||
|
||||
# livesync-bridge (livesync-bridge.nix) — luna's Obsidian vault, mirrored
|
||||
# from CouchDB on jupiter. Consumed only via the rendered config.json, so
|
||||
# the sops default of root:root 0400 is fine here.
|
||||
#
|
||||
# ⚠️ couchdb_luna_password is jupiter's `obsidian` ADMIN password (same as
|
||||
# secrets/jupiter.yaml's couchdb_admin_password) and obsidian_luna_passphrase
|
||||
# reuses the personal vault's passphrase — reusing what already existed, but
|
||||
# it means mars (running an autonomous agent) can decrypt and read EVERY
|
||||
# vault database, not just luna's. To shrink that blast radius: give luna's
|
||||
# vault its own passphrase, and/or scope a CouchDB account to her database
|
||||
# via _security (README -> "Obsidian vaults"). Neither is required for the
|
||||
# bridge to work.
|
||||
sops.secrets.couchdb_luna_password = { };
|
||||
|
||||
# The E2EE passphrase for luna's vault, as entered in the Obsidian plugin.
|
||||
# Vault passphrases otherwise never leave the clients (obsidian-livesync.nix)
|
||||
# — this has to be here because mars IS a client, decrypting to write real
|
||||
# markdown to disk. Also feeds the bridge's separate obfuscatePassphrase
|
||||
# field, since the plugin derives path obfuscation from the same value.
|
||||
sops.secrets.obsidian_luna_passphrase = { };
|
||||
}
|
||||
|
||||
@@ -16,26 +16,25 @@
|
||||
networking.hostName = "mercury";
|
||||
|
||||
# ---- Static networking ----
|
||||
# A DNS/DHCP server must have a fixed address. Fill in the Pi's real values
|
||||
# (from `ip -brief a` / `ip route` on the running Pi). eth0 = the Pi's NIC.
|
||||
# A DNS/DHCP server needs a fixed address (values from `ip -brief a` / `ip
|
||||
# route` on the running Pi; eth0 is its NIC).
|
||||
networking.useDHCP = false;
|
||||
networking.usePredictableInterfaceNames = false; # keep it named eth0
|
||||
networking.interfaces.eth0.ipv4.addresses = [
|
||||
{ address = "10.0.0.10"; prefixLength = 24; } # the Pi's current IP
|
||||
];
|
||||
# Stable IPv6 (FRITZ!Box ULA prefix) so mercury is a fixed IPv6 DNS target.
|
||||
# SLAAC still provides the GUA + default route. Announce THIS address as the
|
||||
# DNSv6 server in the FRITZ!Box so IPv6 clients resolve .sol via pihole.
|
||||
# Stable IPv6 (FRITZ!Box ULA prefix) so mercury is a fixed IPv6 DNS target —
|
||||
# SLAAC still handles the GUA + default route. Announce this address as the
|
||||
# FRITZ!Box's DNSv6 server so IPv6 clients resolve .sol via pihole.
|
||||
networking.interfaces.eth0.ipv6.addresses = [
|
||||
{ address = "fd18:df17:9078:0::10"; prefixLength = 64; }
|
||||
];
|
||||
networking.defaultGateway = { address = "10.0.0.1"; interface = "eth0"; };
|
||||
networking.nameservers = [ "1.1.1.1" "9.9.9.9" ];
|
||||
|
||||
# Never take the tailnet's DNS on THIS host: headscale points every node at
|
||||
# pihole, which runs here — mercury would be resolving through itself. Keep
|
||||
# the public resolvers above for the Pi's own lookups, exactly as the
|
||||
# unbound resolveLocalQueries note in CLAUDE.md requires.
|
||||
# Never take the tailnet's DNS here: headscale points every node at pihole,
|
||||
# which runs on this host, so mercury would resolve through itself — keep
|
||||
# the public resolvers above for its own lookups.
|
||||
services.tailscale.extraUpFlags = [ "--accept-dns=false" ];
|
||||
|
||||
# ---- pihole web admin password (from sops) ----
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
# sops-nix wiring for mercury. Encrypted values in ../../secrets/mercury.yaml.
|
||||
#
|
||||
# SD images have no `--extra-files` step, so mercury uses a DEDICATED age key
|
||||
# placed on the ROOT filesystem (the Pi's vfat partition isn't mounted at
|
||||
# runtime — u-boot reads it pre-boot). `./deploy flash mercury <dev>` drops
|
||||
# ~/.config/homelab/mercury/age.txt there automatically.
|
||||
# The key never enters the repo, the nix store, or the image itself.
|
||||
# SD images get no `--extra-files` step, so mercury uses a dedicated age key
|
||||
# on the root filesystem instead of the admin key — the Pi's vfat boot
|
||||
# partition isn't mounted at runtime (u-boot reads it pre-boot), so the key
|
||||
# can't live there.
|
||||
{
|
||||
sops.defaultSopsFile = ../../secrets/mercury.yaml;
|
||||
sops.age.keyFile = "/var/lib/sops-nix/age.txt";
|
||||
|
||||
@@ -43,30 +43,19 @@
|
||||
# default via fe80::1 dev eth0 metric 1024 onlink
|
||||
networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; };
|
||||
networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ];
|
||||
# Addressing is fully static above, but netcup's router still sends periodic
|
||||
# RAs on this segment; the kernel then tries (and fails, since the static
|
||||
# route already exists) to install its own default route from them, spamming
|
||||
# "ndisc_router_discovery failed to add default route" on the console. Stop
|
||||
# it from processing RAs on eth0 at all rather than just live with the noise.
|
||||
# netcup's router still sends periodic RAs on this segment despite fully static
|
||||
# addressing, spamming "ndisc_router_discovery failed to add default route" on the
|
||||
# console. Stop processing RAs on eth0 entirely instead of living with the noise.
|
||||
boot.kernel.sysctl."net.ipv6.conf.eth0.accept_ra" = 0;
|
||||
|
||||
# ---- Local split-DNS stub ----
|
||||
# neptun must NOT take the tailnet's DNS: headscale points every node at
|
||||
# pihole on mercury, and making a public reverse proxy's name resolution
|
||||
# depend on a Pi behind a domestic line would take ACME renewals — and so
|
||||
# the certs for the control server every node needs — down with it. It is
|
||||
# also circular, since tailscaled has to resolve vpn.mgaction.town to
|
||||
# connect in the first place.
|
||||
#
|
||||
# So neptun opts out with --accept-dns=false and does its own split DNS.
|
||||
# tailscaled still answers MagicDNS on 100.100.100.100 whenever it is
|
||||
# running (--accept-dns only governs whether it rewrites resolv.conf), so
|
||||
# dnsmasq forwards just the tailnet suffix there and everything else to the
|
||||
# public resolvers above. jupiter's address is therefore resolved live and
|
||||
# never pinned — nothing to update when the tailnet is rebuilt.
|
||||
#
|
||||
# resolveLocalQueries (default) points resolv.conf at 127.0.0.1 and feeds
|
||||
# networking.nameservers to dnsmasq as upstreams via resolvconf.
|
||||
# neptun must NOT take the tailnet's DNS: headscale points every node at pihole on
|
||||
# mercury, and a public reverse proxy depending on a Pi on a domestic line for name
|
||||
# resolution (and thus for its own ACME renewals) would be fragile and circular.
|
||||
# It opts out (--accept-dns=false) and runs its own split DNS instead: dnsmasq
|
||||
# forwards the tailnet suffix to MagicDNS (100.100.100.100, still answered by
|
||||
# tailscaled) and everything else to the public resolvers above — jupiter's address
|
||||
# is resolved live, never pinned.
|
||||
services.tailscale.extraUpFlags = [ "--accept-dns=false" ];
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
@@ -111,6 +100,33 @@
|
||||
reverse_proxy http://jupiter.orbit.sol:2283
|
||||
'';
|
||||
|
||||
# ---- Obsidian LiveSync (CouchDB on jupiter) ----
|
||||
# Published publicly (mobile apps refuse cleartext HTTP; *.jupiter.sol has no public
|
||||
# cert), kept safe by the plugin's end-to-end encryption (jupiter stores only
|
||||
# ciphertext) plus this allowlist — CouchDB otherwise exposes Fauxton, /_all_dbs and
|
||||
# /_node/_local/_config, the last of which can rewrite the server's config with admin
|
||||
# creds. Use the tailnet directly for those: `curl http://jupiter.orbit.sol:5984/_utils/`.
|
||||
#
|
||||
# The regex keys off CouchDB's own naming rule (system paths start with `_`, user
|
||||
# databases can't) rather than listing vaults, plus `_session` for cookie auth — so a
|
||||
# mistyped-but-legal name reaches CouchDB (real 404) while an illegal one gets
|
||||
# caddy's 404 with no CORS, which Obsidian shows as a silent connection failure.
|
||||
# Never point two vaults at the same database (LiveSync merges them, not reversibly).
|
||||
#
|
||||
# `flush_interval -1` is required, not tuning — replication rides a continuous
|
||||
# _changes feed that caddy would otherwise buffer, stalling sync.
|
||||
services.caddy.virtualHosts."notes.mgaction.town".extraConfig = ''
|
||||
@livesync path_regexp ^/(_session|[a-z][a-z0-9_$()+-]*)?(/.*)?$
|
||||
handle @livesync {
|
||||
reverse_proxy http://jupiter.orbit.sol:5984 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
'';
|
||||
|
||||
# ---- Hermes dashboard ----
|
||||
# Authentik-gated (hosts/mars/hermes-agent.nix has the OIDC config and the
|
||||
# "create the Authentik app" instructions — moved here from jupiter).
|
||||
|
||||
+11
-18
@@ -14,13 +14,11 @@
|
||||
sops.secrets.darman_password.neededForUsers = true;
|
||||
users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path;
|
||||
|
||||
# Authentik takes a single systemd EnvironmentFile (services/identity/authentik.nix).
|
||||
# No `owner` here on purpose: systemd reads EnvironmentFile as root before
|
||||
# dropping to the service's DynamicUser, so root:root 0400 is what we want.
|
||||
#
|
||||
# AUTHENTIK_SECRET_KEY signs sessions/tokens — rotating it logs everyone out.
|
||||
# The BOOTSTRAP_* vars only take effect on the very first start, where they
|
||||
# create the `akadmin` superuser; they're inert on every boot after that.
|
||||
# Authentik takes a single systemd EnvironmentFile (services/identity/authentik.nix);
|
||||
# no `owner` here on purpose, since systemd reads it as root before dropping to
|
||||
# DynamicUser. AUTHENTIK_SECRET_KEY signs sessions (rotating it logs everyone out);
|
||||
# the BOOTSTRAP_* vars only matter on the very first start (create `akadmin`) and are
|
||||
# inert after.
|
||||
sops.secrets.authentik_secret_key = { };
|
||||
sops.secrets.authentik_bootstrap_password = { };
|
||||
sops.secrets.authentik_bootstrap_email = { };
|
||||
@@ -38,17 +36,12 @@
|
||||
ACME_EMAIL=${config.sops.placeholder.caddy_acme_email}
|
||||
'';
|
||||
|
||||
# Headplane: cookie_secret_path takes a path natively (no store leak).
|
||||
# oidc.client_secret + the headscale API key are still REPLACE_ME
|
||||
# placeholders (see services/vpn/headplane.nix) until Authentik/headscale are
|
||||
# actually deployed and those get created for real.
|
||||
#
|
||||
# owner: unlike authentik's EnvironmentFile above, headscale and headplane
|
||||
# open these paths themselves, already running as the headscale user — so
|
||||
# the root:root 0400 default would fail and each needs an explicit owner.
|
||||
#
|
||||
# headscale's OIDC client is a SEPARATE Authentik application from
|
||||
# headplane's (services/vpn/headscale.nix), hence the second client secret.
|
||||
# Headplane's cookie_secret_path takes a path natively (no store leak); oidc.client_secret
|
||||
# and the headscale API key are still REPLACE_ME placeholders (services/vpn/headplane.nix)
|
||||
# until Authentik/headscale are deployed for real. Unlike authentik's EnvironmentFile,
|
||||
# headscale/headplane open these paths themselves as the headscale user, so each needs
|
||||
# an explicit owner — and headscale's OIDC client is a separate Authentik app from
|
||||
# headplane's, hence the second client secret.
|
||||
sops.secrets.headscale_oidc_client_secret.owner = "headscale";
|
||||
|
||||
sops.secrets.headplane_cookie_secret.owner = "headscale";
|
||||
|
||||
@@ -23,6 +23,13 @@ in
|
||||
|
||||
networking.hostName = "terra";
|
||||
|
||||
homelab.greeter = {
|
||||
monitors = config.home-manager.users.darman.wayland.windowManager.hyprland.settings.monitor;
|
||||
primaryOutput = "DP-2";
|
||||
defaultUser = "darman";
|
||||
keyboardLayout = "de";
|
||||
};
|
||||
|
||||
services.flatpak = {
|
||||
enable = true;
|
||||
remotes = [{ name = "flathub"; location = "https://dl.flathub.org/repo/flathub.flatpakrepo"; }];
|
||||
@@ -32,6 +39,7 @@ in
|
||||
{ appId = "com.discordapp.Discord"; origin = "flathub"; }
|
||||
{ appId = "org.telegram.desktop"; origin = "flathub"; }
|
||||
{ appId = "com.bambulab.BambuStudio"; origin = "flathub"; }
|
||||
{ appId = "md.obsidian.Obsidian"; origin = "flathub"; }
|
||||
];
|
||||
};
|
||||
|
||||
@@ -43,11 +51,36 @@ in
|
||||
# https://nix.dev/permalink/stub-ld ----
|
||||
programs.nix-ld.enable = true;
|
||||
|
||||
# JetBrains IDEs installed via Toolbox bundle a JBR that aborts with
|
||||
# `libX11.so.6: cannot open shared object file` under the default (X11-less)
|
||||
# nix-ld set. Additive — merges with the module's own base list (zlib etc).
|
||||
programs.nix-ld.libraries = with pkgs; [
|
||||
freetype
|
||||
fontconfig
|
||||
libGL
|
||||
libxkbcommon
|
||||
wayland
|
||||
libsecret
|
||||
libx11
|
||||
libxext
|
||||
libxi
|
||||
libxrender
|
||||
libxtst
|
||||
libxcursor
|
||||
libxrandr
|
||||
libxinerama
|
||||
libxcb
|
||||
icu
|
||||
];
|
||||
|
||||
# NixOS only ships /bin/sh; envfs serves /bin and /usr/bin from PATH so
|
||||
# third-party scripts hardcoding `#!/bin/bash` (e.g. JetBrains Toolbox's
|
||||
# generated launchers) still resolve.
|
||||
services.envfs.enable = true;
|
||||
|
||||
# ---- home-manager (user-level config for darman) ----
|
||||
# Base settings (useGlobalPkgs/useUserPackages/backupFileExtension) and the
|
||||
# shared zsh baseline now live in common.nix + home/common.nix, applied to
|
||||
# every host. This just layers terra's desktop/dev-specific profile on top
|
||||
# — home-manager.users.darman.imports merges additively across modules.
|
||||
# Base settings + shared zsh baseline live in common.nix + home/common.nix
|
||||
# (every host); this layers terra's desktop profile on top (imports merge).
|
||||
home-manager.extraSpecialArgs = { inherit unstable inputs; };
|
||||
home-manager.users.darman.imports = [ ./home.nix ];
|
||||
|
||||
@@ -56,66 +89,18 @@ in
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
hardware.cpu.amd.updateMicrocode = true;
|
||||
|
||||
# mercury (aarch64) is built/flashed from here. Without this, `nix build`
|
||||
# for it dies with "platform mismatch" — no qemu binfmt handler registered
|
||||
# and aarch64-linux missing from nix.settings.extra-platforms. This module
|
||||
# sets up both (see CLAUDE.md's aarch64 gotcha).
|
||||
# Lets `nix build` target mercury (aarch64) from here — see CLAUDE.md's
|
||||
# aarch64 gotcha.
|
||||
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
|
||||
|
||||
# ---- GPU (Radeon RX 6800 XT / Navi 21) ----
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
boot.initrd.kernelModules = [ "amdgpu" ];
|
||||
|
||||
# /dev/dri/renderD128 is root:render 0660, so rootless podman containers can
|
||||
# only reach the GPU if the *host* user is in render. Needed by the Vulkan
|
||||
# whisper.cpp/llama.cpp containers in ~/Data/Dev/repos/content-trigger-scanner.
|
||||
# /dev/dri/renderD128 is root:render 0660 — host user needs render group for
|
||||
# rootless podman GPU containers (Vulkan whisper.cpp/llama.cpp).
|
||||
users.users.darman.extraGroups = [ "render" "video" ];
|
||||
|
||||
# ---- ollama (local LLM server, ROCm on the 6800 XT) ----
|
||||
# Navi 21 is gfx1030 — officially supported by ROCm, so no
|
||||
# rocmOverrideGfx/HSA_OVERRIDE_GFX_VERSION needed (that's for gpus ROCm
|
||||
# doesn't recognize, e.g. RDNA1/gfx101x). The upstream module runs the
|
||||
# service under DynamicUser with SupplementaryGroups=["render"] and
|
||||
# DeviceAllow for char-kfd/char-drm/char-fb already, so unlike jellyfin's
|
||||
# static user it needs no extraGroups wiring here.
|
||||
services.ollama = {
|
||||
enable = true;
|
||||
package = pkgs.ollama-rocm;
|
||||
# keep in sync with services/desktop/librechat.nix's endpoints.custom
|
||||
# default model — LibreChat's config schema needs a non-empty default
|
||||
# even though fetch=true replaces it with whatever's actually pulled.
|
||||
# gemma4:12b: general chat/coding daily driver, fits fully in 16G VRAM —
|
||||
# also doubles as the memory-extraction agent (see librechat.nix): a
|
||||
# 3b model (llama3.2:3b, dropped) couldn't reliably tell the user's
|
||||
# stated facts apart from its own boilerplate, e.g. saving "I am an AI
|
||||
# assistant with tool calling capabilities" as the user's personal_info
|
||||
# after "Hi I'm Erik Simon". Reusing gemma4:12b for both roles also means
|
||||
# no second model needs to swap into VRAM while it's already the active
|
||||
# chat model.
|
||||
# qwen3.6:35b-a3b: MoE (3B active/36B total), ~24GB Q4_K_M — doesn't fit
|
||||
# in VRAM alone, so ollama offloads the inactive experts to CPU RAM.
|
||||
# Sparse activation makes that far less painful than it'd be for a dense
|
||||
# model this size, but still expect it to run slower than the two above.
|
||||
loadModels = [ "gemma4:12b" "qwen3.6:35b-a3b" ];
|
||||
# Ollama truncates context far below the model's real window unless
|
||||
# told otherwise (the OpenAI-compat /v1 route it's reached through has
|
||||
# no way to set this per-request). 131072 chosen as the practical
|
||||
# ceiling after load-testing with real prompts, not just idle
|
||||
# `ollama ps` checks:
|
||||
# 32768 (31.6k-token prompt) and 65536 (40.8k-token prompt) both stayed
|
||||
# 100% GPU with VRAM barely moving (~10.1G / ~10.67G of 16G) — KV cache
|
||||
# cost barely grows with context, likely sliding-window/local attention
|
||||
# on most of gemma4:12b's layers. At 131072 that stopped being true: a
|
||||
# ~108k-token prompt pushed VRAM to ~11.4G/16G (still 100% GPU, no CPU
|
||||
# spillover, negligible GTT) but with visibly shrinking headroom, and
|
||||
# prefill throughput measurably dropped (~490 -> ~460 tok/s) over just
|
||||
# the last 13k tokens — filling the full window would take minutes of
|
||||
# pure prompt processing. Stopped here rather than push further: next
|
||||
# doubling would risk CPU spillover under any concurrent GPU load
|
||||
# (desktop compositor, jellyfin transcode) for diminishing benefit.
|
||||
environmentVariables.OLLAMA_CONTEXT_LENGTH = "131072";
|
||||
};
|
||||
|
||||
# ---- Dev-data disks — NOT in disko, mounted read-write, never wiped ----
|
||||
fileSystems."/mnt/hdd_01" = {
|
||||
device = "/dev/disk/by-uuid/b8445126-ec6d-4f88-818a-d9e13031d9a4";
|
||||
|
||||
@@ -5,27 +5,14 @@
|
||||
# `fileSystems.*` entries, so hardware-configuration.nix must NOT define
|
||||
# fileSystems for "/" or "/boot".
|
||||
#
|
||||
# ⚠️ disko's `mkfs` create step SKIPS formatting when `blkid` still detects a
|
||||
# filesystem signature on the freshly-cut partition:
|
||||
#
|
||||
# if ! (blkid "$device" -o export | grep -q '^TYPE='); then
|
||||
# mkfs.btrfs "$device" -f # ← -f only runs WHEN this line runs
|
||||
# fi
|
||||
#
|
||||
# The disk previously held a CachyOS btrfs root. The whole-disk `wipefs`
|
||||
# disko runs before partitioning clears the signature at the OLD layout's
|
||||
# offsets, but `sgdisk --clear --align-end` then re-cuts the partitions, so
|
||||
# a stale btrfs superblock survives at the NEW root partition's own 64 KiB
|
||||
# offset. `blkid` sees TYPE=btrfs, `mkfs` is skipped entirely, and the
|
||||
# later `mount` fails on the leftover bytes ("wrong fs type / bad
|
||||
# superblock"). Switching ext4→btrfs did NOT fix this: `mkfs.btrfs -f` is
|
||||
# never reached, because the guard is on whether `mkfs` runs at all, not on
|
||||
# its flags. The ESP hits the same trap (its `mkfs.vfat` gets skipped too).
|
||||
#
|
||||
# Fix: `preCreateHook = wipefs --all --force "$device"` on each partition's
|
||||
# content. The hook runs AFTER sgdisk re-cuts the partition but BEFORE the
|
||||
# `blkid` guard, so it erases the stale signature at the FINAL offset;
|
||||
# `blkid` then comes back empty and `mkfs` actually runs.
|
||||
# ⚠️ disko's `mkfs` step skips formatting if `blkid` still detects a
|
||||
# filesystem signature on the partition. Repartitioning doesn't erase
|
||||
# signatures at the new offsets, so this disk's old CachyOS btrfs
|
||||
# superblock survived, causing mkfs (and the ESP's mkfs.vfat) to be
|
||||
# skipped and the later mount to fail on the stale superblock.
|
||||
# Fix: `preCreateHook = wipefs --all --force "$device"` on each
|
||||
# partition — it runs after sgdisk re-cuts the partition but before the
|
||||
# `blkid` guard, so the guard sees no signature and `mkfs` actually runs.
|
||||
#
|
||||
# ⚠️ This disk is WIPED on install. This is the Kingston SA400 SSD that
|
||||
# currently holds CachyOS (btrfs root+subvols on sdb2, ESP on sdb1).
|
||||
@@ -58,8 +45,7 @@
|
||||
type = "btrfs";
|
||||
extraArgs = [ "-f" ];
|
||||
mountpoint = "/";
|
||||
# erase the stale CachyOS btrfs superblock before disko's blkid
|
||||
# format-guard, otherwise mkfs.btrfs is skipped (see header comment)
|
||||
# same wipefs fix as the ESP above (see header comment)
|
||||
preCreateHook = ''wipefs --all --force "$device"'';
|
||||
};
|
||||
};
|
||||
|
||||
+52
-6
@@ -1,6 +1,47 @@
|
||||
{ pkgs, unstable, inputs, ... }:
|
||||
let
|
||||
tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; };
|
||||
|
||||
# SUDO_ASKPASS helper: shows sudo's password prompt in quickshell
|
||||
# (HyprChrome/Widgets/Askpass) instead of the terminal. sudo doesn't speak
|
||||
# polkit (setuid + PAM reading the tty), so this reuses the polkit dialog's
|
||||
# look via the askpass mechanism instead — `run0` is the actual polkit-native
|
||||
# alternative.
|
||||
#
|
||||
# Must be a package, not a dotfiles file: SUDO_ASKPASS needs an executable,
|
||||
# and xdg.configFile copies keep store-copy permissions.
|
||||
#
|
||||
# The secret returns over a 0600 fifo (never argv/env, so not visible in
|
||||
# /proc); cancelling closes the fifo unwritten so sudo aborts cleanly.
|
||||
qs-askpass = pkgs.writeShellApplication {
|
||||
name = "qs-askpass";
|
||||
runtimeInputs = [ pkgs.quickshell pkgs.coreutils ];
|
||||
text = ''
|
||||
runtime="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
fifo="$(mktemp -u "$runtime/qs-askpass.XXXXXXXX")"
|
||||
mkfifo -m 600 "$fifo"
|
||||
trap 'rm -f "$fifo"' EXIT
|
||||
|
||||
# Returns immediately; the dialog is asynchronous and we block on the
|
||||
# fifo, not on the IPC call.
|
||||
if ! qs ipc call askpass prompt "''${1:-Password:}" "$fifo" >/dev/null 2>&1; then
|
||||
echo "qs-askpass: quickshell is not running or has no askpass handler" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bounded, so a prompt nobody answers fails instead of wedging sudo for
|
||||
# good. On timeout take the dialog down too, or it would sit there with
|
||||
# nothing listening.
|
||||
if ! secret="$(timeout 120 cat "$fifo")"; then
|
||||
qs ipc call askpass cancel >/dev/null 2>&1 || true
|
||||
echo "qs-askpass: timed out waiting for the prompt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -n "$secret" ] || exit 1
|
||||
printf '%s\n' "$secret"
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
# home.stateVersion, programs.home-manager.enable, programs.zsh.enable all
|
||||
@@ -27,13 +68,17 @@ in
|
||||
nix-direnv.enable = true;
|
||||
};
|
||||
|
||||
# Rootless podman: containers run as darman, not root. services/containers.nix
|
||||
# gives us the `docker` CLI shim (dockerCompat), but compose v2 is a separate
|
||||
# binary and talks to a socket rather than the CLI — the NixOS podman module
|
||||
# enables the *user* socket (systemd.user.sockets.podman), so point compose at
|
||||
# it instead of the root /var/run/docker.sock.
|
||||
# Rootless podman runs containers as darman; compose v2 talks to a socket
|
||||
# rather than the docker CLI shim, so point it at the user podman socket
|
||||
# instead of the root one.
|
||||
home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock";
|
||||
|
||||
# Only sets WHICH helper sudo uses; it still only calls it when asked with
|
||||
# `sudo -A` (or when there is no tty at all). Plain `sudo` keeps prompting on
|
||||
# the terminal, deliberately: aliasing it wholesale would break every sudo in
|
||||
# a TTY or over ssh, where there is no shell to draw the dialog.
|
||||
home.sessionVariables.SUDO_ASKPASS = "${qs-askpass}/bin/qs-askpass";
|
||||
|
||||
xdg.userDirs = {
|
||||
enable = true;
|
||||
};
|
||||
@@ -46,13 +91,14 @@ in
|
||||
(pkgs.writeTextDir "share/mime/packages/application-x-ms-sln.xml"
|
||||
(builtins.readFile ../../dotfiles/mime/application-x-ms-sln.xml))
|
||||
unstable.claude-code
|
||||
unstable.codex
|
||||
pkgs.opencode
|
||||
pkgs.quickshell
|
||||
qs-askpass
|
||||
pkgs.github-cli
|
||||
pkgs.tea
|
||||
pkgs.docker-compose
|
||||
pkgs.hyprcursor
|
||||
pkgs.bibata-cursors
|
||||
pkgs.papirus-icon-theme
|
||||
];
|
||||
|
||||
|
||||
@@ -1,42 +1,18 @@
|
||||
{ lib, pkgs, config, inputs, ... }:
|
||||
|
||||
# Hyprland config migrated from github.com/darman96/hyprland-dotfiles (the
|
||||
# hyprlang `hypr/*.conf` files) into the home-manager lua-style `settings`
|
||||
# (configType defaults to "lua" on stateVersion 26.05). Each top-level
|
||||
# `settings` attr becomes an `hl.<name>(...)` call in ~/.config/hypr/hyprland.lua;
|
||||
# `_args` lists become multi-arg calls, `_var` locals become `local x = ...`, and
|
||||
# `lib.generators.mkLuaInline` values render as raw Lua expressions.
|
||||
#
|
||||
# Imported by home.nix. System-level Hyprland enable (session entry, portals)
|
||||
# lives in ../../services/desktop/desktop-hyprland.nix; this manages the user's
|
||||
# own hyprland.lua.
|
||||
#
|
||||
# Deliberately NOT migrated:
|
||||
# - hyprbars.conf: config for the third-party `hyprbevelbars` plugin, which
|
||||
# isn't packaged in nixpkgs. Load it via
|
||||
# `wayland.windowManager.hyprland.plugins` and re-add its config once
|
||||
# available. (hyprredsquare.conf's plugin was renamed hypr-chrome and
|
||||
# rewritten since - it's wired in below via the `hypr-chrome` flake
|
||||
# input instead, with its own `plugin.hyprchrome` config.)
|
||||
# - hyprqt6engine.conf + `QT_QPA_PLATFORMTHEME=hyprqt6engine`: terra themes Qt
|
||||
# through qtct/Dracula in home.nix, so that env var is left off to avoid a conflict.
|
||||
# - hyprlock.conf: a separate program (use `programs.hyprlock` if wanted).
|
||||
# - the duplicate pamixer/amixer + `.wob` volume binds: kept only the clean
|
||||
# pipewire `wpctl`/`playerctl` set (no wob overlay is configured here).
|
||||
# - `XDG_MENU_PREFIX=arch-` and `VCPKG_ROOT`: Arch-/user-specific.
|
||||
# Many binds reference apps/scripts not packaged on terra yet (vivaldi-stable,
|
||||
# dolphin, vicinae, grimblast, waypaper, discord, gitkraken, qbz,
|
||||
# ~/.config/scripts/start-communications.sh); add them separately.
|
||||
|
||||
let
|
||||
lua = lib.generators.mkLuaInline;
|
||||
|
||||
# Wallpaper images aren't checked into this repo (binary blobs) — pulled
|
||||
# from the existing Wallhaven library on /mnt/hdd_01 instead. Picked once
|
||||
# here rather than at runtime, since hyprpaper has no built-in "random"
|
||||
# mode; re-pick and rebuild (or swap in real per-monitor selection) when
|
||||
# this stops being a placeholder.
|
||||
wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png";
|
||||
# Cursor theme+size live in home.pointerCursor (theme.nix) so the name is
|
||||
# in one place; hyprland.lua is what actually gets them into the graphical
|
||||
# session's environment (hm-session-vars.sh is only sourced by login shells).
|
||||
cursorName = config.home.pointerCursor.name;
|
||||
cursorSize = toString config.home.pointerCursor.size;
|
||||
|
||||
# Wallpapers aren't checked into this repo (binaries) — pulled from the
|
||||
# Wallhaven library on /mnt/hdd_01. Picked once here since hyprpaper has
|
||||
# no built-in "random" mode.
|
||||
wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-mlwz78.png";
|
||||
|
||||
# Dispatchers → the new hl.dsp.* API (signatures verified against hyprland
|
||||
# 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp).
|
||||
@@ -94,7 +70,7 @@ in
|
||||
settings = {
|
||||
# ---- colours (from colors.conf) ----
|
||||
fg_color = { _var = "rgba(eeeeeeff)"; };
|
||||
fg_accent = { _var = "rgba(ffd063ff)"; };
|
||||
fg_accent = { _var = "rgba(e8722aff)"; };
|
||||
fg_accent_alt = { _var = "rgba(ff9d42ff)"; };
|
||||
bg_color = { _var = "rgba(0f1012ff)"; };
|
||||
bg_accent = { _var = "rgba(963c38ff)"; };
|
||||
@@ -117,7 +93,7 @@ in
|
||||
debug.disable_logs = false;
|
||||
|
||||
general = {
|
||||
border_size = 0;
|
||||
border_size = 1;
|
||||
col = {
|
||||
inactive_border = lua "bg_accent";
|
||||
active_border = {
|
||||
@@ -182,10 +158,10 @@ in
|
||||
|
||||
# ---- environment (environment.conf) ----
|
||||
env = [
|
||||
{ _args = [ "HYPRCURSOR_THEME" "Bibata-Modern-Classic" ]; }
|
||||
{ _args = [ "HYPRCURSOR_SIZE" "24" ]; }
|
||||
{ _args = [ "XCURSOR_THEME" "Bibata-Modern-Classic" ]; }
|
||||
{ _args = [ "XCURSOR_SIZE" "24" ]; }
|
||||
{ _args = [ "HYPRCURSOR_THEME" cursorName ]; }
|
||||
{ _args = [ "HYPRCURSOR_SIZE" cursorSize ]; }
|
||||
{ _args = [ "XCURSOR_THEME" cursorName ]; }
|
||||
{ _args = [ "XCURSOR_SIZE" cursorSize ]; }
|
||||
{ _args = [ "GDK_BACKEND" "wayland,x11" ]; }
|
||||
{ _args = [ "SDL_VIDEODRIVER" "wayland" ]; }
|
||||
{ _args = [ "CLUTTER_BACKEND" "wayland" ]; }
|
||||
@@ -310,7 +286,8 @@ in
|
||||
"hyprland.start"
|
||||
(lua ''
|
||||
function()
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent")
|
||||
-- No polkit agent started here: quickshell registers its own
|
||||
-- (HyprChrome/Widgets/Polkit), and a session admits only one.
|
||||
hl.exec_cmd("cosmic-settings-daemon")
|
||||
hl.exec_cmd("quickshell")
|
||||
hl.exec_cmd("alacritty", { workspace = "special:terminal silent" })
|
||||
|
||||
@@ -23,11 +23,22 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Flatpak apps are sandboxed and can't see XDG_DATA_DIRS/nix-store theme
|
||||
# paths, so the portal-reported GTK theme / icon theme names resolve to
|
||||
# nothing inside the sandbox and they fall back to Adwaita. Flatpak
|
||||
# auto-exposes ~/.themes and ~/.icons read-only to every sandboxed app
|
||||
# specifically for this case.
|
||||
# XCURSOR_THEME alone isn't enough for Steam: steamwebhelper runs inside a
|
||||
# pressure-vessel container with its own /etc, so XCURSOR_PATH doesn't
|
||||
# resolve there and it falls back to the core X11 cursor. $HOME and /nix are
|
||||
# bind-mounted in though, so the ~/.icons symlink `dotIcons` drops still
|
||||
# resolves — same fix as the flatpak workaround below.
|
||||
home.pointerCursor = {
|
||||
name = "Bibata-Modern-Classic";
|
||||
package = pkgs.bibata-cursors;
|
||||
size = 24;
|
||||
gtk.enable = true;
|
||||
hyprcursor.enable = true;
|
||||
};
|
||||
|
||||
# Flatpak apps can't see XDG_DATA_DIRS/nix-store theme paths, so the
|
||||
# portal-reported theme names resolve to nothing and fall back to Adwaita;
|
||||
# Flatpak auto-exposes ~/.themes and ~/.icons read-only as the workaround.
|
||||
home.file.".themes/Dracula".source =
|
||||
"${pkgs.dracula-theme}/share/themes/Dracula";
|
||||
home.file.".icons/${iconTheme}".source = iconThemeFolder;
|
||||
|
||||
@@ -17,13 +17,11 @@ stdenvNoCC.mkDerivation {
|
||||
|
||||
dontBuild = true;
|
||||
|
||||
# Upstream ships a handful of dangling symlinks under mimetypes/16 (e.g.
|
||||
# libreoffice-spreadsheet.svg -> libreoffice-oasis-spreadsheet.svg, which
|
||||
# doesn't exist in that size dir) — a minor packaging bug in the theme
|
||||
# itself. Harmless: GTK's icon lookup just falls through to the theme's
|
||||
# own Inherits= chain (breeze-dark, breeze, Adwaita, hicolor) for those few
|
||||
# mimetypes. Nixpkgs' default noBrokenSymlinks fixup check would otherwise
|
||||
# fail the whole build over it.
|
||||
# Upstream ships a handful of dangling symlinks under mimetypes/16 (e.g. a
|
||||
# target that doesn't exist in that size dir) — harmless, GTK's own
|
||||
# Inherits= chain (breeze-dark, breeze, Adwaita, hicolor) covers the
|
||||
# fallback. Nixpkgs' default noBrokenSymlinks check would otherwise fail
|
||||
# the build over it.
|
||||
dontCheckForBrokenSymlinks = true;
|
||||
|
||||
# gtk3's setup hook strips icon-theme.cache from $out by default
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Mnemosyne memory provider for Hermes. Built here rather than pip-installed:
|
||||
# the image's Python has no pip and is PEP 668 managed.
|
||||
#
|
||||
# Core deps only: the embeddings extra (fastembed/onnxruntime) is optional at
|
||||
# runtime, and recall falls back to FTS5.
|
||||
{
|
||||
python3,
|
||||
fetchPypi,
|
||||
}:
|
||||
|
||||
let
|
||||
mnemosyneMemory = python3.pkgs.buildPythonPackage rec {
|
||||
pname = "mnemosyne-memory";
|
||||
version = "3.15.1";
|
||||
pyproject = true;
|
||||
|
||||
src = fetchPypi {
|
||||
pname = "mnemosyne_memory";
|
||||
inherit version;
|
||||
hash = "sha256-lspUMxc0pUSkhSUrNdiiO5OJ1NMC/S853EYSanXtXKM=";
|
||||
};
|
||||
|
||||
build-system = with python3.pkgs; [ setuptools ];
|
||||
dependencies = with python3.pkgs; [ pyyaml ];
|
||||
|
||||
doCheck = false; # tests want a live Hermes + LLM key
|
||||
# Ships the Hermes plugin package too, not just the core.
|
||||
pythonImportsCheck = [
|
||||
"mnemosyne"
|
||||
"hermes_memory_provider"
|
||||
];
|
||||
};
|
||||
|
||||
mnemosyneHermes = python3.pkgs.buildPythonPackage rec {
|
||||
pname = "mnemosyne-hermes";
|
||||
version = "0.5.0";
|
||||
pyproject = true;
|
||||
|
||||
src = fetchPypi {
|
||||
pname = "mnemosyne_hermes";
|
||||
inherit version;
|
||||
hash = "sha256-CzEvnUw5oPFtT5bHQQ/GBdy2C/E7qShQn32irIRYKqw=";
|
||||
};
|
||||
|
||||
build-system = with python3.pkgs; [ setuptools ];
|
||||
# Upstream asks for mnemosyne-memory[embeddings]; see the header.
|
||||
dependencies = [ mnemosyneMemory ] ++ (with python3.pkgs; [ pyyaml ]);
|
||||
|
||||
doCheck = false;
|
||||
pythonImportsCheck = [ "mnemosyne_hermes" ];
|
||||
};
|
||||
in
|
||||
python3.withPackages (_: [
|
||||
mnemosyneMemory
|
||||
mnemosyneHermes
|
||||
])
|
||||
@@ -21,15 +21,11 @@ stdenvNoCC.mkDerivation {
|
||||
# the theme's own Inherits= chain (breeze-dark, Adwaita, hicolor) for those.
|
||||
dontCheckForBrokenSymlinks = true;
|
||||
|
||||
# index.theme's Directories= lists panel/16@2, panel/22@2, panel/24@2 (with
|
||||
# Scale=2), but the actual on-disk dirs are named 16@2x/22@2x/24@2x (the
|
||||
# correct freedesktop-spec suffix) — an upstream index.theme typo. That
|
||||
# mismatch makes `gtk-update-icon-cache` refuse to emit ANY cache at all
|
||||
# (exits 1, "The generated cache was invalid"), so unlike the other vendored
|
||||
# themes here, this one ships with no icon-theme.cache and relies on GTK's
|
||||
# live directory-scan lookup instead — functionally fine, just not
|
||||
# cache-accelerated. gtk3's default postFixup hook (dropIconThemeCache)
|
||||
# would strip a cache anyway, so there's nothing to opt out of.
|
||||
# index.theme's Directories= names panel/16@2 etc. (Scale=2) but the
|
||||
# on-disk dirs are 16@2x etc. (the correct suffix) — an upstream typo that
|
||||
# makes `gtk-update-icon-cache` exit 1, so this theme ships uncached and
|
||||
# relies on GTK's live directory scan instead (functionally fine, just not
|
||||
# cache-accelerated).
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out/share/icons"
|
||||
|
||||
+5
-7
@@ -56,13 +56,11 @@ buildDotnetModule (finalAttrs: {
|
||||
|
||||
executables = [ "Tome.App" ];
|
||||
|
||||
# wrapGAppsHook3: buildDotnetModule sets dontWrapGApps = true by default (to
|
||||
# avoid double-wrapping) but its own wrap step still splices gappsWrapperArgs
|
||||
# in when the hook is present (see nixpkgs' libation package, same pattern).
|
||||
# Without it the binary never gets XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR set, so
|
||||
# GTK/WebKitGTK can't find the icon theme or GTK settings from the desktop
|
||||
# session — symptoms: missing icons and a denser default UI font/size than
|
||||
# when launched from an already-fully-initialized session (e.g. via Rider).
|
||||
# buildDotnetModule sets dontWrapGApps = true by default, but wrapGAppsHook3's
|
||||
# own wrap step still splices gappsWrapperArgs in when present (same pattern
|
||||
# as nixpkgs' libation). Without it XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR never
|
||||
# get set, so GTK/WebKitGTK can't find the icon theme or settings — missing
|
||||
# icons, denser default UI font.
|
||||
nativeBuildInputs = [ copyDesktopItems wrapGAppsHook3 ];
|
||||
|
||||
runtimeDeps = [
|
||||
|
||||
+126
-281
@@ -1,106 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy a NixOS host from this flake. ALL arguments are mandatory (no defaults).
|
||||
#
|
||||
# ./deploy kexec <config> <host> headless kexec into a RAM installer, for a
|
||||
# read-only-root box (ZimaOS) where
|
||||
# nixos-anywhere can't ssh-copy-id. Ships our
|
||||
# SSH login key. Then run `install`. <config>
|
||||
# is only used to look up the vault item.
|
||||
# ./deploy kexec-local [--yes] kexec THIS machine into the RAM installer,
|
||||
# no ssh/second machine involved. Run as root,
|
||||
# locally, on the box you're installing onto.
|
||||
# Disks are untouched; console drops for
|
||||
# ~1-2 min then comes back as the installer.
|
||||
# Prompts for confirmation (--yes skips it),
|
||||
# because run on the wrong terminal this
|
||||
# kexecs your laptop. TMPDIR (default
|
||||
# /var/tmp) must be exec-capable and hold
|
||||
# ~3x the tarball.
|
||||
# Then run `install <config> localhost`.
|
||||
# ./deploy kexec <config> <host> ZimaOS/RO-root box: kexec into a RAM installer, ships the ssh key, then run `install`.
|
||||
# ./deploy kexec-local [--yes] kexec THIS machine (no ssh) into the RAM installer; disks untouched. Then `install <config> localhost`.
|
||||
# ./deploy install <config> <host> [--yes]
|
||||
# first install. Wipes the OS disk. Ships the
|
||||
# host's sops key. <host>=localhost/127.0.0.1
|
||||
# skips nixos-anywhere/ssh and runs disko +
|
||||
# nixos-install directly against /mnt — but
|
||||
# ONLY once actually inside a live installer
|
||||
# (hostname nixos-installer, from kexec, or
|
||||
# homelab-installer, from installer-iso).
|
||||
# Run from the REAL running OS instead (e.g.
|
||||
# a box where kexec-local doesn't work),
|
||||
# it builds installer-iso, stages its
|
||||
# kernel/initrd + the host key on the boot
|
||||
# partition and the iso file on a non-OS-disk
|
||||
# partition, sets a systemd-boot one-shot
|
||||
# entry with homelab.install=<config> +
|
||||
# homelab.keypart=<PARTUUID> on its kernel
|
||||
# cmdline, and reboots — a real ACPI reboot,
|
||||
# not a kexec jump. The booted installer's
|
||||
# homelab-auto-install.service reads those
|
||||
# cmdline params, picks the host key back up
|
||||
# and re-runs this exact command itself once
|
||||
# its repo checkout (homelab-checkout.service)
|
||||
# succeeds, finishing the install unattended.
|
||||
# It confirms before rebooting; --yes skips
|
||||
# that (it is what the ISO passes itself).
|
||||
# first install (wipes the OS disk, ships the host's sops key). localhost only
|
||||
# runs disko/nixos-install directly once already inside a live installer;
|
||||
# from a real running OS it stages installer-iso and reboots into that instead.
|
||||
# See CLAUDE.md.
|
||||
# ./deploy switch <config> <host> rebuild + activate on a running host.
|
||||
# ./deploy boot <config> <host> stage for next boot, don't activate now.
|
||||
# ./deploy test <config> <host> activate without adding a boot entry.
|
||||
# ./deploy image <config> build an SD-card image (e.g. rpi mercury).
|
||||
# ./deploy flash <config> <dev> build SD image, write to <dev>, and (if
|
||||
# ~/.config/homelab/<config>/age.txt exists)
|
||||
# drop the sops key on its boot partition.
|
||||
# ./deploy flash <config> <dev> build SD image, write to <dev>, and drop the sops age key onto it if one exists.
|
||||
#
|
||||
# <config> = a nixosConfigurations name (e.g. jupiter, vps). Its pre-generated
|
||||
# SSH host key must be at ~/.config/homelab/<config>/ssh_host_ed25519_key.
|
||||
# <config> = a nixosConfigurations name. Its pre-generated SSH host key must be
|
||||
# at ~/.config/homelab/<config>/ssh_host_ed25519_key. Runs from a non-NixOS host too.
|
||||
#
|
||||
# Runs from a non-NixOS host too (nixos-rebuild / nixos-anywhere via `nix run`).
|
||||
#
|
||||
# Password prompts are auto-filled from the "HomeLab" Proton Pass vault when
|
||||
# `pass-cli` is installed and logged in; otherwise every command prompts exactly
|
||||
# as before. Both items are keyed by <config>, never by <host>: the address is
|
||||
# incidental (DHCP, a new box, localhost) while the config name is the stable
|
||||
# identity of the machine being built.
|
||||
# darman@<config> darman's sudo password (switch/boot/test)
|
||||
# root@<config> root's ssh password (kexec/install)
|
||||
# Password prompts auto-fill from the "HomeLab" Proton Pass vault, keyed by
|
||||
# <config> not <host> (darman@<config> for sudo, root@<config> for ssh).
|
||||
# Override with HOMELAB_PASS_ITEM / HOMELAB_PASS_ROOT_ITEM / HOMELAB_PASS_VAULT.
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
# Captured before anything shifts/parses $@, so require_root() below can
|
||||
# re-exec the ORIGINAL invocation under sudo — inside a function, "$@"/"$1"
|
||||
# refer to the function's own args (empty here), not the script's, so this
|
||||
# has to be a global array instead of relying on positional-parameter scoping.
|
||||
# Captured before $@ is parsed, so require_root() can re-exec the ORIGINAL
|
||||
# invocation under sudo (inside a function, "$@" is the function's own args).
|
||||
SCRIPT_ARGS=("$@")
|
||||
|
||||
# Locate the repo root (flake dir) regardless of where this script lives on disk.
|
||||
SCRIPT_PATH="$(realpath "$0")" # absolute — "$0" itself may be relative,
|
||||
# and require_root() re-execs after cd "$REPO"
|
||||
SCRIPT_PATH="$(realpath "$0")"
|
||||
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
|
||||
REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || dirname "$SCRIPT_DIR")"
|
||||
cd "$REPO"
|
||||
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
|
||||
|
||||
# Every `nix` call below assumes `nix-command` + `flakes`. Those are ambient on
|
||||
# a Determinate-Nix laptop, but a STOCK NixOS box leaves both experimental
|
||||
# features OFF — so bare `nix eval`/`build`/`run` die with "experimental Nix
|
||||
# feature 'nix-command' is disabled". That box is exactly the prepare host for
|
||||
# `install <config> localhost` (a fresh NixOS the reinstall runs from), and it
|
||||
# is why the installer-iso already sets these itself (flake.nix). Enable them
|
||||
# additively via NIX_CONFIG (extra-, so anything already configured is kept).
|
||||
# This runs again at the top of the sudo re-exec in require_root(), so root
|
||||
# gets it too regardless of whether `sudo -E` carries the env across.
|
||||
# A stock NixOS box (unlike a Determinate-Nix laptop) leaves nix-command/flakes
|
||||
# disabled, and that's exactly the prepare host for `install <config> localhost`.
|
||||
# Enable them additively so root gets them too after the require_root() re-exec.
|
||||
export NIX_CONFIG="$(printf 'extra-experimental-features = nix-command flakes\n%s' "${NIX_CONFIG:-}")"
|
||||
|
||||
# Off-repo material keyed by <config>: pre-generated SSH host keys (install)
|
||||
# and per-config sops age keys (flash).
|
||||
#
|
||||
# Resolved defensively rather than as a bare $HOME, because this script also
|
||||
# runs from installer-iso's homelab-auto-install.service, and systemd does not
|
||||
# set $HOME for a system service without User= (systemd.exec(5):
|
||||
# SetLoginEnvironment= "defaults to true if User=, DynamicUser= or PAMName= are
|
||||
# set, false otherwise"). Under `set -u` that aborted the whole unattended run
|
||||
# with an "unbound variable" that read like a bug in this script.
|
||||
# and per-config sops age keys (flash). Resolved defensively, not as a bare
|
||||
# $HOME, since systemd doesn't set $HOME for a service without User= — this
|
||||
# also runs unattended from installer-iso's homelab-auto-install.service.
|
||||
KEYDIR="${HOMELAB_KEY_DIR:-${HOME:-/root}/.config/homelab}"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
@@ -108,48 +50,40 @@ die() { echo "error: $*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
||||
|
||||
# Self-elevate instead of dying: re-exec this exact invocation under sudo.
|
||||
# -E preserves the environment (HOMELAB_* overrides, Proton Pass vault vars)
|
||||
# across the re-exec. A no-op once already root.
|
||||
# -E preserves HOMELAB_*/vault env vars; pin HOMELAB_KEY_DIR too since whether
|
||||
# sudo carries $HOME across depends on the local sudoers policy. No-op if already root.
|
||||
require_root() {
|
||||
[ "$(id -u)" = 0 ] && return 0
|
||||
echo ">> $1 needs root — re-executing under sudo" >&2
|
||||
# $KEYDIR is derived from $HOME, and whether sudo carries $HOME across
|
||||
# depends on the local sudoers policy (env_reset/always_set_home). Pin the
|
||||
# resolved value so the re-exec looks for host keys where the invoking user
|
||||
# has them, not under /root.
|
||||
export HOMELAB_KEY_DIR="$KEYDIR"
|
||||
exec sudo -E -- "$SCRIPT_PATH" "${SCRIPT_ARGS[@]}"
|
||||
}
|
||||
|
||||
# Exactly one path matching a glob, or die. `ls glob | head -1` silently yields
|
||||
# an empty string when nothing matches (head exits 0, so set -e never fires) and
|
||||
# the failure only surfaces later as a confusing tar/dd error.
|
||||
# an empty string when nothing matches (head exits 0, so set -e never fires).
|
||||
one_match() {
|
||||
local what="$1"; shift
|
||||
local f=("$@") # caller expands the glob (nullglob is on)
|
||||
[ "${#f[@]}" -gt 0 ] || die "no $what found — did the build actually produce one?"
|
||||
# Say so instead of silently taking [0]: a stale result-sd/ symlink from an
|
||||
# earlier config is exactly how you flash the wrong image without a word.
|
||||
# A stale result-sd/ symlink from an earlier config is how you'd otherwise
|
||||
# flash the wrong image without a word — warn instead of silently taking [0].
|
||||
[ "${#f[@]}" -eq 1 ] \
|
||||
|| echo ">> warning: ${#f[@]} candidates for $what, using ${f[0]} (rm the stale ones)" >&2
|
||||
printf '%s\n' "${f[0]}"
|
||||
}
|
||||
|
||||
# Every whole-disk device backing a block device or a mounted path, one per
|
||||
# line. LVM/RAID/LUKS can sit on several at once (verified on terra:
|
||||
# /mnt/ssd_01 -> sdd AND sde), so a single lookup is not enough. Empty output
|
||||
# means "could not determine" — which callers must treat as unsafe, not as OK.
|
||||
# Every whole-disk device backing a block device or mounted path, one per line.
|
||||
# LVM/RAID/LUKS can span several disks at once (e.g. terra's /mnt/ssd_01),
|
||||
# so callers must treat empty output as "unknown", not "safe".
|
||||
disks_backing() {
|
||||
lsblk -rnso NAME,TYPE "$1" 2>/dev/null | awk '$2 == "disk" { print "/dev/" $1 }'
|
||||
}
|
||||
|
||||
# Label of the temporary UEFI boot entry arm_efi_bootnext() creates. Also the
|
||||
# key the ISO uses to delete it again once it has booted (see flake.nix).
|
||||
# Label of the temporary UEFI boot entry arm_efi_bootnext() creates; also what
|
||||
# the booted ISO matches to delete it again (flake.nix) — must match EXACTLY.
|
||||
EFI_LABEL="Homelab Installer"
|
||||
|
||||
# Boot numbers of every UEFI entry with exactly this label, one per line.
|
||||
# efibootmgr prints `Boot0002* Limine<TAB>HD(1,GPT,...)/\EFI\...`, so the
|
||||
# label runs from past the "Boot####* " prefix up to the first TAB.
|
||||
# (Character classes spelled out rather than {4}: mawk predates ERE intervals.)
|
||||
efi_entries_named() {
|
||||
efibootmgr 2>/dev/null | awk -v want="$1" '
|
||||
@@ -162,17 +96,11 @@ efi_entries_named() {
|
||||
}'
|
||||
}
|
||||
|
||||
# Arm a genuine one-shot boot of the staged installer WITHOUT any help from the
|
||||
# bootloader: create a UEFI boot entry that EFI-stub-boots the kernel straight
|
||||
# off the ESP, and point BootNext at it.
|
||||
#
|
||||
# Needed because "boot this once, then go back to normal" is not something
|
||||
# every bootloader can do. systemd-boot has it; terra's CachyOS runs Limine,
|
||||
# which reports `One-shot entry control: ✗` and has no equivalent, and whose
|
||||
# limine.conf is regenerated by pacman hooks anyway. BootNext is a firmware
|
||||
# feature, so it works underneath all of them — and the firmware clears it
|
||||
# after that one boot, which is what keeps the "a failed attempt still comes
|
||||
# back on the normal bootloader" property that makes this safe to try.
|
||||
# Arm a genuine one-shot boot of the staged installer without bootloader help:
|
||||
# create a UEFI entry that EFI-stub-boots the kernel off the ESP and point
|
||||
# BootNext at it. Needed because Limine (terra's CachyOS) has no one-shot
|
||||
# entry support; BootNext is a firmware feature so it works underneath any
|
||||
# bootloader, and the firmware clears it after one boot either way.
|
||||
arm_efi_bootnext() {
|
||||
local esp="$1" cmdline="$2"
|
||||
local esp_src esp_disk esp_part num n
|
||||
@@ -184,20 +112,17 @@ arm_efi_bootnext() {
|
||||
{ [ -n "$esp_disk" ] && [ -n "$esp_part" ]; } \
|
||||
|| die "couldn't work out the disk + partition number of the ESP ($esp -> $esp_src)"
|
||||
|
||||
# Clear anything left by an earlier attempt first, so repeated runs don't
|
||||
# slowly fill NVRAM with dead entries pointing at a wiped partition.
|
||||
# Clear anything left by an earlier attempt so NVRAM doesn't slowly fill
|
||||
# with dead entries pointing at a wiped partition.
|
||||
for n in $(efi_entries_named "$EFI_LABEL"); do
|
||||
echo ">> removing stale UEFI entry Boot$n ($EFI_LABEL)"
|
||||
efibootmgr -q -B -b "$n"
|
||||
done
|
||||
|
||||
# --create-only, NOT --create: the latter also pushes the entry to the front
|
||||
# of BootOrder, which would make a wiped installer the permanent default if
|
||||
# anything went wrong. This way the entry is reachable through BootNext and
|
||||
# nothing else, i.e. exactly once.
|
||||
#
|
||||
# The EFI stub loads `initrd=` off the volume it was itself loaded from, so
|
||||
# the path is relative to the ESP root and uses backslashes.
|
||||
# --create-only, NOT --create: --create also pushes the entry to the front of
|
||||
# BootOrder, which would make a wiped installer the permanent default on any
|
||||
# failure. This way it's reachable only via BootNext, exactly once. The EFI
|
||||
# stub loads `initrd=` relative to the ESP root, hence the backslash path.
|
||||
efibootmgr -q --create-only --disk "$esp_disk" --part "$esp_part" \
|
||||
--label "$EFI_LABEL" \
|
||||
--loader '\homelab-installer\bzImage' \
|
||||
@@ -210,11 +135,8 @@ arm_efi_bootnext() {
|
||||
}
|
||||
|
||||
# Sets tb / cpio / bbox — the kexec tarball plus the static cpio+gzip that
|
||||
# kexec-run.sh needs on PATH to rebuild its initrd.
|
||||
#
|
||||
# HOMELAB_KEXEC_TARBALL (with _CPIO / _GZIP) short-circuits the build and uses a
|
||||
# prebuilt installer instead. That lets the VM test in flake.nix drive this
|
||||
# script offline, and lets you re-kexec a box without rebuilding ~500MB.
|
||||
# kexec-run.sh needs on PATH to rebuild its initrd. HOMELAB_KEXEC_TARBALL (+
|
||||
# _CPIO/_GZIP) short-circuits the build to reuse a prebuilt installer instead.
|
||||
kexec_artifacts() {
|
||||
if [ -n "${HOMELAB_KEXEC_TARBALL:-}" ]; then
|
||||
tb="$HOMELAB_KEXEC_TARBALL"
|
||||
@@ -235,11 +157,10 @@ kexec_artifacts() {
|
||||
fi
|
||||
}
|
||||
|
||||
# True inside one of the throwaway live-installer environments this repo
|
||||
# produces (kexec's nixos-installer, or installer-iso's homelab-installer) —
|
||||
# i.e. `install <config> localhost` should wipe/install right here. False on
|
||||
# any real running OS, where the same command instead means "prepare and
|
||||
# reboot into an installer for THIS box" (see local_install_prepare_and_reboot).
|
||||
# True inside one of this repo's throwaway live-installer environments
|
||||
# (nixos-installer from kexec, or homelab-installer from installer-iso) —
|
||||
# i.e. `install <config> localhost` should wipe/install right here, not
|
||||
# prepare-and-reboot (see local_install_prepare_and_reboot).
|
||||
is_live_installer() {
|
||||
case "$(uname -n)" in
|
||||
nixos-installer | homelab-installer) return 0 ;;
|
||||
@@ -247,17 +168,12 @@ is_live_installer() {
|
||||
esac
|
||||
}
|
||||
|
||||
# `install <config> localhost` run on a REAL running OS (not already inside a
|
||||
# live installer): builds installer-iso, stages its kernel/initrd + the host's
|
||||
# pre-generated ssh key on the boot partition and the iso file on a non-OS
|
||||
# disk, points a systemd-boot one-shot entry at them with
|
||||
# homelab.install=<config> + homelab.keypart=<PARTUUID> on the kernel cmdline,
|
||||
# and reboots — a real ACPI reboot through firmware POST, deliberately NOT a
|
||||
# kexec jump (see terra's kexec-local gotcha in CLAUDE.md). The booted
|
||||
# installer's homelab-auto-install.service reads those params, picks the host
|
||||
# key back up and re-runs this exact `install <config> localhost` command
|
||||
# itself (now genuinely inside the installer) once homelab-checkout.service has
|
||||
# fetched the repo, finishing the job unattended.
|
||||
# `install <config> localhost` on a REAL running OS (not yet inside a live
|
||||
# installer): stages installer-iso's kernel/initrd + host key on the boot
|
||||
# partition, arms a one-shot boot with homelab.install=<config> on its
|
||||
# cmdline, and does a real ACPI reboot — deliberately not a kexec jump, per
|
||||
# terra's kexec-local gotcha in CLAUDE.md. The booted installer re-runs this
|
||||
# same command itself once its repo checkout succeeds, finishing unattended.
|
||||
local_install_prepare_and_reboot() {
|
||||
local config="$1" hostkey="$2" assume_yes="$3"
|
||||
require_root "preparing a local reinstall"
|
||||
@@ -271,17 +187,12 @@ local_install_prepare_and_reboot() {
|
||||
need stat
|
||||
need df
|
||||
|
||||
# Where to stage the installer, and how to make the box boot it exactly once.
|
||||
#
|
||||
# systemd-boot keeps its entries on $BOOT — the XBOOTLDR partition when there
|
||||
# is one, the ESP otherwise — which is not always /boot. Hardcoding /boot on
|
||||
# a box that mounts its ESP elsewhere just creates a directory on the root
|
||||
# filesystem and then reboots into an entry the firmware never sees.
|
||||
#
|
||||
# No systemd-boot (terra's CachyOS runs Limine) means no `bootctl set-oneshot`,
|
||||
# so fall back to the firmware's own BootNext — see arm_efi_bootnext(). That
|
||||
# path EFI-stub-boots the kernel directly, which requires it to sit on the ESP
|
||||
# itself rather than on a separate XBOOTLDR.
|
||||
# Where to stage the installer: use bootctl's reported $BOOT (XBOOTLDR or the
|
||||
# ESP), not a hardcoded /boot, since that's not always where the ESP mounts.
|
||||
# No systemd-boot (terra's CachyOS runs Limine) means no `bootctl
|
||||
# set-oneshot`, so fall back to firmware BootNext (arm_efi_bootnext()) —
|
||||
# which EFI-stub-boots the kernel directly and needs it on the ESP itself,
|
||||
# not a separate XBOOTLDR.
|
||||
local boot boot_mode esp
|
||||
esp="$(bootctl --print-esp-path 2>/dev/null)" \
|
||||
|| die "bootctl couldn't locate the ESP — is this box actually UEFI-booted?"
|
||||
@@ -296,10 +207,9 @@ local_install_prepare_and_reboot() {
|
||||
echo " own BootNext instead (bootloader in charge here: $(bootctl status 2>/dev/null | awk '/Product:/ {$1=""; print substr($0,2); exit}' || echo unknown))"
|
||||
fi
|
||||
|
||||
# No default/auto-picked location — the wrong disk here is destroyed
|
||||
# mid-install (see the OS-disk check below), so this always asks rather
|
||||
# than guessing. HOMELAB_INSTALLER_STAGE_DIR skips the prompt for scripted
|
||||
# use, but is otherwise just as explicit a choice as typing it in.
|
||||
# No default/auto-picked location: the wrong disk here is destroyed
|
||||
# mid-install (see the OS-disk check below), so this always asks unless
|
||||
# HOMELAB_INSTALLER_STAGE_DIR is set for scripted use.
|
||||
local stagedir="${HOMELAB_INSTALLER_STAGE_DIR:-}"
|
||||
if [ -z "$stagedir" ]; then
|
||||
echo ">> currently mounted filesystems:"
|
||||
@@ -323,17 +233,13 @@ local_install_prepare_and_reboot() {
|
||||
|| die "couldn't read the OS disk device from hosts/$config/disk-config.nix"
|
||||
osdisk_real="$(readlink -f "$osdisk")"
|
||||
|
||||
# --nofsroot matters: on btrfs, findmnt prints the subvolume as
|
||||
# `/dev/sdb2[/@]`, which is not a path lsblk can open. Without it the lookup
|
||||
# came back empty and the guard below was skipped entirely — i.e. it silently
|
||||
# allowed staging on the very disk about to be wiped. terra's current
|
||||
# CachyOS root is exactly that layout.
|
||||
# --nofsroot matters: on btrfs findmnt prints the subvolume as
|
||||
# `/dev/sdb2[/@]`, which lsblk can't open, silently skipping the guard
|
||||
# below and allowing staging on the disk about to be wiped (terra's layout).
|
||||
stage_src="$(findmnt -no SOURCE --nofsroot --target "$stagedir")" \
|
||||
|| die "$stagedir doesn't resolve to a mounted filesystem"
|
||||
# `|| true` so the explicit check below is what reports the problem: lsblk
|
||||
# exits nonzero on a device it can't parse, and under `set -e` + pipefail a
|
||||
# bare assignment from a failing substitution kills the script silently,
|
||||
# right past the fail-closed message.
|
||||
# `|| true` so the fail-closed check below reports the problem, rather than
|
||||
# `set -e`/pipefail silently killing the script on lsblk's nonzero exit.
|
||||
stage_disks="$(disks_backing "$stage_src" || true)"
|
||||
# Fail closed. "Couldn't determine the disk" is not "different disk".
|
||||
[ -n "$stage_disks" ] \
|
||||
@@ -344,30 +250,23 @@ local_install_prepare_and_reboot() {
|
||||
fi
|
||||
done
|
||||
|
||||
# stage-1 resolves findiso= by mounting each blkid-visible partition and
|
||||
# testing `-e /findiso$isoPath` (nixos/modules/system/boot/stage-1-init.sh).
|
||||
# For btrfs it mounts the volume's TOP level, so a path that lives inside a
|
||||
# subvolume (/@/...) is simply not there and the box boots to an emergency
|
||||
# shell — after it has already rebooted out of the working OS.
|
||||
# stage-1 mounts a btrfs volume's TOP level to resolve findiso=, so a path
|
||||
# inside a subvolume is unreachable and the box boots to an emergency shell
|
||||
# after it's already left the working OS. Refuse btrfs staging outright.
|
||||
stage_fstype="$(findmnt -no FSTYPE --target "$stagedir")"
|
||||
[ "$stage_fstype" != btrfs ] \
|
||||
|| die "$stagedir is btrfs: findiso= mounts the volume's top level, so a path inside a subvolume never resolves. Stage on a non-btrfs partition (ext4/vfat/ntfs)."
|
||||
|
||||
# PARTUUID of the staging partition. Handed to the installer as
|
||||
# homelab.logpart= so it can mount this partition rw and persist its whole
|
||||
# run — disko + nixos-install output included — to a file next to the iso.
|
||||
# This partition is on a DIFFERENT disk from the one disko wipes (guarded
|
||||
# above), so unlike $boot it SURVIVES the install: a failed attempt otherwise
|
||||
# leaves nothing to debug, its journal having died on tmpfs at the reboot.
|
||||
# Best-effort — an LVM/mdraid stage_src has no PARTUUID, in which case logging
|
||||
# PARTUUID of the staging partition, handed to the installer as
|
||||
# homelab.logpart= so it can persist the whole install's log there — it's on
|
||||
# a different disk than the one disko wipes, so it survives a failed
|
||||
# install. Best-effort: an LVM/mdraid stage_src has no PARTUUID, so logging
|
||||
# is simply skipped rather than blocking the install.
|
||||
local stage_partuuid
|
||||
stage_partuuid="$(lsblk -no PARTUUID "$stage_src" 2>/dev/null | head -1 | tr -d ' ' || true)"
|
||||
|
||||
# Last chance to back out. This is the most destructive command in the
|
||||
# script — it reboots the machine you are typing at and the wipe that
|
||||
# follows is unattended — so it confirms just like `flash` and `kexec-local`
|
||||
# do, both of which are less final than this.
|
||||
# Last chance to back out: this reboots the machine you're typing at into an
|
||||
# unattended wipe, so it confirms like `flash`/`kexec-local` do.
|
||||
if [ "$assume_yes" != "--yes" ]; then
|
||||
echo ">> about to REINSTALL this machine from scratch:"
|
||||
echo " hostname: $(uname -n)"
|
||||
@@ -392,16 +291,13 @@ local_install_prepare_and_reboot() {
|
||||
initrd="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.initialRamdisk)/initrd"
|
||||
isodir="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.isoImage)"
|
||||
iso="$(one_match 'installer iso' "$isodir"/iso/*.iso)"
|
||||
# The live ISO's root is a tmpfs; stage 1 finds the real system's init via
|
||||
# init=<toplevel>/init, which the grub/isolinux menu supplies on a normal
|
||||
# boot (iso-image.nix). EFI-stub-booting our own cmdline, we must pass it too
|
||||
# — omit it and stage 1 loop-mounts the iso fine, then dies on
|
||||
# The grub/isolinux menu normally supplies init=<toplevel>/init; EFI-stub
|
||||
# booting our own cmdline means we must pass it too, or stage 1 dies on
|
||||
# "stage 2 init script (/mnt-root//init) not found".
|
||||
toplevel="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.toplevel)"
|
||||
|
||||
# A short write is not visible until the reboot, when findiso finds a
|
||||
# truncated iso and drops to an emergency shell. Check first — `install`
|
||||
# prints no progress and the iso is ~1GB.
|
||||
# Check space before writing: a short write isn't visible until reboot,
|
||||
# when findiso finds a truncated ~1GB iso and drops to an emergency shell.
|
||||
local need_stage need_boot avail_stage avail_boot
|
||||
need_stage="$(stat -Lc %s "$iso")"
|
||||
need_boot="$(( $(stat -Lc %s "$kernel") + $(stat -Lc %s "$initrd") + $(stat -Lc %s "$hostkey") ))"
|
||||
@@ -417,15 +313,9 @@ local_install_prepare_and_reboot() {
|
||||
install -Dm644 "$initrd" "$boot/homelab-installer/initrd"
|
||||
install -Dm644 "$iso" "$stagedir/homelab-installer.iso"
|
||||
|
||||
# The ISO is built from a PUBLIC repo and deliberately carries no
|
||||
# credentials, so the host key has to travel with the staged installer or
|
||||
# the auto-install run has nothing to seed /etc/ssh with — and without that,
|
||||
# sops can't decrypt on boot #1, /etc/shadow gets written once with a locked
|
||||
# darman, and no later `deploy switch` can fix it (README).
|
||||
#
|
||||
# $boot lives on the OS disk, so disko destroys this copy minutes later. The
|
||||
# mode is advisory on vfat (permissions come from the mount's fmask, 0077 on
|
||||
# a NixOS/systemd-boot ESP) — it is the wipe, not the mode, doing the work.
|
||||
# The ISO is built from a public repo with no credentials, so the host key
|
||||
# must travel with the staged installer or sops can't decrypt on boot #1
|
||||
# (README). $boot is on the OS disk, so disko destroys this copy minutes later.
|
||||
install -Dm600 "$hostkey" "$boot/homelab-installer/ssh_host_ed25519_key"
|
||||
install -Dm644 "$hostkey.pub" "$boot/homelab-installer/ssh_host_ed25519_key.pub"
|
||||
boot_src="$(findmnt -no SOURCE --nofsroot --target "$boot")" \
|
||||
@@ -434,22 +324,15 @@ local_install_prepare_and_reboot() {
|
||||
[ -n "$boot_partuuid" ] \
|
||||
|| die "couldn't read a PARTUUID for $boot ($boot_src) — the installer needs it to find the host key"
|
||||
|
||||
# findiso= is a path relative to whatever partition the initrd finds it on
|
||||
# (it mounts every blkid-visible partition looking for it), not to `/`, if
|
||||
# $stagedir is a subdirectory of a bigger filesystem rather than a mountpoint
|
||||
# itself. It must KEEP its leading slash: stage-1 tests `-e /findiso$isoPath`,
|
||||
# so a bare `var/tmp/x.iso` becomes `/findisovar/tmp/x.iso` and never matches.
|
||||
# Prefixing then squeezing handles both ends: stagedir == the mountpoint
|
||||
# (strip leaves "") and mnt_point == "/" (strip leaves a relative path).
|
||||
# findiso= is relative to whichever partition the initrd finds it on, and
|
||||
# must KEEP its leading slash: stage-1 tests `-e /findiso$isoPath`, so a bare
|
||||
# `var/tmp/x.iso` becomes `/findisovar/tmp/x.iso` and never matches.
|
||||
mnt_point="$(findmnt -no TARGET --target "$stagedir")"
|
||||
iso_relpath="$(printf '/%s/%s' "${stagedir#"$mnt_point"}" homelab-installer.iso | tr -s /)"
|
||||
|
||||
# Identical either way — only the mechanism that gets the kernel booted with
|
||||
# it differs.
|
||||
# root=LABEL=<volumeID> matches what the ISO menu passes; findiso overwrites
|
||||
# /dev/root with the loop-mounted iso regardless, but keep it honest.
|
||||
# boot.shell_on_fail gives a shell instead of the reboot/ignore prompt if
|
||||
# stage 1 ever fails again. init= is the one that actually made this work.
|
||||
# root=LABEL=<volumeID> matches what the ISO menu passes (findiso overwrites
|
||||
# /dev/root regardless); boot.shell_on_fail gives a shell instead of a
|
||||
# reboot/ignore prompt if stage 1 fails again.
|
||||
local cmdline volumeID
|
||||
volumeID="$(nix eval --raw .#nixosConfigurations.installer-iso.config.isoImage.volumeID)"
|
||||
cmdline="init=$toplevel/init nohibernate root=LABEL=$volumeID boot.shell_on_fail loglevel=4 lsm=landlock,yama,bpf findiso=$iso_relpath homelab.install=$config homelab.keypart=$boot_partuuid"
|
||||
@@ -483,31 +366,24 @@ EOF
|
||||
require_tracked() {
|
||||
local config="$1" cfgfile="hosts/$1/configuration.nix" f
|
||||
[ -e "$cfgfile" ] || die "no $cfgfile in the repo"
|
||||
# No .git at all (e.g. a tarball export of the repo, no working tree), or no
|
||||
# git binary, means there's nothing that CAN be untracked — nothing to check.
|
||||
# Only skip on that, not on any other git failure.
|
||||
# No .git or no working tree (e.g. a tarball export) means nothing CAN be
|
||||
# untracked — skip only on that, not on any other git failure.
|
||||
command -v git >/dev/null 2>&1 || return 0
|
||||
git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
|
||||
# Every .nix in hosts/<config>/, not just configuration.nix: an untracked
|
||||
# disk-config.nix is exactly as invisible to the flake, and it is the file
|
||||
# that decides which disk gets wiped.
|
||||
# Every .nix in hosts/<config>/, not just configuration.nix — an untracked
|
||||
# disk-config.nix decides which disk gets wiped and is just as invisible.
|
||||
for f in "hosts/$config"/*.nix; do
|
||||
git -C "$REPO" ls-files --error-unmatch "$f" >/dev/null 2>&1 \
|
||||
|| die "$f is untracked — 'git add hosts/$config' first (flakes ignore untracked files)"
|
||||
done
|
||||
}
|
||||
|
||||
# The password field of a Proton Pass item ("--field password" prints the bare
|
||||
# value, one line), or empty if pass-cli is missing / logged out / has no such
|
||||
# item — every caller then falls back to the normal interactive prompt.
|
||||
#
|
||||
# Resolve the title to an item id among ACTIVE items first, because `item view
|
||||
# --item-title` has no state filter: Proton Pass keeps deleted items in the
|
||||
# trash, and if a trashed item shares the title, view can match THAT one and
|
||||
# return an empty password with exit 0. Empty is indistinguishable from "no such
|
||||
# item", so the only symptom is a silent fall back to the interactive prompt
|
||||
# even though the vault clearly holds the entry. (Hit for real on darman@neptun,
|
||||
# which had an Active and a Trashed copy.)
|
||||
# The password field of a Proton Pass item, or empty if pass-cli is missing /
|
||||
# logged out / has no such item — callers then fall back to an interactive
|
||||
# prompt. Resolves the title among ACTIVE items first, because `item view
|
||||
# --item-title` has no state filter and can silently match a trashed item of
|
||||
# the same title instead, returning an empty password with exit 0 (hit for
|
||||
# real on darman@neptun, which had both an Active and a Trashed copy).
|
||||
proton_pass_password() {
|
||||
local title="$1" vault="${HOMELAB_PASS_VAULT:-HomeLab}" id pw
|
||||
command -v pass-cli >/dev/null 2>&1 || return 0
|
||||
@@ -564,15 +440,10 @@ case "$cmd" in
|
||||
o=(-o ControlMaster=auto -o "ControlPath=$cm" -o ControlPersist=300 \
|
||||
-o StrictHostKeyChecking=accept-new)
|
||||
|
||||
# Root's password from Proton Pass, fed to ssh/scp via sshpass -e. Only the
|
||||
# first (master) connection authenticates; the rest ride the control socket.
|
||||
#
|
||||
# SSHPASS is exported here rather than passed as `env SSHPASS=... sshpass`.
|
||||
# Both end up equally safe at rest: `env` execs its target immediately, so
|
||||
# the assignment is only in argv for the sub-millisecond before exec, after
|
||||
# which /proc/PID/cmdline reads plain `sshpass -e`. Exporting just closes
|
||||
# that race window and drops a process. Either way the secret lives in the
|
||||
# child's environ, which is readable by the owner and root only.
|
||||
# Root's password from Proton Pass, fed to ssh/scp via sshpass -e; only the
|
||||
# first (master) connection authenticates, the rest ride the control socket.
|
||||
# Exported rather than `env SSHPASS=... sshpass` to close the sub-millisecond
|
||||
# argv-exposure race before exec (either way the secret only lives in environ).
|
||||
sp=()
|
||||
root_item="${HOMELAB_PASS_ROOT_ITEM:-root@$config}"
|
||||
root_pw="$(proton_pass_password "$root_item" || true)"
|
||||
@@ -613,24 +484,18 @@ case "$cmd" in
|
||||
ssh "${o[@]}" -O exit "root@$host" 2>/dev/null || true # close control socket
|
||||
unset SSHPASS
|
||||
|
||||
# NB: no ssh-keygen -R here on purpose. kexec-run.sh copies /etc/ssh/ssh_host_*
|
||||
# into the appended initrd and restore-remote-access.nix installs them back
|
||||
# into the installer's /etc/ssh, so the host key SURVIVES the jump. Clearing
|
||||
# known_hosts would just throw away the TOFU record for no reason.
|
||||
# NB: no ssh-keygen -R here on purpose — the kexec installer keeps the box's
|
||||
# ssh host key (restore-remote-access.nix), so known_hosts is still valid.
|
||||
|
||||
echo ">> box is kexec-ing. Wait ~1-2 min for the installer + network, then:"
|
||||
echo " ./deploy install $config $host"
|
||||
;;
|
||||
|
||||
kexec-local)
|
||||
# No ssh, no second machine: build the same RAM installer as `kexec`, but
|
||||
# run it directly on this box (you're sitting at it). The current shell
|
||||
# drops when the kernel switches, same as any reboot — that's expected,
|
||||
# not a failure. Disks are untouched; only the running kernel changes.
|
||||
#
|
||||
# This is a one-way trip on the machine you are typing at, so every check
|
||||
# that can fail is done BEFORE the point of no return, and nothing that the
|
||||
# jump depends on is cleaned up behind it (see the trap discussion below).
|
||||
# Build the same RAM installer as `kexec`, but run it directly on this box
|
||||
# (no ssh/second machine). One-way trip on the machine you're typing at, so
|
||||
# every check that can fail runs BEFORE the point of no return (see the
|
||||
# trap discussion below).
|
||||
require_root "kexec-local"
|
||||
|
||||
assume_yes=""
|
||||
@@ -719,11 +584,9 @@ case "$cmd" in
|
||||
[ "$(cat /sys/kernel/kexec_loaded 2>/dev/null || echo 0)" = 1 ] \
|
||||
|| { rm -rf "$stage"; die "kexec reported success but no image is loaded — aborting"; }
|
||||
|
||||
# THE trap MUST GO NOW. kexec-run.sh backgrounds `nohup sh -c "sleep 6 &&
|
||||
# $SCRIPT_DIR/kexec -e"` and returns immediately, so the binary that
|
||||
# performs the jump still has to exist ~6s after this script would normally
|
||||
# exit. Letting the EXIT trap rm -rf "$stage" deletes it out from under that
|
||||
# sleeping shell and the machine silently never jumps.
|
||||
# THE trap MUST GO NOW: kexec-run.sh backgrounds the actual jump ~6s in the
|
||||
# future, so an EXIT trap rm -rf'ing $stage here would delete the binary
|
||||
# that performs it and the machine would silently never jump.
|
||||
trap - EXIT
|
||||
|
||||
sync
|
||||
@@ -737,9 +600,8 @@ case "$cmd" in
|
||||
install)
|
||||
config="${2:-}"; host="${3:-}"; assume_yes="${4:-}"
|
||||
{ [ -n "$config" ] && [ -n "$host" ]; } || die "usage: ./deploy install <config> <host> [--yes]"
|
||||
# $KEYDIR, not a bare $HOME — see its definition. This same check runs
|
||||
# inside installer-iso, where homelab-auto-install.service has no $HOME and
|
||||
# has just dropped the key into /root/.config/homelab/<config>/.
|
||||
# $KEYDIR, not a bare $HOME — see its definition (also runs inside
|
||||
# installer-iso, which has no $HOME).
|
||||
hostkey="$KEYDIR/$config/ssh_host_ed25519_key"
|
||||
[ -f "$hostkey" ] || die "missing host key: $hostkey"
|
||||
[ -d "./hosts/$config" ] || die "no ./hosts/$config directory in the repo"
|
||||
@@ -761,9 +623,8 @@ case "$cmd" in
|
||||
[ -f "./hosts/$config/disk-config.nix" ] || die "no ./hosts/$config/disk-config.nix"
|
||||
|
||||
echo ">> disko .#$config onto this box's OS disk (WILL be wiped)"
|
||||
# `.#disko`, not github:nix-community/disko — the revision comes from this
|
||||
# repo's flake.lock rather than upstream master-of-the-day, and resolves
|
||||
# from the local store. See the nixos-anywhere input in flake.nix.
|
||||
# `.#disko`, not github:nix-community/disko: pins to this repo's
|
||||
# flake.lock revision instead of upstream master-of-the-day.
|
||||
nix run ".#disko" -- \
|
||||
--mode disko "./hosts/$config/disk-config.nix"
|
||||
|
||||
@@ -787,9 +648,7 @@ case "$cmd" in
|
||||
--target-host "root@$host")
|
||||
|
||||
# nixos-anywhere's --env-password reads root's ssh password from $SSHPASS
|
||||
# (it ships its own sshpass), so a vault hit skips the ssh-copy-id prompt.
|
||||
# Exported rather than `env SSHPASS=...` for consistency with `kexec`;
|
||||
# see the note there — it's a marginal win, not a leak fix.
|
||||
# (its own bundled sshpass), so a vault hit skips the ssh-copy-id prompt.
|
||||
root_item="${HOMELAB_PASS_ROOT_ITEM:-root@$config}"
|
||||
root_pw="$(proton_pass_password "$root_item" || true)"
|
||||
if [ -n "$root_pw" ]; then
|
||||
@@ -812,9 +671,7 @@ case "$cmd" in
|
||||
|
||||
echo ">> nixos-rebuild $cmd .#$config on darman@$host"
|
||||
# --ask-sudo-password, not the deprecated --use-remote-sudo: common.nix sets
|
||||
# security.sudo.wheelNeedsPassword = true, and --use-remote-sudo only
|
||||
# prefixes with sudo without ever prompting. Asks for darman's password
|
||||
# (the darman_password hash in each host's sops file).
|
||||
# wheelNeedsPassword = true, and --use-remote-sudo never actually prompts.
|
||||
rebuild=(nix run nixpkgs#nixos-rebuild -- "$cmd"
|
||||
--flake ".#$config"
|
||||
--target-host "darman@$host"
|
||||
@@ -823,30 +680,19 @@ case "$cmd" in
|
||||
item="${HOMELAB_PASS_ITEM:-darman@$config}"
|
||||
pw="$(proton_pass_password "$item" || true)"
|
||||
if [ -n "$pw" ] && command -v setsid >/dev/null 2>&1; then
|
||||
# nixos-rebuild prompts with getpass(), which reads /dev/tty and ignores a
|
||||
# piped stdin. setsid drops the controlling terminal, so getpass falls back
|
||||
# to stdin and takes the vault password (it warns about echo — harmless,
|
||||
# nothing is echoed since the password never reaches the terminal).
|
||||
#
|
||||
# Caveat of dropping the tty: EVERY prompt in the subtree now reads this
|
||||
# stdin, not just the sudo one. Feed the line a few times so a retry or a
|
||||
# second sudo ask doesn't hit EOF and hang. Anything else that prompts
|
||||
# (an ssh key passphrase, a host-key confirmation) will still fail — fix
|
||||
# those out of band rather than by feeding more lines here.
|
||||
# nixos-rebuild's getpass() reads /dev/tty and ignores piped stdin; setsid
|
||||
# drops the controlling terminal so it falls back to stdin instead. Every
|
||||
# prompt in the subtree now reads that stdin, so the password line is fed
|
||||
# a few times to survive a retry — anything else that prompts still fails.
|
||||
echo ">> sudo password from Proton Pass ($item)"
|
||||
printf '%s\n%s\n%s\n' "$pw" "$pw" "$pw" | setsid -w "${rebuild[@]}"
|
||||
else
|
||||
"${rebuild[@]}"
|
||||
fi
|
||||
|
||||
# jupiter's 29G eMMC has no room to just let generations pile up between
|
||||
# gc.dates=weekly runs (common.nix) — that's exactly how it filled up
|
||||
# once already. configurationLimit=5 (also common.nix) makes
|
||||
# switch-to-configuration prune generations beyond 5 as part of the
|
||||
# switch above, but pruning a generation only drops it as a GC root —
|
||||
# the store paths themselves still need an actual collect to free the
|
||||
# disk. So do that here, right after every switch, rather than waiting
|
||||
# up to a week for it to matter again.
|
||||
# jupiter's 29G eMMC has already filled up once waiting for the weekly gc
|
||||
# (common.nix). configurationLimit=5 only drops old generations as GC
|
||||
# roots, so collect explicitly here rather than waiting up to a week.
|
||||
if [ "$cmd" = switch ] && [ "$config" = jupiter ]; then
|
||||
echo ">> jupiter: collecting garbage post-switch (keeps the eMMC under the 5-generation cap)"
|
||||
need ssh
|
||||
@@ -889,9 +735,8 @@ case "$cmd" in
|
||||
sync
|
||||
|
||||
# If this config has a dedicated sops age key, drop it on the ROOT ext4
|
||||
# partition at /var/lib/sops-nix/age.txt so sops decrypts on first boot.
|
||||
# (The Pi's vfat partition isn't mounted at runtime, so the key can't live
|
||||
# there.) Key stays off-repo, out of the nix store, and out of the image.
|
||||
# partition (the Pi's vfat one isn't mounted at runtime) so sops decrypts
|
||||
# on first boot. Key stays off-repo, out of the nix store and the image.
|
||||
keyfile="$KEYDIR/$config/age.txt"
|
||||
if [ -f "$keyfile" ]; then
|
||||
echo ">> installing sops age key onto the root partition"
|
||||
|
||||
@@ -36,10 +36,9 @@ if [ "$show" -eq 1 ]; then
|
||||
exec nix shell nixpkgs#sops -c sops --decrypt "$file"
|
||||
fi
|
||||
|
||||
# sops opens $EDITOR on a temp file and re-encrypts only if it changed.
|
||||
# Pitfalls that cause "File has not changed, exiting":
|
||||
# - $EDITOR unset: no editor is on the `nix shell` PATH -> bundle one.
|
||||
# - GUI editor (code/zed) forks and returns instantly -> force --wait.
|
||||
# sops re-encrypts only if the $EDITOR session actually changed the temp file.
|
||||
# GUI editors (code/zed) return instantly unless forced to --wait, and if
|
||||
# $EDITOR is unset no editor exists on the `nix shell` PATH, so bundle one.
|
||||
editor="${VISUAL:-${EDITOR:-}}"
|
||||
extra=()
|
||||
case "$editor" in
|
||||
|
||||
@@ -1,50 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Import the OLD ZimaOS/CasaOS Immich database into the NixOS-managed one.
|
||||
# Run this ON jupiter, as root, ONCE, AFTER the first `./deploy switch jupiter`
|
||||
# that ships services/media/immich.nix (the empty `immich` DB must exist).
|
||||
#
|
||||
# The media files are moved separately — do that FIRST, it is a rename on the
|
||||
# same filesystem, so instant even at 9.1G. Move the CONTENTS, not the dir:
|
||||
# systemd.tmpfiles already created /mnt/data/AppData/immich on the first
|
||||
# deploy, so `mv <src> <dst>` would nest it as .../immich/upload/ and every
|
||||
# thumbnail lookup would ENOENT.
|
||||
# Run ONCE on jupiter, as root, after the first `./deploy switch jupiter` that
|
||||
# ships services/media/immich.nix (the empty `immich` DB must already exist).
|
||||
#
|
||||
# Move the media files separately FIRST (a same-filesystem rename, instant
|
||||
# even at 9.1G) — move the CONTENTS of /mnt/data/Immich/upload into
|
||||
# /mnt/data/AppData/immich, not the directory itself, or it nests under
|
||||
# .../immich/upload and every thumbnail lookup ENOENTs:
|
||||
# systemctl stop immich-server immich-machine-learning
|
||||
# mv /mnt/data/Immich/upload/* /mnt/data/AppData/immich/
|
||||
# chown -R immich:immich /mnt/data/AppData/immich
|
||||
# chmod 700 /mnt/data/AppData/immich
|
||||
# chown -R immich:immich /mnt/data/AppData/immich && chmod 700 /mnt/data/AppData/immich
|
||||
#
|
||||
# Expected afterwards: library/ upload/ thumbs/ encoded-video/ profile/ backups/
|
||||
#
|
||||
# The legacy cluster turned out to be Postgres 14 running VectorChord 0.3.0 +
|
||||
# pgvector 0.8.1 (NOT pgvecto.rs), the same extensions nixpkgs ships — so this
|
||||
# is a plain version-upgrade dump/restore and the smart-search and face
|
||||
# embeddings come across intact. No re-running the ML jobs over the library.
|
||||
# Upstream's accepted VectorChord range is >= 0.3, < 2.0, so 0.3.0 -> 1.1.1 is
|
||||
# a supported jump; the REINDEX at the end is what upstream asks for after a
|
||||
# version change.
|
||||
#
|
||||
# What this script does:
|
||||
# 1. cp -a the legacy PGDATA to a scratch dir (the original is never touched,
|
||||
# never even mounted rw — postgres would replay WAL into it).
|
||||
# 2. Boots that copy under immich's own PG14 image, pinned to the SAME
|
||||
# VectorChord version nixpkgs has (1.1.1), and runs `ALTER EXTENSION
|
||||
# vchord UPDATE` so the catalog matches the loaded library.
|
||||
# 3. Dumps it with the LOCAL pg_dump (17.x) over TCP, not the container's
|
||||
# pg_dump (14.x) — dumping with the newer tool is the supported direction.
|
||||
# 4. Restores into a scratch DB, hands ownership to the immich role, shows
|
||||
# you the row counts, and only swaps it into place after you confirm.
|
||||
#
|
||||
# Afterwards Immich runs its own schema migrations up to 2.7.5 on first start.
|
||||
# The legacy cluster is Postgres 14 + VectorChord 0.3.0 + pgvector 0.8.1 (the
|
||||
# same extensions nixpkgs ships), so this is a plain version-upgrade
|
||||
# dump/restore — smart-search and face embeddings come across intact with no
|
||||
# ML rerun needed.
|
||||
set -euo pipefail
|
||||
|
||||
LEGACY="${LEGACY:-/mnt/data/Immich/pg-data}"
|
||||
WORK="${WORK:-/var/tmp/immich-import}"
|
||||
# Pinned to EXACTLY what the legacy cluster records in pg_extension —
|
||||
# vchord 0.3.0 + pgvector 0.8.1 — so the old server reads its own indexes
|
||||
# without any in-place extension upgrade. The target side is vchord 1.1.1 /
|
||||
# pgvector 0.8.2, which is fine: a dump/restore rebuilds every index from
|
||||
# scratch, so only the index DEFINITION has to still be valid there.
|
||||
# Pinned to exactly what the legacy cluster's pg_extension records (vchord
|
||||
# 0.3.0/pgvector 0.8.1) so it reads its own indexes unmodified; the dump/
|
||||
# restore rebuilds indexes from scratch on the target's newer versions, so
|
||||
# only the index definitions need to stay valid.
|
||||
IMAGE="${IMAGE:-ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvector0.8.1}"
|
||||
CTR=immich-legacy-pg
|
||||
PORT="${PORT:-15432}"
|
||||
@@ -71,13 +49,10 @@ cp -a "$LEGACY" "$WORK/pgdata"
|
||||
# A crashed cluster leaves this behind; it makes the container refuse to start.
|
||||
rm -f "$WORK/pgdata/postmaster.pid"
|
||||
|
||||
# The dump runs over TCP (local pg_dump 17 -> published port), and this
|
||||
# cluster's own pg_hba wants a password for host connections — the marketplace
|
||||
# app's POSTGRES_PASSWORD is long gone, and POSTGRES_HOST_AUTH_METHOD only
|
||||
# applies when the image INITIALISES a cluster, not to an existing one. This is
|
||||
# a scratch copy bound to 127.0.0.1 for the length of one dump, so trust it.
|
||||
# REPLACE the file rather than appending: pg_hba is first-match-wins, and the
|
||||
# image's existing scram-sha-256 line would shadow anything added below it.
|
||||
# The marketplace app's original POSTGRES_PASSWORD is long gone, and
|
||||
# POSTGRES_HOST_AUTH_METHOD only applies when the image initializes a cluster
|
||||
# (not an existing one) — so pg_hba is REPLACED outright (not appended, since
|
||||
# it's first-match-wins) to trust this scratch copy while it's dumped.
|
||||
cat > "$WORK/pgdata/pg_hba.conf" <<'EOF'
|
||||
local all all trust
|
||||
host all all 0.0.0.0/0 trust
|
||||
@@ -101,10 +76,10 @@ for _ in $(seq 1 60); do
|
||||
done
|
||||
[ "${ready:-}" = 1 ] || { podman logs --tail 30 "$CTR"; die "legacy postgres never became ready"; }
|
||||
|
||||
# The compose stack's POSTGRES_USER is not recorded anywhere on disk and is NOT
|
||||
# necessarily "postgres" — the ZimaOS/CasaOS marketplace app used "casaos".
|
||||
# pg_isready reports "accepting connections" even for a role that doesn't
|
||||
# exist, so probe for one that can actually log in.
|
||||
# The original POSTGRES_USER isn't recorded on disk and wasn't necessarily
|
||||
# "postgres" (this marketplace app used "casaos"), and pg_isready reports
|
||||
# ready even for a role that doesn't exist — so probe for one that can
|
||||
# actually log in.
|
||||
if [ -z "$LEGACY_USER" ] || ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqt >/dev/null 2>&1; then
|
||||
for candidate in casaos immich postgres; do
|
||||
if podman exec "$CTR" psql -U "$candidate" -lqt >/dev/null 2>&1; then
|
||||
@@ -160,13 +135,11 @@ echo ">> errors logged: $(grep -c '^ERROR' "$WORK/restore.log" || true) (see $W
|
||||
grep '^ERROR' "$WORK/restore.log" | sort -u | head -10 | sed 's/^/ /' || true
|
||||
|
||||
step "handing ownership to the immich role"
|
||||
# --no-owner made everything owned by the restoring role (postgres); immich
|
||||
# connects as "immich" and its startup migrations run ALTER TABLE, so it must
|
||||
# own its own schema. NOT `REASSIGN OWNED BY postgres` — that also sweeps up
|
||||
# system objects and fails with "cannot reassign ownership of objects owned by
|
||||
# role postgres because they are required by the database system". Extension-
|
||||
# owned routines/types are excluded for the same reason; immich never alters
|
||||
# those, and they correctly stay with postgres.
|
||||
# immich's own ALTER TABLE migrations need it to own its schema, but plain
|
||||
# `REASSIGN OWNED BY postgres` also sweeps up system objects and fails on ones
|
||||
# the database system requires — so ownership is walked table-by-table
|
||||
# instead, skipping extension-owned routines/types, which correctly stay with
|
||||
# postgres.
|
||||
sudo -u postgres psql -qd "$STAGING_DB" <<'SQL'
|
||||
ALTER SCHEMA public OWNER TO immich;
|
||||
DO $$
|
||||
|
||||
@@ -15,6 +15,7 @@ sabnzbd_nzb_key: ENC[AES256_GCM,data:DNVenqhJ7wf5Ng0XRA1gJN95e+90e6D9NImOSHJv/Us
|
||||
sabnzbd_eweka_username: ENC[AES256_GCM,data:eLsTZoM8T8fAlGaXWlDaoQ==,iv:eawyGhN7+d6UfBIbI3y1qgq+MYBGrXP6VfAkSOK6llA=,tag:ELOfQGHU5NOxZFhKOKf8LA==,type:str]
|
||||
sabnzbd_eweka_password: ENC[AES256_GCM,data:Mt3ZHAe2wzacCQq3x9Uy8WxjrVNad1SmU6sl8ZgrkMLymfq2eP4JzO/uPdD33A==,iv:PnFT95Zxqz4QBpPF5PRloKpoa15AU7Ef/Owwy+iDotw=,tag:/uRX00RzHLJN3gws5Qz8SA==,type:str]
|
||||
gitea_hermes_webhook_secret: ENC[AES256_GCM,data:Q8e+mj05MJI7CEJwRonpOmQphAZ0CfnZFoGxrDSSiyHoH3BNhqBU5gBmzuu+6NK9OS33kN+JnFvrwCeEzVxooA==,iv:mdsKOMD5B0Jzh1YRmRh71P8Io9RFtI6aqAky5x+WxOQ=,tag:v3LKz5a8ayl7WAzIPbwj6Q==,type:str]
|
||||
couchdb_admin_password: ENC[AES256_GCM,data:QHkCFUwLbQdd5yKETI5qAz4CkEfsPcl2iCU8F9mX3PA=,iv:2dPNKjjoXEgm7wfC6MlhQTMvAXSNDtaXnjWl2ldl4fk=,tag:1ZltqH94Q5/6GbXAnaIgdg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -35,7 +36,7 @@ sops:
|
||||
CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt
|
||||
lastmodified: "2026-08-23T03:16:52Z"
|
||||
mac: ENC[AES256_GCM,data:uQcOxORIWugK43LpQLI7JEjH6oGooseKCQQt0d+n43i7o23JGdUN5Wy/iD7GqmtVVZod02gl1ohEXV+kpvgetFpAO5NZu76HUVPFgaLOx+2LjrR1pNpC+52Iqlx52uypwby9eDvnC01jLFHu2l13NGBrLM3JQGmEXF57phzM/Q4=,iv:H7o3gdx/1GmZ1FRm7z97TNmiVpm6YFCEk0Puw4ZETDs=,tag:bsz3bcK2z3szrwpo55bSzQ==,type:str]
|
||||
lastmodified: "2026-08-25T20:39:37Z"
|
||||
mac: ENC[AES256_GCM,data:Z59BCw8gETfddXqul4LXrq6V3LBJA1itF7A1VNUERwK4NfaUGwWUhbl9h7YF/srzgtg9yGjbFB/f5kwmT3k/TWTG+C0M/4KOyTVs4y5UvB9gI4g8awYbtnFDPRAcqqcxoMD0sgapgVcNh48KWv76ndF6UGn+QfWn9eF4KBP+ZzQ=,iv:57myu1aTMMSLTz+1ldwxdusnzh8cyPwrLiEIx3rLS9w=,tag:isthpdOydD4ZNoFZyflViw==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
|
||||
+4
-2
@@ -6,6 +6,8 @@ telegram_bot_token: ENC[AES256_GCM,data:WX+KFtoqFodkoWNwd7EXUrUJakZ9oaMZgg4OnCeL
|
||||
hermes_dashboard_oidc_client_secret: ENC[AES256_GCM,data:IMPNTPMKO+b7eyV4hyGfnvH1/i+W4IPDNjncoyB1oIV8WaB6nOJn0sSEuTUCKB94K+Y7bsVQU0zpbKdIYOdGqgmPzwMCsScxMt4SewTmiiqWxv6SQFf4EzMxgXqjMvH8PWDzLcI2C2tI/KcVS251iqRViOTFe1/tkm+mV8sJmEI=,iv:F/rOUDmJZoGPS9fObAni5ntyOqbbhMWDPdHGLTexwlA=,tag:ALf98DmB0JziGspZMiLCiw==,type:str]
|
||||
gitea_luna_token: ENC[AES256_GCM,data:0ypW9oVFs1mXYPhPareMFRdkSYcvHSCm+fQOd7/76lJEXi217r9dmg==,iv:j3TPm/iLk6pB6CmDePFBOlnhxWSbmLKvOhz06SM1T7k=,tag:ydErvC2mZ1RRnwNffiHkkg==,type:str]
|
||||
gitea_hermes_webhook_secret: ENC[AES256_GCM,data:lV78H0xAehPxusSO/QruOYkt7fkMJrW+ScZL4UWYvgnBGn/D+1XHYPyHCqe2sEEWSlIaAgWMMoZzoVJ1Z1NFVQ==,iv:GmTZxoH2iiL/vTVgPfziXIFYD+Rl3cbh9hqXvWps+iw=,tag:jtXEUOVFfKrpTRK7S9PZMA==,type:str]
|
||||
couchdb_luna_password: ENC[AES256_GCM,data:V91is2h7UskI1rtwMzQyduNXoDPTYNwa4sw9K9WU+wE=,iv:k976ImKR19+CvGOVsHsrqMaFFtSQxVi6zABCJuQ5AWE=,tag:W2SN4SET/+o4+Wt6BOJ1LA==,type:str]
|
||||
obsidian_luna_passphrase: ENC[AES256_GCM,data:fqHtP3g4J40ddYL9lzCixrisdC/DEJJermE=,iv:FU6BGNcBnyP8Rz3dNBk0+K0aAQTDW6/0aVaFm1rBFkk=,tag:Mg1aKqh++2rmU8ROaVIgDw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -26,7 +28,7 @@ sops:
|
||||
oyJ7PS3lW+PxH5AZkeeU7gXO/pz2oDku0aDOds7kaD3n0+qSWicQ+Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1eapjg6tdrr0fuvmgs3q3nlvnjkaxez298qynqqqxt0lpcv0lrsyq7ayxjk
|
||||
lastmodified: "2026-08-23T05:55:49Z"
|
||||
mac: ENC[AES256_GCM,data:a3vCmrQMCS25tNWrzTeiGmOHf4Fn356PO3uNa2HvS21EBCKTc6YWBj9KmpORdz+6t03JJe/4eiGdghGaLhRr+JXyQnaT54gSV+FhC3dH6blind746XN3h+Z9rxiva6apvcAGUZ9k01Js5IXN9efEMhcI6w0U4oVuVqtvShvg8A8=,iv:9kF3cJ1vyy2H3eH10DVCYmWeXv2MH4AFDiF8cOajlw4=,tag:zhombLVpL8M1TUtYur/gYQ==,type:str]
|
||||
lastmodified: "2026-08-25T21:51:24Z"
|
||||
mac: ENC[AES256_GCM,data:dz249hf3w8Tn0JStFOhhpdCZFMx2yxmNABx1CbeIQ/JlICAU82e4fg8AzJQY9EMOEs3Zx6L61yieljD4A/HLip5rDVlAXqqLeklW60eb7BHuSO79YfFgot+rS05g8WqFkRJYWzmkXhMbErCI133n72XEdeqMUU/m0djOUZlVRLs=,iv:d7G6TJRLfmXvQ2BUG9Hi83lOB9anhmb9qneLeVSCBhc=,tag:F6X/0z00PjiYum5kf8YApA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
darman_password: ENC[AES256_GCM,data:iZQERcXtyH+91yUc3r7U6jnFYrGQPFeCPk/9ZDfxOhPLlGMX3/iEZ+SzZ7a7rDKUeUAaQUsrqANLDLclRYm4Ngo09EkbDxBx5x2GpQwlqSAS45LHnTen9LTzisWghdy79Xnilq322eaB3g==,iv:ozx/BPLR8nZTKHZroKrrh2z6ZlVCuLydQ3aNY4XvcIg=,tag:CSrq5ZipYxtXTT8RintuHQ==,type:str]
|
||||
pihole_webpassword: ENC[AES256_GCM,data:5iOTqD0CcbOCnM1b4+RbajMTyAU=,iv:2ZRW7dshnPzWkuudrn6n92y4Z2n/6fdnBB7BO5/ypS4=,tag:8UZSqJM6dVECQgrF6v5Fuw==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:swWBS6icqidKMBC6Fo8IyOWIswWzGpRJGhFfA1JPlZsvqEo46J/kLjC6wfU4eOhSBsWTYiiqtHDaX05SKr8gwSxA/ERwj/Swf8bNHST4rbKrI4Cq5QDfzA==,iv:UUdVgkFATla6pmErn2oT06PuQ/kv9L8g0nX2CCPaJhI=,tag:97gAUSfxHzemVljl8FTULw==,type:str]
|
||||
tailscale_authkey: ENC[AES256_GCM,data:h3jbN9SrPZwUlYJPEbydvcf9tP/qGQPegnY87AYKOj9AsqAgxeEKfLYwtNeksLgcNBvCoJ1ALk7gqfrMw6sCrW7S7IFlDYhSJDAPfFkR6iczInBDpSfISg==,iv:ML+l4nmPfmSoTGQnNWpdxGZkdaq5HKEHaUxKcblmR8w=,tag:BCAbJ6Iy5ziBNLiOuI/5PA==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -21,7 +21,7 @@ sops:
|
||||
x6FfYadcRfqvSX60l6+TGdzq6xDpxLIZOJ8q19qZsAvB0in50HW5gg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1cpty7zrgnn6l97upq00w5wa8zcvnkxkdt2jvhlj97jh83exure4slha43t
|
||||
lastmodified: "2026-08-21T23:14:15Z"
|
||||
mac: ENC[AES256_GCM,data:zxV+szKjxb+7EV/hSyFeHUs/V2wZgIz8NO78/RDZeGoGtCjwDGiSIFsO1VQI5LZPW+O+pTnT2s4W5P0QuEjYrkPU5LRunW+Tv87XrDBqoR62vPvhRmt0wZXOQZu4oAn7LGpn24xo5QYKc2JowJWIVlyQs03UL3jQtgwfkG9u8k4=,iv:IZpZlUVqaKjO0aokwtF2hFAqC2D9H5bVMO6HezmYQ+Y=,tag:ij6pu3b6CQzctrJ87ifp5Q==,type:str]
|
||||
lastmodified: "2026-09-18T19:44:32Z"
|
||||
mac: ENC[AES256_GCM,data:PYNiUnDjBTgTAvTOAMk+XQEgcFSXLXgkw/vQCw0GhZsVHfkvd7jzkpk33ER64JqU4aPw+PRWo937mVKVEGDKkYVY2pP6hl2EJeOtfgjOuL9QpTNh+BKxDhYige5KsxPeMTEvtL7Y6dSsgAneaNPBEeLfZ0wiTVq1v2RJNgdNAGU=,iv:WYbyNn3/0QWXR7X8CHBKPPdVf0tYv8E4F4CYLjnuhVo=,tag:7ABexc8r6WZfskABUsquHg==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
version: 3.13.3
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
|
||||
# Hyprland (wayland) desktop: compositor, login manager, audio, portals.
|
||||
{
|
||||
imports = [ ./quickshell-greeter.nix ];
|
||||
|
||||
programs.hyprland.enable = true;
|
||||
|
||||
services.gnome.gnome-keyring.enable = true;
|
||||
security.pam.services.login.enableGnomeKeyring = true;
|
||||
security.pam.services.greetd.enableGnomeKeyring = true;
|
||||
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
settings.default_session.command =
|
||||
"${pkgs.tuigreet}/bin/tuigreet --time --cmd start-hyprland";
|
||||
};
|
||||
|
||||
# Audio (pipewire replaces pulseaudio/jack).
|
||||
security.rtkit.enable = true;
|
||||
services.pipewire = {
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
enable = true;
|
||||
enableLocalDB = true; # spins up a local, unauthenticated-on-localhost mongodb
|
||||
|
||||
# LibreChat's isEnabled() treats an UNSET var as false, not true — so
|
||||
# registration is closed unless this is explicit, despite .env.example
|
||||
# suggesting true is the default. Only reachable over the tailnet
|
||||
# (trusted interface, see module comment below), so leaving it open is
|
||||
# fine; flip to false once your account exists if you want it locked down.
|
||||
# LibreChat's isEnabled() treats an unset var as false, not true (despite
|
||||
# .env.example suggesting true is the default), so this must be explicit.
|
||||
# Fine to leave open since it's tailnet-only; flip to false once your
|
||||
# account exists to lock it down.
|
||||
env.ALLOW_REGISTRATION = true;
|
||||
|
||||
credentials = {
|
||||
@@ -32,33 +31,25 @@
|
||||
apiKey = "ollama";
|
||||
baseURL = "http://127.0.0.1:11434/v1";
|
||||
models = {
|
||||
# schema requires >=1 entry even though fetch=true overwrites it
|
||||
# at runtime with whatever's pulled (see loadModels in
|
||||
# hosts/terra/configuration.nix) — kept roughly in sync anyway
|
||||
# so the UI has sane names before the first fetch completes.
|
||||
default = [ "gemma4:12b" "qwen3.6:35b-a3b" ];
|
||||
# Schema requires >=1 entry even though fetch=true overwrites this at
|
||||
# runtime with whatever's pulled (hosts/terra/configuration.nix) —
|
||||
# kept roughly in sync so the UI has sane names before the first fetch.
|
||||
default = [ "gemma4:12b" "qwen3.6:35b-a3b" "VladimirGav/qwen3.8-27B-14GB-IQ4:latest" ];
|
||||
fetch = true; # pull the model list from ollama at startup
|
||||
};
|
||||
titleConvo = true;
|
||||
}
|
||||
];
|
||||
|
||||
# Persistent memory is opt-in at the CONFIG level — omitting this block
|
||||
# (as before) leaves the feature entirely off, no matter what a user
|
||||
# toggles in Settings > Personalization. `agent.provider` must match
|
||||
# endpoints.custom[].name above exactly ("Ollama"), which is how the
|
||||
# memory-extraction agent picks a backend/model.
|
||||
# Persistent memory is opt-in at the config level — omitting this block
|
||||
# leaves it off regardless of the user's Settings > Personalization toggle.
|
||||
# `agent.provider` must match endpoints.custom[].name above exactly.
|
||||
memory = {
|
||||
personalize = true; # still needs a per-user opt-in toggle in the UI
|
||||
# instructions REPLACES the default extraction prompt entirely (not
|
||||
# appended to it) — the 3b model (llama3.2:3b, dropped) was
|
||||
# defaulting to saving things like its own "I am a helpful
|
||||
# assistant..." boilerplate under an invented "user_conversation"
|
||||
# key, and even after adding this prompt, still saved "I am an AI
|
||||
# assistant with tool calling capabilities" as personal_info after
|
||||
# the user introduced THEMSELVES — a capability ceiling, not a
|
||||
# prompting problem. validKeys constrains it to a fixed whitelist
|
||||
# and instructions spells out the bar for each one.
|
||||
# instructions REPLACES the default extraction prompt, not appends to it —
|
||||
# needed because the smaller llama3.2:3b (since dropped) kept saving its
|
||||
# own assistant boilerplate as memories, a capability ceiling rather than
|
||||
# a prompting gap. validKeys whitelists what can be stored.
|
||||
validKeys = [ "user_preferences" "personal_info" "ongoing_projects" "technical_context" ];
|
||||
agent = {
|
||||
enabled = true;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# greetd greeter: a throwaway Hyprland running quickshell's greeter.qml as the
|
||||
# `greeter` user. Hyprland must exit after the login so greetd can start the
|
||||
# real session, hence the exit dispatch once qs returns.
|
||||
let
|
||||
cfg = config.homelab.greeter;
|
||||
hyprland = config.programs.hyprland.package;
|
||||
toLua = lib.generators.toLua { };
|
||||
|
||||
shellDir = ../../dotfiles/quickshell;
|
||||
|
||||
session = pkgs.writeShellScript "greeter-session" ''
|
||||
${lib.getExe pkgs.quickshell} -p ${shellDir}/greeter.qml
|
||||
${hyprland}/bin/hyprctl dispatch 'hl.dsp.exit()'
|
||||
'';
|
||||
|
||||
hyprConfig = pkgs.writeText "greeter-hyprland.lua" ''
|
||||
${lib.concatMapStrings (m: "hl.monitor(${toLua m})\n") cfg.monitors}
|
||||
hl.config(${toLua {
|
||||
input = { kb_layout = cfg.keyboardLayout; numlock_by_default = true; };
|
||||
animations.enabled = false;
|
||||
misc = {
|
||||
disable_hyprland_logo = true;
|
||||
disable_splash_rendering = true;
|
||||
background_color = "rgb(0a0a0a)";
|
||||
};
|
||||
ecosystem = { no_update_news = true; no_donation_nag = true; };
|
||||
}})
|
||||
|
||||
hl.env("QS_GREETER_OUTPUT", ${toLua cfg.primaryOutput})
|
||||
hl.env("QS_GREETER_USER", ${toLua cfg.defaultUser})
|
||||
hl.env("QS_GREETER_HOST", ${toLua config.networking.hostName})
|
||||
hl.env("QS_GREETER_SESSION", "start-hyprland")
|
||||
|
||||
hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("${session}")
|
||||
end)
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.homelab.greeter = {
|
||||
monitors = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.attrs;
|
||||
default = [ ];
|
||||
description = "hl.monitor() tables; reuse the user's so outputs line up.";
|
||||
};
|
||||
primaryOutput = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Output that gets the login panel (others get the backdrop only).";
|
||||
};
|
||||
defaultUser = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
};
|
||||
keyboardLayout = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "us";
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
settings.default_session.command =
|
||||
"${hyprland}/bin/start-hyprland -- --config ${hyprConfig}";
|
||||
};
|
||||
|
||||
# Hyprland and quickshell want a writable $HOME for cache/state.
|
||||
users.users.greeter = {
|
||||
home = "/var/lib/greeter";
|
||||
createHome = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
+72
-153
@@ -1,14 +1,12 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# Gitea — self-hosted git. stateDir/repositories were migrated from the old
|
||||
# ZimaOS docker instance straight into stateDir's default layout, so no
|
||||
# import step is needed — just chown it to the gitea user after first deploy
|
||||
# (currently darman:users from the CIFS copy):
|
||||
# chown -R gitea:gitea /mnt/data/AppData/gitea
|
||||
# Gitea — self-hosted git. Repos were migrated from the old ZimaOS docker
|
||||
# instance straight into stateDir's default layout, so after first deploy
|
||||
# just: chown -R gitea:gitea /mnt/data/AppData/gitea
|
||||
#
|
||||
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
|
||||
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not
|
||||
# :222 — the unpriv gitea user can't bind <1024).
|
||||
# SSH uses gitea's own server on :2222, since the unprivileged gitea user
|
||||
# can't bind :22 or :222 (<1024).
|
||||
let
|
||||
# Repos where the ci-bot account (see below) should be a Write collaborator
|
||||
# and whitelisted to push past branch protection. Add a repo here and
|
||||
@@ -21,40 +19,15 @@ let
|
||||
# nothing she does lands without darman clicking merge.
|
||||
lunaRepos = [ "darman/homelab" ];
|
||||
|
||||
# One gitea webhook per Hermes route. `route` is the path segment Hermes
|
||||
# dispatches on (http://mars.orbit.sol:8644/webhooks/<route>), so it must
|
||||
# match a key in the route config that hosts/mars/hermes-agent.nix writes.
|
||||
# One gitea webhook per Hermes route; `route` must match a key in the route
|
||||
# config hosts/mars/hermes-agent.nix writes.
|
||||
#
|
||||
# `events` are the strings gitea's HOOK API accepts. That set is coarser
|
||||
# than gitea's internal HookEventType set, and both collide on spelling with
|
||||
# the wire names Hermes matches on — three namespaces, one of which is a
|
||||
# trap. From routers/api/v1/utils/hook.go (updateHookEvents),
|
||||
# models/webhook/webhook.go (HasEvent) and modules/webhook/type.go (Event()):
|
||||
#
|
||||
# api event (here) delivers wire name (mars route)
|
||||
# -------------------- ------------------- ----------------------
|
||||
# pull_request_comment comment on a PR issue_comment
|
||||
# pull_request_review review with a body pull_request_comment
|
||||
# changes requested pull_request_rejected
|
||||
# approval pull_request_approved
|
||||
#
|
||||
# So this file and hosts/mars/hermes-agent.nix name the same event
|
||||
# differently on purpose, and neither is a typo.
|
||||
#
|
||||
# THE TRAP: updateHookEvents silently ignores strings it does not recognise,
|
||||
# so a plausible-looking but non-API name leaves the hook registered with no
|
||||
# events at all, delivering nothing and reporting no error. That is exactly
|
||||
# what "pull_request_review_comment" did here — a real HookEventType, and a
|
||||
# real value of X-GitHub-Event-Type, but not an API event name.
|
||||
#
|
||||
# There is no narrower name for reviews: HasEvent collapses approved,
|
||||
# rejected and review-comment onto HookEventPullRequestReview, so
|
||||
# `pull_request_review` is a single switch for all three. Approvals
|
||||
# therefore cannot be excluded here. They are dropped on the mars side
|
||||
# instead — the route's event list has no "pull_request_approved", so Hermes
|
||||
# answers {"status": "ignored"} without running the filter or spending a
|
||||
# token. Expect approvals in gitea's delivery log, answered 200 and ignored;
|
||||
# that is the design, not a failure.
|
||||
# `events` must be gitea's HOOK API event names, which gitea silently drops
|
||||
# if unrecognized — registering with no events and no error ("pull_request_
|
||||
# review_comment" did this: a real HookEventType, but not an API name).
|
||||
# `pull_request_review` also covers approvals with no narrower option, so
|
||||
# those are filtered on the mars side instead (answered 200 and ignored —
|
||||
# expected, not a failure).
|
||||
giteaHermesHooks = [
|
||||
{
|
||||
name = "PR comments Hermes";
|
||||
@@ -81,9 +54,8 @@ in
|
||||
server = {
|
||||
DOMAIN = "git.mgaction.town";
|
||||
SSH_DOMAIN = "git.mgaction.town";
|
||||
# https, not http: neptun's Caddy terminates TLS for this name. Gitea
|
||||
# builds its absolute URLs (clone buttons, redirects, webhooks) from
|
||||
# ROOT_URL, so an http:// value hands out downgraded links.
|
||||
# https, not http: neptun's Caddy terminates TLS here, and gitea builds
|
||||
# its absolute URLs (clone buttons, webhooks) from ROOT_URL.
|
||||
ROOT_URL = "https://git.mgaction.town/";
|
||||
HTTP_PORT = 3000;
|
||||
START_SSH_SERVER = true;
|
||||
@@ -94,20 +66,11 @@ in
|
||||
DISABLE_REGISTRATION = true;
|
||||
};
|
||||
security = {
|
||||
# Gitea refuses to deliver a webhook to any host outside this list,
|
||||
# which defaults to `external` — "a valid non-private unicast IP".
|
||||
# Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT),
|
||||
# which is neither RFC1918 private nor, as far as gitea's matcher is
|
||||
# concerned, external — so the hermes relay on mars was refused with
|
||||
# deny 'mars.orbit.sol(100.64.0.6:8644)'
|
||||
# even though nothing here is private in the RFC1918 sense. Adding
|
||||
# the tailnet CIDR is what makes tailnet-internal webhook targets
|
||||
# deliverable at all; `external` is kept so a future webhook to a
|
||||
# public service (discord, slack) still works without another edit.
|
||||
#
|
||||
# This lives in [security], not [webhook]: the webhook-section key is
|
||||
# deprecated and now just falls back to this one, which is the name
|
||||
# the delivery error itself reports.
|
||||
# Gitea's default `external` webhook target filter treats tailnet
|
||||
# addresses (100.64.0.0/10, CGNAT) as neither private nor external, so
|
||||
# the mars hermes relay was refused until the CIDR was added here.
|
||||
# Lives under [security], not the deprecated [webhook] key it falls
|
||||
# back to.
|
||||
ALLOWED_HOST_LIST = "external,100.64.0.0/10";
|
||||
};
|
||||
actions = {
|
||||
@@ -118,14 +81,10 @@ in
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 2222 ];
|
||||
|
||||
# `gitea <args>` == the admin CLI, as the gitea user, against the real
|
||||
# state dir — mirrors the `hermes` alias on mars. Worth having because none
|
||||
# of that is discoverable: the package is not in systemPackages (so `gitea`
|
||||
# is not otherwise on PATH at all), every admin subcommand needs
|
||||
# GITEA_WORK_DIR pointed at a stateDir that is not the module default, and
|
||||
# it has to run as the gitea user or it writes root-owned files into that
|
||||
# directory. Both paths come from the config rather than being spelled out,
|
||||
# so a package bump or a stateDir move cannot leave this stale.
|
||||
# `gitea <args>` == the admin CLI as the gitea user against the real state
|
||||
# dir. Not otherwise usable: the package isn't on PATH, and admin
|
||||
# subcommands need GITEA_WORK_DIR set and root-owned files avoided by
|
||||
# running as gitea.
|
||||
#
|
||||
# Handy ones:
|
||||
# gitea admin user generate-access-token --username luna \
|
||||
@@ -138,15 +97,13 @@ in
|
||||
|
||||
users.users.gitea.extraGroups = [ "users" ];
|
||||
|
||||
# Runner instance registered against this same gitea. Jobs run in containers
|
||||
# (podman, via services/containers.nix — already enabled on jupiter), one
|
||||
# image per requested `runs-on` label using the catthehacker act-compatible
|
||||
# images (same ones upstream `act`/Forgejo docs recommend).
|
||||
# Runner instance registered against this same gitea. Jobs run in podman
|
||||
# containers (services/containers.nix), one image per `runs-on` label, using
|
||||
# the catthehacker act-compatible images.
|
||||
#
|
||||
# tokenFile points at an env file rendered by sops (TOKEN=<registration
|
||||
# token>, see hosts/jupiter/secrets.nix) rather than a plain `token`, so the
|
||||
# secret never lands in the Nix store. The registration token itself is NOT
|
||||
# generated by this module — it comes from gitea once Actions is enabled:
|
||||
# tokenFile (not `token`) keeps the sops-rendered secret out of the Nix
|
||||
# store. The registration token isn't generated by this module — get it
|
||||
# from gitea once Actions is enabled:
|
||||
# su gitea -s /bin/sh -c \
|
||||
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token'
|
||||
# then written into secrets/jupiter.yaml as gitea_runner_token.
|
||||
@@ -161,23 +118,18 @@ in
|
||||
];
|
||||
};
|
||||
|
||||
# ci-bot: dedicated account CI workflows push as (kept separate from any
|
||||
# human account so its own PAT can be scoped/rotated/revoked independently).
|
||||
# Collaborator access + branch-protection push-whitelisting have no CLI or
|
||||
# config-file surface in gitea — only the HTTP API — so this is the one
|
||||
# part of the setup that stays imperative even though it's nix-triggered:
|
||||
# a oneshot that PUTs/PATCHes the API into the desired state on every
|
||||
# deploy where its script changed (adding a repo to `ciBotRepos` and
|
||||
# redeploying is enough to pick it up; it won't self-heal a manual revert
|
||||
# done via the web UI unless the unit is also restarted).
|
||||
# ci-bot: dedicated account CI workflows push as, so its PAT can be scoped
|
||||
# and rotated independently of any human account. Collaborator access and
|
||||
# branch-protection whitelisting have no CLI/config-file surface in gitea —
|
||||
# only the HTTP API — so this oneshot re-applies the desired state via
|
||||
# PUT/PATCH on every deploy (won't self-heal a manual UI revert unless
|
||||
# restarted).
|
||||
#
|
||||
# Auth for those API calls is darman's OWN token (named
|
||||
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository +
|
||||
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos
|
||||
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on
|
||||
# the collaborator/branch-protection endpoints; write:user is additionally
|
||||
# needed to push ci-bot's token below as a secret on darman's own account.
|
||||
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
|
||||
# Auth is darman's own token (write:repository + write:user, see
|
||||
# hosts/jupiter/secrets.nix): an owner-scoped token is required by the
|
||||
# collaborator/branch-protection endpoints, and write:user is needed to
|
||||
# push ci-bot's token as a secret on darman's account — ci-bot can't grant
|
||||
# itself access.
|
||||
#
|
||||
# ci-bot's own push token (separate secret, ci_bot_token) is generated
|
||||
# once via:
|
||||
@@ -255,47 +207,26 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A.,
|
||||
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot:
|
||||
# Hermes runs on mars, takes instructions over Telegram, and can be
|
||||
# prompt-injected via tool output — a dedicated account with its own
|
||||
# scoped, revocable token keeps that blast radius off darman's own
|
||||
# credentials, and the branch-protection whitelists below keep it off
|
||||
# `master` entirely regardless of what the token can technically do.
|
||||
# She gets Write collaborator access (needed to push a branch and open a
|
||||
# PR against the same repo — this instance has no fork workflow), but:
|
||||
# - enable_push + enable_push_whitelist(darman only): nobody but darman
|
||||
# can push straight to master; luna can only land on a side branch.
|
||||
# - enable_merge_whitelist(darman only): opening a PR is not the same
|
||||
# as merging one — only darman can click merge.
|
||||
# - required_approvals=1 + enable_approvals_whitelist(darman only):
|
||||
# an approval has to come from darman specifically, not luna
|
||||
# rubber-stamping her own PR from a second identity.
|
||||
# This covers the SERVER side only (account + collaborator + branch
|
||||
# protection). The client side — git/tea inside the hermes-agent container,
|
||||
# and the token below — lives in hosts/mars/hermes-agent.nix.
|
||||
# luna: Hermes Agent's gitea identity, deliberately PR-tier only (not
|
||||
# push-tier like ci-bot) — Hermes runs on mars, takes Telegram instructions,
|
||||
# and can be prompt-injected via tool output, so branch protection below
|
||||
# keeps her off `master` regardless of what her token can technically do:
|
||||
# - enable_push_whitelist(darman only): nobody but darman pushes to master.
|
||||
# - enable_merge_whitelist(darman only): opening a PR isn't merging one.
|
||||
# - required_approvals=1 + enable_approvals_whitelist(darman only): no
|
||||
# self-approval from a second identity.
|
||||
# This is the server side only; the client side (git/tea, token) is in
|
||||
# hosts/mars/hermes-agent.nix.
|
||||
#
|
||||
# luna's own push token is generated once, the same way ci-bot's was:
|
||||
# su gitea -s /bin/sh -c \
|
||||
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
|
||||
# --username luna --scopes write:repository,write:issue,read:user'
|
||||
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
|
||||
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
|
||||
# since luna isn't a CI workflow running inside gitea, she's an external
|
||||
# agent calling out to it.
|
||||
# luna's push token is generated once (same as ci-bot's, username luna,
|
||||
# scopes write:repository,write:issue,read:user) and stored as a secret —
|
||||
# NOT pushed into gitea as an Actions secret, since she's an external agent
|
||||
# calling in, not a CI workflow.
|
||||
#
|
||||
# **write:issue is NOT optional and is easy to miss**: this token started
|
||||
# life as `write:repository` alone, which clones, fetches and pushes
|
||||
# branches perfectly well — so everything looks fine right up until the
|
||||
# first `tea pr create`, which gitea rejects with
|
||||
# token scope=write:repository,read:user required=read:issue
|
||||
# A pull request IS an issue in gitea's data model, so every /pulls
|
||||
# endpoint is gated on the *issue* scope category, not the repository one.
|
||||
# write:issue covers it (in gitea's scope model write:X implies read:X);
|
||||
# read:issue alone would satisfy the GET half and then fail the POST that
|
||||
# actually opens the PR. The error names read:issue only because that's
|
||||
# the first check tea trips on. Rotating the token is free — the prepare
|
||||
# oneshot on mars does delete-then-add for the tea login on every start.
|
||||
# write:issue is required, not optional: a PR is an issue in gitea's data
|
||||
# model, so `tea pr create` needs it even though push/fetch work fine on
|
||||
# write:repository alone. The resulting error misleadingly names read:issue
|
||||
# (the first check tea trips), not write:issue.
|
||||
systemd.services.gitea-luna-provision = {
|
||||
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
|
||||
after = [ "gitea.service" ];
|
||||
@@ -359,14 +290,10 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# Register one Gitea webhook per Hermes route (giteaHermesHooks above).
|
||||
# Idempotent: each target URL is updated if a hook for it already exists and
|
||||
# created otherwise.
|
||||
#
|
||||
# It deliberately does NOT delete anything, including hooks for routes that
|
||||
# were removed from the list above. Retiring one is a one-off, done by hand
|
||||
# in the repo's Settings -> Webhooks, so that a redeploy can never silently
|
||||
# unregister a hook someone added on purpose.
|
||||
# Register one Gitea webhook per Hermes route (giteaHermesHooks above),
|
||||
# idempotently (update if the target URL exists, else create). Deliberately
|
||||
# never deletes — a hook for a route removed from the list is retired by
|
||||
# hand in Settings -> Webhooks, not silently by a redeploy.
|
||||
systemd.services.gitea-hermes-webhook-provision = {
|
||||
description = "Provision Gitea webhooks for Hermes routes";
|
||||
after = [ "gitea.service" ];
|
||||
@@ -386,24 +313,17 @@ in
|
||||
set -euo pipefail
|
||||
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
|
||||
|
||||
# Neither secret is ever passed as an argument. This unit runs as the
|
||||
# gitea user on a multi-user box, where /proc/<pid>/cmdline is
|
||||
# world-readable for the lifetime of the process — so `-H "Authorization:
|
||||
# token $t"` would publish the admin token, and `jq --arg secret "$s"`
|
||||
# the webhook secret. The token goes into a 0600 curl config file
|
||||
# instead (printf is a shell builtin, so the substitution below never
|
||||
# reaches an argv), the webhook secret into jq via --rawfile, and the
|
||||
# request body into curl on stdin with --data @-.
|
||||
# Secrets never go on argv, since /proc/<pid>/cmdline is world-readable
|
||||
# on this multi-user box: the token goes into a 0600 curl config file
|
||||
# (printf avoids argv entirely), the webhook secret into jq via
|
||||
# --rawfile, and the body into curl via stdin.
|
||||
authcfg="$(mktemp)"
|
||||
trap 'rm -f "$authcfg"' EXIT
|
||||
chmod 0600 "$authcfg"
|
||||
printf 'header = "Authorization: token %s"\n' "$(cat "$TOKEN_FILE")" > "$authcfg"
|
||||
|
||||
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
|
||||
# above: After=gitea.service only means the process started, not that it
|
||||
# is serving HTTP yet. Without this the first curl below fails under
|
||||
# `set -e`, and a Type=oneshot with no Restart= stays failed — leaving
|
||||
# the webhooks silently unregistered until someone restarts the unit.
|
||||
# Same readiness gate as the other provisioning units: After=gitea.service
|
||||
# only means the process started, not that it's serving HTTP yet.
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fs "$api/version" >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
@@ -413,10 +333,9 @@ in
|
||||
local name="$1" route="$2" events="$3" url body hook_id
|
||||
url="http://mars.orbit.sol:8644/webhooks/$route"
|
||||
|
||||
# rtrimstr: sops stores this without a trailing newline, but one
|
||||
# slipping in would change the key the HMAC is computed with and make
|
||||
# every delivery fail signature validation on the Hermes side. The
|
||||
# same trim happens there, so both ends agree either way.
|
||||
# rtrimstr: a stray trailing newline would change the HMAC key and
|
||||
# break signature validation on the Hermes side, which trims the same
|
||||
# way.
|
||||
body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \
|
||||
--arg url "$url" --arg name "$name" --argjson events "$events" \
|
||||
'{type: "gitea", name: $name, active: true, events: $events,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Plain CouchDB 3 node, tuned as the backend for Obsidian Self-hosted LiveSync
|
||||
# (vrtmrz/obsidian-livesync), which replicates the vault into it via PouchDB.
|
||||
#
|
||||
# Published PUBLICLY as https://notes.mgaction.town via neptun's caddy, since
|
||||
# Obsidian's mobile apps refuse cleartext HTTP and jupiter's *.jupiter.sol
|
||||
# names can't get a real cert — so the settings below are security-relevant:
|
||||
# - `require_valid_user` in both [chttpd] and [chttpd_auth], else CouchDB
|
||||
# answers unauthenticated GETs on the open internet.
|
||||
# - neptun's vhost allowlists only the plugin's endpoints; Fauxton and
|
||||
# cluster/config are reachable only over the tailnet.
|
||||
# - Turn on the plugin's end-to-end encryption (+ "Obfuscate Properties"),
|
||||
# so this server only ever holds ciphertext — what makes a
|
||||
# publicly-reachable credentialed database an acceptable trade.
|
||||
#
|
||||
# Its passphrase must stay a SEPARATE secret from couchdb_admin_password:
|
||||
# the CouchDB password is stored here and in secrets/jupiter.yaml, while
|
||||
# the E2EE passphrase never leaves the clients (kept in the HomeLab Proton
|
||||
# Pass vault, not sops) — reusing one string for both would hand the
|
||||
# decryption key to whoever gets the CouchDB credential. Losing the
|
||||
# passphrase costs the remote database, not the notes: wipe and
|
||||
# re-initialize from a device that still holds the plaintext vault.
|
||||
{
|
||||
services.couchdb = {
|
||||
enable = true;
|
||||
|
||||
# Listens on all interfaces, but :5984 is not opened in the firewall, so
|
||||
# it's reachable only over tailscale0 (trusted) and localhost — the path
|
||||
# neptun's caddy takes.
|
||||
bindAddress = "0.0.0.0";
|
||||
port = 5984;
|
||||
|
||||
# The vault database is the ONLY copy of the notes once LiveSync is the
|
||||
# source of truth, so it belongs on the array, not the 29G eMMC — all
|
||||
# three default under /var/lib/couchdb and must move together.
|
||||
databaseDir = "/mnt/data/AppData/couchdb";
|
||||
viewIndexDir = "/mnt/data/AppData/couchdb";
|
||||
configFile = "/mnt/data/AppData/couchdb/local.ini";
|
||||
|
||||
# [admins] ini fragment from sops; services.couchdb.adminPass would render
|
||||
# into the world-readable store instead.
|
||||
#
|
||||
# ⚠️ CouchDB hashes the password at startup and persists it to local.ini
|
||||
# (above), which then takes precedence — so changing the sops value alone
|
||||
# does NOT rotate it. Also delete the `[admins]` line from
|
||||
# /mnt/data/AppData/couchdb/local.ini and restart.
|
||||
extraConfigFiles = [ config.sops.templates."couchdb-admins.ini".path ];
|
||||
|
||||
# Values taken from LiveSync's own CouchDB setup documentation; the plugin
|
||||
# refuses to replicate (or silently truncates) without them.
|
||||
extraConfig = {
|
||||
couchdb = {
|
||||
# Creates _users/_replicator on first boot instead of leaving the node
|
||||
# in the un-set-up state where every request 500s.
|
||||
single_node = "true";
|
||||
# LiveSync splits notes into chunks, but a big pasted image still
|
||||
# arrives as one document. 8MB (the default) is too small.
|
||||
max_document_size = "50000000";
|
||||
};
|
||||
|
||||
chttpd = {
|
||||
require_valid_user = "true";
|
||||
max_http_request_size = "4294967296";
|
||||
enable_cors = "true";
|
||||
};
|
||||
|
||||
chttpd_auth = {
|
||||
require_valid_user = "true";
|
||||
authentication_redirect = "/_utils/session.html";
|
||||
};
|
||||
|
||||
httpd = {
|
||||
# Makes CouchDB answer 401 with a WWW-Authenticate challenge rather
|
||||
# than a bare 401 body — the plugin's basic-auth flow depends on it.
|
||||
"WWW-Authenticate" = ''Basic realm="couchdb"'';
|
||||
enable_cors = "true";
|
||||
};
|
||||
|
||||
# Obsidian is an Electron/Capacitor app, so its requests carry these
|
||||
# non-http origins. Without them desktop and mobile both fail CORS
|
||||
# preflight and the plugin reports a bare "cannot connect".
|
||||
cors = {
|
||||
credentials = "true";
|
||||
origins = "app://obsidian.md,capacitor://localhost,http://localhost";
|
||||
headers = "accept, authorization, content-type, origin, referer";
|
||||
methods = "GET, PUT, POST, HEAD, DELETE";
|
||||
max_age = "3600";
|
||||
};
|
||||
|
||||
# The module points [log] file at /var/log/couchdb.log, which nothing
|
||||
# rotates — on a 29G eMMC an info-level log of every replication request
|
||||
# is a slow disk-fill. stderr hands it to journald's capped storage
|
||||
# instead (the file setting is then ignored).
|
||||
log = {
|
||||
writer = "stderr";
|
||||
level = "warning";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# /mnt/data/AppData is drwx--x--- darman:users, so the couchdb user needs
|
||||
# group "users" just to traverse into its own database dir. The dir itself
|
||||
# is created couchdb:couchdb by the module's tmpfiles rule.
|
||||
users.users.couchdb.extraGroups = [ "users" ];
|
||||
|
||||
# databaseDir is outside /var/lib, so systemd derives no mount dependency
|
||||
# from it. Without this CouchDB starts with the array missing, creates an
|
||||
# empty database on the eMMC, and LiveSync sees a remote vault that lost
|
||||
# every note — which it would then happily replicate back to the clients.
|
||||
systemd.services.couchdb.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Cinephage — indexer search + streaming/library manager. Runs the official
|
||||
# container image, not upstream's nix flake module: its npmDepsHash is stale
|
||||
# against its own package-lock.json, and a transitive dep hard-enforces pnpm,
|
||||
# breaking the nix-sandboxed npm build regardless. Docker is the actually-
|
||||
# maintained path. BETTER_AUTH_SECRET (paired sops secret in
|
||||
# hosts/jupiter/secrets.nix) signs sessions/encrypts stored API keys — must
|
||||
# be static, not app-generated, or losing it invalidates everything.
|
||||
# Cinephage — indexer search + streaming/library manager, run as the official
|
||||
# container image rather than upstream's nix flake module (its npmDepsHash is
|
||||
# stale and a transitive dep hard-enforces pnpm, breaking the sandboxed npm
|
||||
# build). BETTER_AUTH_SECRET (paired sops secret, hosts/jupiter/secrets.nix)
|
||||
# signs sessions and encrypts stored API keys — keep it static, since losing
|
||||
# it invalidates everything.
|
||||
{
|
||||
virtualisation.oci-containers.containers.cinephage = {
|
||||
image = "ghcr.io/moldytaint/cinephage:latest";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{ config, ... }:
|
||||
|
||||
# MediaManager — media request/library manager. Module comes from the
|
||||
# community flake input `mediamanager-nix`, not nixpkgs. Paired sops secret
|
||||
# in hosts/jupiter/secrets.nix — without it the module mints+discards a
|
||||
# random auth token_secret on every restart, logging everyone out.
|
||||
# Port 8010: 8000 is taken by audiobookshelf on this host.
|
||||
# MediaManager — media request/library manager (module from the
|
||||
# `mediamanager-nix` flake input, not nixpkgs). The paired sops secret
|
||||
# (hosts/jupiter/secrets.nix) is required — without it the module mints a
|
||||
# random token_secret every restart, logging everyone out; port 8010 since
|
||||
# audiobookshelf already holds 8000.
|
||||
{
|
||||
services.media-manager = {
|
||||
enable = true;
|
||||
@@ -45,9 +45,9 @@
|
||||
MEDIAMANAGER_INDEXERS__PROWLARR__API_KEY=${config.sops.placeholder.prowlarr_api_key}
|
||||
'';
|
||||
|
||||
# HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 on disk —
|
||||
# group has no write bit. media-manager is in "users" (below); the dirs
|
||||
# themselves were chmod g+w by hand once (not declarative — see CLAUDE.md
|
||||
# gotchas), since this is pre-existing data, not something tmpfiles owns.
|
||||
# HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 (no group
|
||||
# write bit); media-manager is in "users" (below), and the dirs were
|
||||
# chmod g+w by hand once since this is pre-existing data, not something
|
||||
# tmpfiles owns.
|
||||
users.users.media-manager.extraGroups = [ "users" ];
|
||||
}
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
{ config, pkgs, inputs, ... }:
|
||||
|
||||
# Authentik — self-hosted identity/OIDC provider.
|
||||
# Authentik — self-hosted identity/OIDC provider. Replaced Zitadel because
|
||||
# nixpkgs is stuck on 2.71 (no login-v2 split) with a forward-only db
|
||||
# migration; authentik-nix tracks upstream closely instead.
|
||||
#
|
||||
# Replaced Zitadel: nixpkgs only carries Zitadel 2.71 (no login-v2 split, and
|
||||
# a v3/v4 database migrates forward only, so an existing instance can't be
|
||||
# moved onto it). authentik-nix tracks upstream closely instead.
|
||||
# The upstream module owns postgres and its unit ordering, and needs no redis
|
||||
# (channels/cache run on postgres). TLS terminates at Caddy; every listener
|
||||
# below is pinned to loopback since only tailscale0 is trusted.
|
||||
#
|
||||
# The upstream module owns postgres (createDatabase) AND orders the units
|
||||
# against postgresql.target, so no manual After= is needed here. No redis —
|
||||
# recent authentik runs channels/cache on postgres.
|
||||
#
|
||||
# TLS terminates at Caddy; every listener is pinned to loopback below so
|
||||
# nothing is reachable from the tailnet (hosts trust tailscale0).
|
||||
#
|
||||
# Needs, wired via sops in the host's secrets.nix: an environmentFile carrying
|
||||
# - AUTHENTIK_SECRET_KEY (`openssl rand -base64 60`) — signs sessions
|
||||
# - AUTHENTIK_BOOTSTRAP_PASSWORD first-run akadmin password
|
||||
# systemd reads EnvironmentFile as root before dropping to the service's
|
||||
# DynamicUser, so the sops default root:root 0400 is correct — do NOT set
|
||||
# `owner` on it the way the headplane secrets need.
|
||||
# Needs an environmentFile from sops (host's secrets.nix) carrying
|
||||
# AUTHENTIK_SECRET_KEY and AUTHENTIK_BOOTSTRAP_PASSWORD. Keep it root:root
|
||||
# 0400 (systemd reads it as root before dropping to DynamicUser) — don't set
|
||||
# `owner` the way headplane's secrets need.
|
||||
{
|
||||
imports = [ inputs.authentik-nix.nixosModules.default ];
|
||||
|
||||
# Pinned explicitly: the default tracks system.stateVersion, so editing that
|
||||
# line would silently demand a pg_upgrade of the identity store. Bump this
|
||||
# deliberately, with a dump in hand.
|
||||
# would silently demand a pg_upgrade of the identity store.
|
||||
services.postgresql.package = pkgs.postgresql_17;
|
||||
|
||||
services.authentik = {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{ ... }:
|
||||
|
||||
# Audiobookshelf audiobook/podcast server.
|
||||
# Listens on all interfaces: :8000 stays closed on the LAN (no openFirewall),
|
||||
# but reachable over the trusted tailscale0 interface and via localhost (caddy).
|
||||
# Library/media paths are set in the web UI — point them at /mnt/data/...
|
||||
# Runs as user `audiobookshelf`; added to `users` so it can read group-owned
|
||||
# library dirs on the RAID.
|
||||
# Audiobookshelf audiobook/podcast server, listening on all interfaces but
|
||||
# reachable only via tailscale0 or local caddy (no openFirewall) — library
|
||||
# paths are set in the web UI, pointed at /mnt/data/... In the "users" group
|
||||
# so it can read the RAID's group-owned library dirs.
|
||||
{
|
||||
services.audiobookshelf = {
|
||||
enable = true;
|
||||
|
||||
+39
-64
@@ -1,30 +1,22 @@
|
||||
{ config, pkgs, inputs, ... }:
|
||||
|
||||
# Immich photo/video library. Native nixpkgs module (not the upstream compose
|
||||
# stack) — it owns its own postgres (with the pgvector + vectorchord extensions
|
||||
# it needs for search) and a unix-socket redis, so nothing else is required here.
|
||||
# Immich photo/video library. Native nixpkgs module, not the upstream compose
|
||||
# stack — it owns its own postgres (pgvector + vectorchord) and a unix-socket redis.
|
||||
#
|
||||
# Storage: everything lives under /mnt/data/AppData/immich, which is the media
|
||||
# store MIGRATED from the old ZimaOS/CasaOS install's UPLOAD_LOCATION
|
||||
# (/mnt/data/Immich/upload — same layout: library/ upload/ thumbs/
|
||||
# encoded-video/ profile/ backups/). See scripts/immich-import-legacy-db for the
|
||||
# matching database import. The postgres cluster itself stays on the OS disk.
|
||||
# Storage lives under /mnt/data/AppData/immich, migrated from the old ZimaOS/CasaOS
|
||||
# UPLOAD_LOCATION (same subfolder layout); see scripts/immich-import-legacy-db for
|
||||
# the matching DB import. The postgres cluster itself stays on the OS disk.
|
||||
#
|
||||
# ⚠️ The immich DB is the only copy of albums/faces/dates — the files alone
|
||||
# can't rebuild it. It joins the other unbacked databases on this network.
|
||||
let
|
||||
# The PACKAGE comes from nixpkgs-unstable (3.0.3); the MODULE comes from the
|
||||
# 26.05 pin (which ships 2.7.5). That combination is safe because the two
|
||||
# module files are byte-identical — verified by diffing them at the revisions
|
||||
# in flake.lock. RE-CHECK THAT DIFF on any input bump:
|
||||
# Package pinned to nixpkgs-unstable (3.0.3) while the module stays on the 26.05
|
||||
# pin (2.7.5) — safe only because the two module files are byte-identical
|
||||
# (verified by diff; re-check on any input bump). Needed because immich's
|
||||
# migrations are forward-only and jupiter's imported DB was last written by
|
||||
# 3.0.0, which 2.7.5 refuses to start against; drop once the pin ships >= 3.0.0.
|
||||
# diff <(nixpkgs)/nixos/modules/services/web-apps/immich.nix \
|
||||
# <(unstable)/nixos/modules/services/web-apps/immich.nix
|
||||
#
|
||||
# Why: jupiter's imported database was last written by immich 3.0.0, and
|
||||
# immich runs its migrations forward only — 2.7.5 refuses to start against it
|
||||
# with "corrupted migrations: previously executed migration
|
||||
# 1776217577402-DropAuditTable is missing". Drop this override once nixos-26.11
|
||||
# (or whatever the pin becomes) ships >= 3.0.0.
|
||||
unstable = import inputs.nixpkgs-unstable {
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
};
|
||||
@@ -42,27 +34,20 @@ in
|
||||
mediaLocation = "/mnt/data/AppData/immich";
|
||||
machine-learning.enable = true;
|
||||
|
||||
# ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE, and
|
||||
# that is ALL-OR-NOTHING (dist/utils/config.js: the config is
|
||||
# `configFile ? loadFromFile(...) : metadataRepo.get(SystemConfig)` — the
|
||||
# database copy is IGNORED, not merged). Two consequences:
|
||||
# 1. Anything not declared here falls back to immich's DEFAULTS, not to
|
||||
# whatever the admin UI had. The old settings stay in the
|
||||
# system_metadata table, so deleting this block restores them.
|
||||
# 2. The admin settings UI goes read-only — saving throws "Cannot update
|
||||
# configuration while IMMICH_CONFIG_FILE is in use". Change settings
|
||||
# HERE and redeploy.
|
||||
# An unknown/misspelled key is a HARD startup failure under a config file
|
||||
# (the same code path only logs a warning without one), so keys below are
|
||||
# taken verbatim from `defaults` in immich's dist/config.js.
|
||||
# ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE mode,
|
||||
# which is all-or-nothing: undeclared keys fall back to immich's defaults, not
|
||||
# the admin UI's saved values (which stay in system_metadata and return if
|
||||
# this block is deleted), and the admin settings UI goes read-only. An
|
||||
# unknown/misspelled key is a hard startup failure here (just a warning
|
||||
# without a config file), so keys are copied verbatim from `defaults` in
|
||||
# immich's dist/config.js.
|
||||
settings = {
|
||||
server.externalDomain = "https://immich.mgaction.town";
|
||||
newVersionCheck.enabled = false; # nixpkgs pins the version, not immich
|
||||
|
||||
# OIDC via Authentik on neptun. The Authentik application/provider is
|
||||
# created BY HAND in its UI — same as headscale's and headplane's, which
|
||||
# are also separate apps (hosts/neptun/secrets.nix). Only the client
|
||||
# secret is managed here.
|
||||
# OIDC via Authentik on neptun; the application/provider is created by hand
|
||||
# in its UI (like headscale's and headplane's, separate apps) — only the
|
||||
# client secret is managed here (hosts/neptun/secrets.nix).
|
||||
oauth = {
|
||||
enabled = true;
|
||||
# Authentik's per-application issuer. Trailing slash matters: immich
|
||||
@@ -76,24 +61,18 @@ in
|
||||
clientSecret._secret = config.sops.secrets.immich_oauth_client_secret.path;
|
||||
scope = "openid email profile";
|
||||
buttonText = "Login with Authentik";
|
||||
# Existing accounts (the 2 imported users) keep working: matching is by
|
||||
# email, so an Authentik user with the same address adopts that account
|
||||
# rather than creating a second one.
|
||||
# Matches by email, so the 2 imported users adopt their Authentik account
|
||||
# instead of getting a duplicate.
|
||||
autoRegister = true;
|
||||
# Leave the password form reachable — autoLaunch would bounce straight
|
||||
# to Authentik, locking everyone out if the OIDC app is misconfigured.
|
||||
autoLaunch = false;
|
||||
# Land back on immich's own login page after logout. Without this,
|
||||
# immich falls back to the IdP's discovered end_session_endpoint
|
||||
# (auth.service.js:320-326) and logout dumps you on Authentik's
|
||||
# "you've been logged out" page instead. Must be an ABSOLUTE url —
|
||||
# the config schema rejects a relative path — and mirrors immich's
|
||||
# internal LOGIN_URL, including autoLaunch=0.
|
||||
#
|
||||
# Note this ends the IMMICH session only; the Authentik SSO session
|
||||
# survives, so the next "Login with Authentik" click signs straight
|
||||
# back in without a credential prompt. To end both, drop this line and
|
||||
# let the IdP endpoint take over again.
|
||||
# Without this, immich falls back to the IdP's discovered
|
||||
# end_session_endpoint and logout dumps you on Authentik's own page
|
||||
# instead of back here — must be an absolute url, mirroring immich's
|
||||
# internal LOGIN_URL. This ends the immich session only; the Authentik
|
||||
# SSO session survives, so the next login skips the credential prompt —
|
||||
# drop this line to end both.
|
||||
endSessionEndpoint = "https://auth.mgaction.town/application/o/immich/end-session?post_logout_redirect_url=https://immich.mgaction.town";
|
||||
# The mobile app can't follow a browser redirect back to a custom
|
||||
# scheme through Authentik, so immich bounces it via this endpoint.
|
||||
@@ -101,28 +80,24 @@ in
|
||||
mobileRedirectUri = "https://immich.mgaction.town/api/oauth/mobile-redirect";
|
||||
};
|
||||
};
|
||||
# Hardware transcoding would need the iGPU passed in explicitly, e.g.
|
||||
# accelerationDevices = [ "/dev/dri/renderD128" ]; the default [ ] means
|
||||
# PrivateDevices=yes and CPU-only transcode. The ZimaBlade's Celeron does
|
||||
# this slowly but it only runs on upload.
|
||||
# Hardware transcoding needs accelerationDevices set explicitly (e.g.
|
||||
# "/dev/dri/renderD128"); default CPU-only transcode is slow on the
|
||||
# ZimaBlade's Celeron but only runs on upload.
|
||||
};
|
||||
|
||||
# /mnt/data/AppData is drwx--x--- darman:users — immich needs group "users"
|
||||
# just to TRAVERSE into its own media dir. The dir itself stays 0700
|
||||
# immich:immich (the module's tmpfiles rule re-asserts that every rebuild,
|
||||
# and UMask=0077 keeps new files private), so this grants nothing else.
|
||||
# /mnt/data/AppData is drwx--x--- darman:users; immich only needs group "users"
|
||||
# to traverse into it — the dir itself stays 0700 immich:immich (tmpfiles +
|
||||
# UMask=0077 reassert that), so this grants nothing else.
|
||||
users.users.immich.extraGroups = [ "users" ];
|
||||
|
||||
# mediaLocation is outside /var/lib, so the module won't create it — its own
|
||||
# tmpfiles entry only ADJUSTS an existing dir. Harmless no-op after the
|
||||
# legacy import, which puts the real store here.
|
||||
# mediaLocation is outside /var/lib, so the module won't create it — this rule
|
||||
# only adjusts perms on the dir the legacy import already created.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /mnt/data/AppData/immich 0700 immich immich -"
|
||||
];
|
||||
|
||||
# The unit's automatic RequiresMountsFor covers /run/immich and /var/lib/immich
|
||||
# only — nothing points it at mediaLocation. Without this immich starts with
|
||||
# the array missing and writes uploaded photos onto the 29G eMMC, into a
|
||||
# directory that becomes invisible the moment /mnt/data mounts over it.
|
||||
# The unit's automatic RequiresMountsFor doesn't cover mediaLocation — without
|
||||
# this, immich starts before /mnt/data mounts and writes uploads onto the 29G
|
||||
# eMMC, invisibly, under the future mountpoint.
|
||||
systemd.services.immich-server.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
}
|
||||
|
||||
+10
-13
@@ -6,20 +6,17 @@
|
||||
dataDir = "/mnt/data/AppData/jellyfin";
|
||||
cacheDir = "/mnt/data/AppData/jellyfin/cache";
|
||||
};
|
||||
# "users" so the shared library stays readable (see the UMask note below);
|
||||
# "video"/"render" for the DRI nodes used by hardware transcoding. renderD128
|
||||
# happens to be 0666 so VAAPI alone would work without this, but card1 is
|
||||
# 0660 root:video — and neither mode is guaranteed, so don't rely on it. The
|
||||
# groups are harmless on a host with no GPU: they exist regardless, and this
|
||||
# module stays host-agnostic (the DRIVER is enabled per-host, e.g. jupiter's
|
||||
# hardware.graphics + intel-media-driver).
|
||||
# "users" keeps the shared library readable (see the UMask note below);
|
||||
# "video"/"render" cover the DRI nodes for hardware transcoding — card1 is
|
||||
# 0660 root:video (not guaranteed 0666 like renderD128), so don't rely on
|
||||
# device perms alone. Harmless on a GPU-less host: the driver itself is
|
||||
# enabled per-host (e.g. jupiter's hardware.graphics + intel-media-driver).
|
||||
users.users.jellyfin.extraGroups = [ "users" "video" "render" ];
|
||||
|
||||
# The upstream module hardcodes UMask=0077 — root cause of jellyfin writing
|
||||
# trickplay thumbnails into stray new show folders it invented itself,
|
||||
# owned jellyfin:jellyfin 700, invisible to every other service sharing
|
||||
# the library (cinephage, mediamanager, ...). New files/dirs it creates
|
||||
# from here on inherit group "users" (library roots are setgid, see the
|
||||
# one-time chmod g+s done by hand) and stay group-writable.
|
||||
# The upstream module hardcodes UMask=0077, which made jellyfin write
|
||||
# trickplay thumbnails into new folders owned jellyfin:jellyfin 700 —
|
||||
# invisible to every other service sharing the library (cinephage,
|
||||
# mediamanager). Forcing 0002 makes new files inherit group "users"
|
||||
# (library roots are setgid via a one-time chmod g+s) and stay group-writable.
|
||||
systemd.services.jellyfin.serviceConfig.UMask = lib.mkForce "0002";
|
||||
}
|
||||
|
||||
@@ -15,21 +15,16 @@
|
||||
{
|
||||
services.prowlarr.enable = true;
|
||||
|
||||
# `nofail` is NOT optional here: without it this bind is RequiredBy
|
||||
# local-fs.target, so an unassembled RAID array fails that target and drops
|
||||
# jupiter into emergency mode — which is a dead end, since root is locked and
|
||||
# sulogin has nothing to offer on a headless box. It defeats the `nofail` on
|
||||
# /mnt/data itself (a mount layered on the array is what actually took the
|
||||
# target down). Let this bind fail alone instead.
|
||||
# `nofail` is not optional: without it this bind is RequiredBy local-fs.target,
|
||||
# so an unassembled array drops jupiter into emergency mode — a dead end on a
|
||||
# headless box with root locked. Let this bind fail alone instead.
|
||||
fileSystems."/var/lib/private/prowlarr" = {
|
||||
device = "/mnt/data/AppData/prowlarr/config";
|
||||
fsType = "none";
|
||||
options = [ "bind" "nofail" ];
|
||||
};
|
||||
|
||||
# systemd derives RequiresMountsFor from the unit's own paths, which here is
|
||||
# only /var/lib/prowlarr on the eMMC — so without this prowlarr starts happily
|
||||
# with the array absent and writes its state onto the 29G OS disk. Pin it to
|
||||
# the array so it fails loudly instead.
|
||||
# systemd derives RequiresMountsFor only from /var/lib/prowlarr (eMMC) — pin
|
||||
# it to the array too, or prowlarr starts happily and writes state to the OS disk.
|
||||
systemd.services.prowlarr.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{ ... }:
|
||||
|
||||
# Radarr — movie library manager, feeds off SABnzbd/Prowlarr. dataDir points
|
||||
# at the config migrated from the old ZimaOS docker stack (indexers/download
|
||||
# client/history already set up). Unlike prowlarr, this module uses a static
|
||||
# `radarr` user (no DynamicUser) and only auto-chowns dataDir when it's the
|
||||
# module's own default path — since we point at a pre-existing migrated dir,
|
||||
# chown it by hand once after first deploy:
|
||||
# Radarr — movie library manager, feeds off SABnzbd/Prowlarr; dataDir points
|
||||
# at config migrated from the old ZimaOS docker stack. Unlike prowlarr, this
|
||||
# module uses a static `radarr` user (no DynamicUser) and only auto-chowns
|
||||
# dataDir at its own default path, so the migrated dir needs a manual
|
||||
# one-time chown after first deploy:
|
||||
# chown -R radarr:radarr /mnt/data/AppData/radarr/config
|
||||
{
|
||||
services.radarr = {
|
||||
|
||||
+13
-20
@@ -1,18 +1,13 @@
|
||||
{ config, ... }:
|
||||
|
||||
# SABnzbd — usenet downloader. Migrated off a reused hand-authored ini
|
||||
# (servers/API key/history originally imported from the old ZimaOS docker
|
||||
# stack) onto NixOS-managed `settings`, per the module's own deprecation
|
||||
# notice for `configFile`. Only the values that differ from SABnzbd's own
|
||||
# built-in defaults are declared here — everything else falls back to the
|
||||
# same defaults SABnzbd was already using.
|
||||
# SABnzbd — usenet downloader, migrated off a hand-authored ini (imported from
|
||||
# the old ZimaOS docker stack) onto NixOS-managed `settings`. Only values that
|
||||
# differ from SABnzbd's own defaults are declared here.
|
||||
#
|
||||
# `admin_dir`/`log_dir` MUST stay absolute: the module writes the merged ini
|
||||
# to /var/lib/sabnzbd/sabnzbd.ini (eMMC), and both dirs are otherwise
|
||||
# relative to wherever the ini lives. Pointing them back at the ORIGINAL
|
||||
# /mnt/data location keeps the existing download queue/history database
|
||||
# (admin_dir) intact — a relative default here would silently "reset"
|
||||
# SABnzbd to an empty queue on first switch, even though nothing was deleted.
|
||||
# `admin_dir`/`log_dir` must stay absolute: the module writes the merged ini to
|
||||
# /var/lib/sabnzbd/sabnzbd.ini (eMMC), so a relative default would resolve
|
||||
# there instead of the original /mnt/data location — silently "resetting"
|
||||
# SABnzbd to an empty queue/history on first switch, without deleting anything.
|
||||
{
|
||||
services.sabnzbd = {
|
||||
enable = true;
|
||||
@@ -73,17 +68,15 @@
|
||||
# Write access to the shared downloads dir (owned darman:users on disk).
|
||||
users.users.sabnzbd.extraGroups = [ "users" ];
|
||||
|
||||
# download/complete/admin dirs all live on the array, but systemd only
|
||||
# derives RequiresMountsFor from /var/lib/sabnzbd (eMMC) — so with the array
|
||||
# absent sabnzbd would start and download onto the 29G OS disk.
|
||||
# download/complete/admin dirs live on the array, but systemd only derives
|
||||
# RequiresMountsFor from /var/lib/sabnzbd (eMMC) — without this, a missing
|
||||
# array lets sabnzbd start and download onto the 29G OS disk instead.
|
||||
systemd.services.sabnzbd.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
systemd.services.fix-downloads-perms.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
|
||||
# SABnzbd hardcodes completed job folders to 0700 on every job, ignoring
|
||||
# the ini's `umask` (that only covers files during unpack, not the job
|
||||
# dir itself). setgid on Downloads keeps the group as "users" but perm
|
||||
# bits still come back zeroed, locking out cinephage/mediamanager — sweep
|
||||
# it clean instead of fighting SABnzbd.
|
||||
# SABnzbd hardcodes completed job folders to 0700, ignoring the ini's `umask`
|
||||
# (unpack-only) — setgid keeps the group but perm bits still zero out and
|
||||
# lock out cinephage/mediamanager, so sweep it clean on a timer instead.
|
||||
systemd.services.fix-downloads-perms = {
|
||||
description = "Fix group perms SABnzbd resets on completed downloads";
|
||||
serviceConfig.Type = "oneshot";
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
{ ... }:
|
||||
|
||||
# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talks to
|
||||
# Sonarr/Radarr to fulfill requests. Fresh install, no migrated data.
|
||||
#
|
||||
# configDir stays at the module default; bind-mount AppData onto it instead
|
||||
# of overriding configDir, so data lives on the RAID array and survives an
|
||||
# OS-disk reinstall (same DynamicUser/StateDirectory issue as prowlarr.nix —
|
||||
# see that file for why, and why the mount targets /var/lib/private/seerr
|
||||
# rather than the public path).
|
||||
# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talking to
|
||||
# Sonarr/Radarr; fresh install, no migrated data. configDir stays at the
|
||||
# module default, with AppData bind-mounted onto it instead (same
|
||||
# DynamicUser/StateDirectory issue as prowlarr.nix — see that file for why,
|
||||
# and why the mount targets /var/lib/private/seerr rather than the public path).
|
||||
{
|
||||
services.seerr.enable = true;
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{ ... }:
|
||||
|
||||
# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr. dataDir points at
|
||||
# the config migrated from the old ZimaOS docker stack (indexers/download
|
||||
# client/history already set up). Unlike prowlarr, this module uses a static
|
||||
# `sonarr` user (no DynamicUser) and only auto-chowns dataDir when it's the
|
||||
# module's own default path — since we point at a pre-existing migrated dir,
|
||||
# chown it by hand once after first deploy:
|
||||
# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr; dataDir points at
|
||||
# config migrated from the old ZimaOS docker stack. Unlike prowlarr, this
|
||||
# module uses a static `sonarr` user (no DynamicUser) and only auto-chowns
|
||||
# dataDir at its own default path, so the migrated dir needs a manual
|
||||
# one-time chown after first deploy:
|
||||
# chown -R sonarr:sonarr /mnt/data/AppData/sonarr/config
|
||||
{
|
||||
services.sonarr = {
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
|
||||
prometheusConfig = {
|
||||
global.scrape_interval = "5s";
|
||||
# Explicit, and equal to the interval on purpose. The Prometheus default
|
||||
# is 10s, and VictoriaMetrics silently clamps scrape_timeout down to
|
||||
# scrape_interval rather than erroring — so leaving it implicit means the
|
||||
# config says 10s while the scraper uses 5s. Say what actually happens.
|
||||
# Explicit and equal to the interval on purpose: VictoriaMetrics silently
|
||||
# clamps scrape_timeout down to scrape_interval, so leaving the Prometheus
|
||||
# default (10s) here would misstate what actually happens.
|
||||
global.scrape_timeout = "5s";
|
||||
|
||||
scrape_configs = [
|
||||
@@ -44,14 +43,10 @@
|
||||
];
|
||||
}
|
||||
# mercury is a Pi scraped over the tailnet, so it gets its own job at a
|
||||
# slower cadence: at the 5s global it would time out (see above) and
|
||||
# the series would show gaps rather than late samples.
|
||||
#
|
||||
# A separate cadence REQUIRES a separate job — scrape_interval is a
|
||||
# per-job setting and job_name has to be unique — which means mercury's
|
||||
# `job` label differs from every other host's. Select on `host` (set on
|
||||
# every target below) rather than job="node-exporter" in dashboards and
|
||||
# alerts, or mercury drops out of them silently.
|
||||
# slower cadence to avoid timing out at the 5s global. A separate cadence
|
||||
# requires a separate job (scrape_interval is per-job), so mercury's
|
||||
# `job` label differs from every other host's — select on `host` in
|
||||
# dashboards/alerts, not job="node-exporter", or mercury drops out silently.
|
||||
{
|
||||
job_name = "node-exporter-mercury";
|
||||
scrape_interval = "15s";
|
||||
@@ -81,31 +76,22 @@
|
||||
# another host or the tailnet is temporarily unavailable.
|
||||
systemd.services.victoriametrics.after = [ "tailscaled-autoconnect.service" ];
|
||||
|
||||
# Keep the TSDB off jupiter's 29G eMMC. The module hardcodes
|
||||
# -storageDataPath=/var/lib/<stateDir> and runs DynamicUser, so without this
|
||||
# the data lands on the OS disk — a continuous small-write workload aimed at
|
||||
# the one disk here with no headroom and finite write endurance. Same
|
||||
# bind-onto-/var/lib/private pattern as prowlarr.nix and seerr.nix; see
|
||||
# prowlarr.nix for why the mount targets the private path and not the public
|
||||
# /var/lib/victoriametrics.
|
||||
#
|
||||
# `nofail` is NOT optional — again see prowlarr.nix: without it this bind is
|
||||
# RequiredBy local-fs.target, so an unassembled array drops jupiter into an
|
||||
# emergency shell that a headless box cannot be rescued from.
|
||||
# Keep the TSDB off jupiter's 29G eMMC: the module hardcodes
|
||||
# -storageDataPath=/var/lib/<stateDir> under DynamicUser, so without this bind
|
||||
# a continuous small-write workload lands on the one disk with no headroom.
|
||||
# Same /var/lib/private bind pattern as prowlarr.nix and seerr.nix — see
|
||||
# prowlarr.nix for why it targets the private path, and why `nofail` here is
|
||||
# not optional.
|
||||
fileSystems."/var/lib/private/victoriametrics" = {
|
||||
device = "/mnt/data/AppData/victoriametrics";
|
||||
fsType = "none";
|
||||
options = [ "bind" "nofail" ];
|
||||
};
|
||||
|
||||
# The bind above needs its SOURCE to exist or the mount fails — and because
|
||||
# it is `nofail` that failure is quiet: RequiresMountsFor below is satisfied
|
||||
# by /mnt/data itself, so VictoriaMetrics would start regardless and write to
|
||||
# the eMMC, which is the exact thing the bind exists to prevent. prowlarr.nix
|
||||
# gets away without this only because its directory predates the module
|
||||
# (migrated from ZimaOS). This is a fresh service, so it creates its own,
|
||||
# same as seerr.nix. 0755 darman:users matches the other AppData dirs, which
|
||||
# matters because /mnt/data/AppData itself is drwx--x--- darman:users.
|
||||
# The bind above needs its source dir to exist or it quietly fails (`nofail`)
|
||||
# and VictoriaMetrics falls through to writing the eMMC anyway — this is a
|
||||
# fresh service so, unlike prowlarr.nix's pre-existing dir, it must create its
|
||||
# own (same as seerr.nix). 0755 darman:users matches the other AppData dirs.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /mnt/data/AppData/victoriametrics 0755 darman users -"
|
||||
];
|
||||
|
||||
@@ -53,17 +53,11 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Bind-mount source must exist (podman won't create it), and it must be
|
||||
# owned by 1000 — the `pihole` user FTL drops to after the entrypoint's root
|
||||
# phase. Podman here is rootful with no userns remapping, so that number is
|
||||
# the same inside and out (on the host it collides with darman, harmlessly).
|
||||
#
|
||||
# Ownership of gravity.db alone is not enough: sqlite creates a sibling
|
||||
# gravity.db-journal for every write transaction, so FTL needs to CREATE
|
||||
# files in this directory. Root-owned, it fails with
|
||||
# open(/etc/pihole/gravity.db-journal) - (14)
|
||||
# attempt to write a readonly database
|
||||
# which reads like a corrupt or read-only database and is neither.
|
||||
# Bind-mount source must exist (podman won't create it) and be owned by 1000,
|
||||
# the `pihole` user FTL drops to (rootful podman, no userns remapping, so the
|
||||
# uid is the same inside and out). Must be the whole DIRECTORY, not just
|
||||
# gravity.db — sqlite needs to create a sibling gravity.db-journal per write,
|
||||
# and a root-owned dir makes that fail with a misleading "readonly database".
|
||||
systemd.tmpfiles.rules = [ "d /var/lib/pihole 0750 1000 1000 -" ];
|
||||
|
||||
# Seed the adlists above into gravity. `INSERT OR IGNORE` keyed on the URL
|
||||
|
||||
@@ -22,14 +22,13 @@
|
||||
};
|
||||
};
|
||||
|
||||
# Samba keeps its own NTLM password DB, separate from the system password;
|
||||
# `services.samba` never sets it, so logins fail until provisioned. Runs
|
||||
# AFTER samba-smbd so its state dir exists — an activation script runs too
|
||||
# early and smbpasswd fails to init the passdb. Reads a single-line
|
||||
# password from the first file that exists:
|
||||
# Samba keeps its own NTLM password DB, separate from the system password —
|
||||
# `services.samba` never sets it, and this runs as a service (not an
|
||||
# activation script, which fires too early for smbpasswd's passdb) after
|
||||
# samba-smbd. Reads a single-line password from the first existing file,
|
||||
# feeding it twice since smbpasswd prompts new+confirm:
|
||||
# Real host: /run/secrets/samba_password (sops-nix, see secrets.nix)
|
||||
# VM test: /etc/samba/smb-password (plaintext, see vm.nix)
|
||||
# smbpasswd prompts new + confirm, so the value is fed twice.
|
||||
systemd.services.samba-smbpasswd = {
|
||||
description = "Provision Samba password for darman";
|
||||
after = [ "samba-smbd.service" ];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{ ... }:
|
||||
|
||||
# Local recursive DNS resolver (privacy + DNSSEC). Your adblock DNS
|
||||
# (pihole/AdGuard) forwards to this instead of a public upstream.
|
||||
# Listens on 127.0.0.1:5335 — point the adblock engine's upstream there:
|
||||
# Local recursive DNS resolver (privacy + DNSSEC) that the adblock DNS
|
||||
# (pihole/AdGuard) forwards to instead of a public upstream — listens on
|
||||
# 127.0.0.1:5335, so point the adblock engine's upstream there:
|
||||
# AdGuard: dns.upstream_dns = [ "127.0.0.1:5335" ];
|
||||
# pihole: upstream = "127.0.0.1#5335";
|
||||
{
|
||||
|
||||
+12
-20
@@ -1,25 +1,20 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Headplane — web UI for headscale (services/vpn/headscale.nix; must be enabled
|
||||
# first), running as headscale's own OS user.
|
||||
# Headplane — web UI for headscale (services/vpn/headscale.nix; enable first),
|
||||
# running as headscale's OS user.
|
||||
#
|
||||
# It reads headscale's config from the nix store, which is read-only — so the
|
||||
# UI DISPLAYS the settings but can't change them. That's the intended shape
|
||||
# for a declaratively-configured box (config_strict already defaults off
|
||||
# upstream for exactly this reason); edit them here and rebuild instead.
|
||||
# DNS extra-records are the one thing worth making editable, since they're
|
||||
# data rather than config — hence the writable extra_records file below,
|
||||
# which also spares headplane from restarting headscale on every change.
|
||||
# It reads headscale's config from the nix store, so the UI DISPLAYS settings
|
||||
# but can't change them (edit here and rebuild instead) — except DNS
|
||||
# extra-records, which are data rather than config, hence the writable
|
||||
# extra_records file below.
|
||||
#
|
||||
# Served at vpn.mgaction.town/admin (path-routed alongside headscale itself,
|
||||
# see hosts/neptun/configuration.nix). base_url is the site root WITHOUT the
|
||||
# /admin prefix — Headplane appends that itself, including for the OIDC
|
||||
# callback.
|
||||
# Served at vpn.mgaction.town/admin (path-routed with headscale, see
|
||||
# hosts/neptun/configuration.nix); base_url excludes the /admin prefix, which
|
||||
# Headplane appends itself including for the OIDC callback.
|
||||
#
|
||||
# Auth is Authentik (services/identity/authentik.nix) via OIDC. client_id,
|
||||
# client_secret, and the headscale API key can't be known until
|
||||
# Authentik/headscale are actually deployed, so they're placeholders below;
|
||||
# direct API-key login still works as a fallback until then. Once live:
|
||||
# Auth is Authentik via OIDC; client_id/client_secret/API key are placeholders
|
||||
# until Authentik/headscale are deployed (direct API-key login works as a
|
||||
# fallback until then). Once live:
|
||||
# 1. In Authentik: create an OAuth2/OpenID Provider + Application with slug
|
||||
# `headplane` and redirect URI
|
||||
# https://vpn.mgaction.town/admin/oidc/callback. Copy the generated
|
||||
@@ -28,9 +23,6 @@
|
||||
# headplane_oidc_client_secret with the provider's client secret.
|
||||
# 3. `headscale apikeys create` on the box, and replace
|
||||
# headplane_headscale_api_key the same way.
|
||||
#
|
||||
# NOTE: Authentik issues per-application, so the issuer carries the app slug —
|
||||
# it is NOT the bare host the way Zitadel's was.
|
||||
{
|
||||
# Writable DNS extra-records, shared by both services (they run as the same
|
||||
# user). tmpfiles seeds an empty JSON array — headscale won't start against
|
||||
|
||||
+40
-78
@@ -1,14 +1,10 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Headscale — self-hosted control server for the tailnet. Every host's
|
||||
# services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town
|
||||
# (this host). MagicDNS base_domain "orbit.sol" matches the
|
||||
# "jupiter.orbit.sol" names used in this repo's Caddy vhosts
|
||||
# (hosts/neptun/configuration.nix) — changing base_domain means changing
|
||||
# those too, and re-pointing neptun's dnsmasq stub at the new suffix.
|
||||
#
|
||||
# TLS terminates at Caddy (see the host's configuration.nix); headscale
|
||||
# itself only listens on localhost.
|
||||
# Headscale — self-hosted control server for the tailnet; every host's
|
||||
# services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town.
|
||||
# TLS terminates at Caddy; headscale itself only listens on localhost. Changing
|
||||
# base_domain below also means updating this repo's Caddy vhosts and neptun's
|
||||
# dnsmasq stub, which assume "orbit.sol".
|
||||
{
|
||||
services.headscale = {
|
||||
enable = true;
|
||||
@@ -18,89 +14,55 @@
|
||||
server_url = "https://vpn.mgaction.town";
|
||||
|
||||
dns = {
|
||||
# Deliberately OUTSIDE mgaction.town. That zone has a wildcard A+AAAA
|
||||
# pointing at neptun, and DNS wildcards match multi-label names — so
|
||||
# with base_domain = hosts.mgaction.town, `jupiter.hosts.mgaction.town`
|
||||
# resolved publicly to NEPTUN and Caddy proxied to itself: a silent
|
||||
# loop rather than a lookup failure.
|
||||
#
|
||||
# `.sol` is the LAN domain pihole serves, so this nests the tailnet
|
||||
# inside it: planets sit on the LAN as jupiter.sol, and reach each
|
||||
# other in orbit as jupiter.orbit.sol. Resolution is unambiguous
|
||||
# because tailscale matches routes by LONGEST suffix, so orbit.sol
|
||||
# goes to MagicDNS even when everything else funnels to pihole.
|
||||
#
|
||||
# Never give a LAN host the name `orbit`: pihole's
|
||||
# `address=/<host>.sol/<ip>` lines match a name AND everything under
|
||||
# it, so an `orbit` host would swallow this entire zone.
|
||||
# Deliberately outside mgaction.town: that zone has a wildcard A+AAAA at
|
||||
# neptun, so a name under it would resolve publicly to neptun and Caddy
|
||||
# would proxy to itself. Nested under `.sol` (pihole's LAN domain) so
|
||||
# jupiter.sol (LAN) and jupiter.orbit.sol (tailnet) resolve unambiguously
|
||||
# — tailscale matches by longest suffix. Never name a LAN host `orbit`:
|
||||
# pihole's `address=/<host>.sol/<ip>` would swallow this whole zone.
|
||||
base_domain = "orbit.sol";
|
||||
# pihole on mercury, over the tailnet — so every roaming device gets
|
||||
# ad blocking and .sol names wherever it is, not just on the LAN.
|
||||
# Deliberately NO public fallback: tailscale treats the list as a set,
|
||||
# so adding 9.9.9.9 here would let queries slip past the filter
|
||||
# whenever mercury is briefly slow. Strict blocking, at the cost of
|
||||
# mercury being a single point of failure for tailnet DNS.
|
||||
#
|
||||
# ⚠️ A hardcoded tailnet address, so it changes if mercury re-enrols
|
||||
# — check `headscale nodes list` if DNS dies tailnet-wide.
|
||||
nameservers.global = [ "100.64.0.7" ];
|
||||
# pihole on mercury, over the tailnet, so roaming devices get ad blocking
|
||||
# and .sol names everywhere. Deliberately no public fallback — tailscale
|
||||
# treats this as a set, so adding one would let queries slip past the
|
||||
# filter whenever mercury is briefly slow, at the cost of mercury being a
|
||||
# single point of failure for tailnet DNS.
|
||||
# ⚠️ Hardcoded tailnet address — check `headscale nodes list` if it
|
||||
# changes (mercury re-enrolled) and DNS dies tailnet-wide.
|
||||
nameservers.global = [ "100.64.0.4" ];
|
||||
|
||||
# Must be set, and must be HERE rather than via the module's
|
||||
# `dns.split` option. nixpkgs renders that option one level too high
|
||||
# (a sibling of `nameservers:`), but headscale reads
|
||||
# dns.nameservers.split (hscontrol/types/config.go:722) and so does
|
||||
# headplane. So the module's option is dead, and the missing key makes
|
||||
# headplane's DNS page die with
|
||||
# TypeError: Cannot convert undefined or null to object
|
||||
# from Object.keys(config.dns.nameservers.split).
|
||||
# Must be set here, not via the module's `dns.split` option — nixpkgs
|
||||
# renders that one level too high, but headscale (and headplane) read
|
||||
# dns.nameservers.split; the missing key crashes headplane's DNS page.
|
||||
nameservers.split = { };
|
||||
|
||||
# Point every node's resolver at MagicDNS, which forwards on to the
|
||||
# global nameserver above. That is the only way to get pihole onto a
|
||||
# roaming device: with this false, globalResolvers land in the
|
||||
# netmap's FallbackResolvers (hscontrol/types/config.go:826-830) and a
|
||||
# phone with carrier DNS never consults them.
|
||||
#
|
||||
# The cost is that every node's DNS now depends on mercury and on the
|
||||
# home connection, so mercury going down costs name resolution
|
||||
# everywhere, not just `.sol`. neptun and mercury opt out of this
|
||||
# individually with --accept-dns=false — see their configuration.nix.
|
||||
# Routes every node's resolver through MagicDNS to the global nameserver
|
||||
# above — the only way pihole reaches a roaming device (otherwise it
|
||||
# lands in netmap's FallbackResolvers and carrier DNS never consults it).
|
||||
# Cost: all DNS now depends on mercury and the home connection; neptun
|
||||
# and mercury opt out individually with --accept-dns=false.
|
||||
override_local_dns = true;
|
||||
};
|
||||
|
||||
# Authentik as the login provider, so `tailscale up --login-server ...`
|
||||
# sends you to a browser instead of needing a pre-auth key. This is a
|
||||
# SEPARATE Authentik application from headplane's — its own provider,
|
||||
# slug `headscale`, redirect https://vpn.mgaction.town/oidc/callback
|
||||
# (headscale's own callback; headplane's is under /admin).
|
||||
#
|
||||
# ⚠️ headscale performs OIDC discovery at STARTUP and a failure is
|
||||
# FATAL ("creating OIDC provider from issuer config: 404 Not Found") —
|
||||
# it will not boot, taking the whole tailnet's control plane with it.
|
||||
# Never point `issuer` at an application that doesn't exist yet; verify
|
||||
# with:
|
||||
# Authentik as the login provider (own application, slug `headscale`,
|
||||
# separate from headplane's) so `tailscale up --login-server ...` opens a
|
||||
# browser instead of needing a pre-auth key; headless hosts still use those.
|
||||
# ⚠️ headscale does OIDC discovery at startup and a failure is fatal — it
|
||||
# won't boot, taking the whole control plane with it. Never point `issuer`
|
||||
# at an application that doesn't exist yet; verify with
|
||||
# curl -s <issuer>.well-known/openid-configuration
|
||||
#
|
||||
# Headless hosts still enrol with pre-auth keys. Note also that users
|
||||
# created here are distinct from `headscale users create` ones: matching
|
||||
# is by the OIDC `sub` claim against the user's providerId, and 0.28
|
||||
# dropped map_legacy_users, so CLI-made users never gain one.
|
||||
# Users created here are matched by OIDC `sub`, so `headscale users
|
||||
# create`-made users never link to one (0.28 dropped map_legacy_users).
|
||||
oidc = {
|
||||
issuer = "https://auth.mgaction.town/application/o/headscale/";
|
||||
client_id = "14vhRYaLiONHmI2YFIxbQEveJDLu5cCvzSkTb9oq";
|
||||
client_secret_path = config.sops.secrets.headscale_oidc_client_secret.path;
|
||||
};
|
||||
|
||||
# Run our own DERP relay instead of pulling Tailscale's map.
|
||||
#
|
||||
# With the default (urls = [controlplane.tailscale.com/derpmap/default],
|
||||
# auto_update_enabled = true) headscale fetches that map at startup and
|
||||
# treats failure as FATAL — so a DNS blip or a Tailscale outage stops the
|
||||
# control server from booting at all. A self-hosted control plane that
|
||||
# can't start without Tailscale's infrastructure rather misses the point.
|
||||
#
|
||||
# The relay itself rides Caddy on :443 (hence the flush_interval -1 on
|
||||
# that vhost); only STUN needs its own UDP port.
|
||||
# Run our own DERP relay instead of pulling Tailscale's map: the default
|
||||
# fetches that map at startup and treats a failure as fatal, so a DNS blip
|
||||
# or Tailscale outage would stop this control server from booting at all.
|
||||
# The relay rides Caddy on :443 (hence flush_interval -1 on that vhost);
|
||||
# only STUN needs its own UDP port.
|
||||
derp = {
|
||||
urls = [ ];
|
||||
auto_update_enabled = false;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Tailscale node joined to the self-hosted headscale control server.
|
||||
# Auto-registers on boot from a sops pre-auth key. Requires the importing host
|
||||
# to declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix).
|
||||
# Not for the VM (no sops).
|
||||
# Tailscale node joined to the self-hosted headscale control server,
|
||||
# auto-registering on boot from a sops pre-auth key — importing hosts must
|
||||
# declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix).
|
||||
# Not used by the VM target (no sops there).
|
||||
{
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
@@ -14,11 +14,11 @@
|
||||
# Reach the host's services over the tailnet without opening LAN ports.
|
||||
networking.firewall.trustedInterfaces = [ "tailscale0" ];
|
||||
|
||||
# The upstream unit is a one-shot with no Restart, so a login attempt made
|
||||
# before the control server is reachable fails permanently until someone
|
||||
# starts it by hand. That's the norm on a first boot — neptun hosts headscale
|
||||
# itself, and the other hosts race it. 30s spacing also keeps restarts clear
|
||||
# of systemd's default start limit (5 within 10s).
|
||||
# The upstream unit is a one-shot with no Restart, so a login attempted
|
||||
# before the control server is up fails permanently until restarted by
|
||||
# hand — the norm on first boot, since neptun hosts headscale itself and
|
||||
# other hosts race it. 30s spacing keeps retries clear of systemd's default
|
||||
# start limit (5 within 10s).
|
||||
systemd.services.tailscaled-autoconnect.serviceConfig = {
|
||||
Restart = "on-failure";
|
||||
RestartSec = 30;
|
||||
|
||||
Reference in New Issue
Block a user