diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..f9d77ee --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +PATH_add scripts diff --git a/CLAUDE.md b/CLAUDE.md index 00d956d..e384ccc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 2429ef4..6f1b8f5 100644 --- a/README.md +++ b/README.md @@ -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//`). ## 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 ``` diff --git a/dotfiles/quickshell/CLAUDE.md b/dotfiles/quickshell/CLAUDE.md index 1668082..f25a650 100644 --- a/dotfiles/quickshell/CLAUDE.md +++ b/dotfiles/quickshell/CLAUDE.md @@ -99,16 +99,21 @@ on it, hence the index loops in `HyprChromeShell`. 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`); `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 - launchers. -- `widgets/launcher/` — shared `AppModel` search/execution plus eleven launcher - variants. `ApplicationLauncher` (variant 8, and the primary `SUPER` launcher) - keeps its visual core in the headlessly renderable - `ApplicationLauncherContent`; the rest are available on `SUPER CTRL 1–11` for - comparison. + 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. diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml new file mode 100644 index 0000000..27bd58f --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Askpass/AskpassPrompt.qml @@ -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 "" "" (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(); + } + } + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index 6f90fd5..3d6f7cf 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -6,6 +6,8 @@ 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. @@ -17,10 +19,11 @@ import qs.HyprChrome.Widgets.Polkit // * 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 an authorization prompt is up — it raises the same scrim the rail -// uses and freezes the density while it is open, so two surfaces read it. -// That is why the agent lives here rather than as a sibling of the -// launchers in shell.qml +// * 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 @@ -96,24 +99,30 @@ Scope { // 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 (polkit.prompting) + if (shell.modalOpen) return; shell.expanded = !shell.expanded; } - // Whether the scrim is up, from EITHER cause. This is the fact the surfaces - // actually share — the rail's density is only one of the two things that - // can raise it — so the backdrop and the layer pair below key off this - // rather than off `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 both. A prompt arriving over an already - // expanded rail therefore changes nothing about the scrim: it is already up, - // already full height, and the dialog simply appears above it. A prompt over - // a COLLAPSED rail expands that same scrim 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 || polkit.prompting + // 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 @@ -174,7 +183,7 @@ Scope { required property var modelData screen: modelData - active: polkit.prompting + active: shell.modalOpen wlrLayer: WlrLayer.Top barHeight: 0 } @@ -196,7 +205,7 @@ Scope { // 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 && !polkit.prompting + grabsKeyboard: shell.expanded && !shell.modalOpen onDismissed: shell.expanded = false } @@ -220,4 +229,23 @@ Scope { 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 + } } diff --git a/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncher.qml similarity index 51% rename from dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncher.qml index cc1a063..c27ad19 100644 --- a/dotfiles/quickshell/widgets/launcher/ApplicationLauncher.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncher.qml @@ -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() } } } diff --git a/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncherContent.qml similarity index 98% rename from dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml rename to dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncherContent.qml index cb11326..f9e9ecb 100644 --- a/dotfiles/quickshell/widgets/launcher/ApplicationLauncherContent.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppLauncherContent.qml @@ -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 diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml new file mode 100644 index 0000000..2acbdfc --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/AppModel.qml @@ -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; + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml new file mode 100644 index 0000000..8179daa --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Launcher/LauncherPanel.qml @@ -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 } + } + } +} diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 7272fad..99dfee1 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -20,7 +20,11 @@ Scope { // Dense multi-monitor status rail; visual core is headlessly renderable. 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,7 +32,6 @@ 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 diff --git a/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml b/dotfiles/quickshell/tests/AppLauncherHeadless.qml similarity index 96% rename from dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml rename to dotfiles/quickshell/tests/AppLauncherHeadless.qml index 71ca1d9..122ce33 100644 --- a/dotfiles/quickshell/tests/ApplicationLauncherHeadless.qml +++ b/dotfiles/quickshell/tests/AppLauncherHeadless.qml @@ -1,7 +1,7 @@ import QtQuick -import qs.widgets.launcher +import qs.HyprChrome.Widgets.Launcher -ApplicationLauncherContent { +AppLauncherContent { width: 1080 height: 620 diff --git a/flake.lock b/flake.lock index ab1ac67..5341c59 100644 --- a/flake.lock +++ b/flake.lock @@ -14,11 +14,11 @@ "uv2nix": "uv2nix" }, "locked": { - "lastModified": 1787577519, - "narHash": "sha256-YNAXQTgR26RJiX2vtYjk6OtBu2jMWeq4qUN6sUrt6Lc=", + "lastModified": 1788820723, + "narHash": "sha256-NIqHasKysUniYrJozavASUF4MqpsyBg7lZZNtM+WrwI=", "owner": "nix-community", "repo": "authentik-nix", - "rev": "30c37930450d7a5fefa8ffec613f037fc75c3071", + "rev": "fd34a5238314351ed92dd79d00f518b8a03e19cb", "type": "github" }, "original": { @@ -30,16 +30,16 @@ "authentik-src": { "flake": false, "locked": { - "lastModified": 1784731584, - "narHash": "sha256-/HdXzjjvuSW7zjbCNJKm3Fj8gvIwfrDf8mOYev0yuIg=", + "lastModified": 1788268887, + "narHash": "sha256-069RXUkk4aSYVelezdmYM70TGIJM8KyTBrLW3Vv0XdM=", "owner": "goauthentik", "repo": "authentik", - "rev": "0c67ea476be6319f1b2a41cb0f5ed128af37b99b", + "rev": "b4de7336e903ef51febf42c0ff3b57c484866cdc", "type": "github" }, "original": { "owner": "goauthentik", - "ref": "version/2026.5.6", + "ref": "version/2026.8.1", "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": 1788642154, + "narHash": "sha256-sPpQFVaFTDqO/4vvCAhuAhqTgqN/ygu+9eJcs5eB0js=", "owner": "nix-community", "repo": "home-manager", - "rev": "65258d5c65a250189fde2e35f490d15e064c4c62", + "rev": "fd0956c99c41ae3c13a73a638f1f7e963aebc4ab", "type": "github" }, "original": { @@ -270,11 +270,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1787728766, - "narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=", + "lastModified": 1788938537, + "narHash": "sha256-ooFA+3//Y9bpyFjVwTvPegsWngEoQ0pGj+2DJ5cVyJ0=", "owner": "nix-community", "repo": "nixos-anywhere", - "rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e", + "rev": "9df41112343713520ba071674cf8e45e91c25845", "type": "github" }, "original": { @@ -291,11 +291,11 @@ "nixos-unstable": "nixos-unstable" }, "locked": { - "lastModified": 1787826771, - "narHash": "sha256-gWkyr3I/cg4SHWGkAoUo24+BQaqoi+S0T2JxDjsA+pw=", + "lastModified": 1789036642, + "narHash": "sha256-ng2Ou1UrVCP98OAJiq8Mv6aDF/kjQWyaKm4PaxWtAu4=", "owner": "nix-community", "repo": "nixos-images", - "rev": "f6714acc84ce92df7286c89a571ad1e946057a5b", + "rev": "4fcaaefd02b5bc777f99afd620cb1a9aa1fb5b83", "type": "github" }, "original": { @@ -323,11 +323,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 +339,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 +354,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1787814960, - "narHash": "sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ=", + "lastModified": 1789073787, + "narHash": "sha256-xfX/toC2QV707s06GbP4II/TxYF0fNQj7s5/LClNDKc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c27cdad491a991b11ed731760aa2ef8db0cb0410", + "rev": "aff8a0b28396750446e5537a96461bc4facdb287", "type": "github" }, "original": { @@ -370,11 +370,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1787753485, - "narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=", + "lastModified": 1789009968, + "narHash": "sha256-GB16oaxpsrnGNnGIE+0uYBqMRzB9X6FYDUvjvnJAYew=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1", + "rev": "d58a46e3bc02d91ebe04667f8397752a749c0024", "type": "github" }, "original": { @@ -507,11 +507,11 @@ ] }, "locked": { - "lastModified": 1786629091, - "narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=", + "lastModified": 1788914643, + "narHash": "sha256-4GuMPW90JSxXWDPUB9M+1m7fYbe3H0apOd86/zBQ2Kw=", "owner": "Mic92", "repo": "sops-nix", - "rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f", + "rev": "13616fff713a9f94055c66f15687ebdc17a335df", "type": "github" }, "original": { @@ -584,11 +584,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": { diff --git a/flake.nix b/flake.nix index 7501c10..e40a313 100644 --- a/flake.nix +++ b/flake.nix @@ -111,8 +111,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; }; @@ -420,138 +420,146 @@ # 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"; + 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 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 &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 &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. # diff --git a/hosts/mars/configuration.nix b/hosts/mars/configuration.nix index 13a0d3f..1b30215 100644 --- a/hosts/mars/configuration.nix +++ b/hosts/mars/configuration.nix @@ -1,13 +1,15 @@ { 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 + ./luna-sites.nix # luna's LAN web apps: http://mars.sol// ../../common.nix # shared base: user / ssh / nix / firewall ../../services/containers.nix ../../services/vpn/tailscale.nix diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 4e7cd56..683bf31 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -49,8 +49,8 @@ # (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 +# over the tailnet. mars's own Caddy (luna-sites.nix) only serves luna's apps +# and has no vhost for this — reach the dashboard directly via mars's tailnet # name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging. # # Uses upstream's generic self-hosted OIDC plugin, same Authentik @@ -196,13 +196,31 @@ in # directly against the real instance during the first version of this # setup). Delete-then-add is idempotent either way and picks up a rotated # token for free. + # + # `tea logins add` is the ONLY step in here that touches the network, and + # ordering is what makes it survivable. switch-to-configuration restarts + # NetworkManager and starts this unit in the SAME pass: on 2026-09-11 the + # two landed in the same second, tea's connect went out over an interface + # that was still coming back, and the kernel spent 2m48s on SYN retries + # before reporting "connection timed out". That failed this unit, which + # podman-hermes-agent Requires=, so a five-second network blip took the + # whole container down and returned 4 from the deploy. Hence + # network-online.target below, the bounded reachability probe in the script, + # and TimeoutStartSec as the backstop — no single blocking call in here may + # outlive the deploy that started it. 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. Failing at that point + # is strictly better than holding the deploy open. + serviceConfig.TimeoutStartSec = "120"; script = '' mkdir -p ${hermesHome} mkdir -p ${dropboxDir} @@ -231,9 +249,43 @@ in 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 + # Probe before touching the login, with a hard per-attempt timeout: a + # bare TCP connect to an interface that is still coming up hangs for + # ~3 minutes on kernel SYN retries, and tea has no timeout flag of its + # own. /api/v1/version is unauthenticated, so this says "is gitea + # reachable", never "is the token good" — the token is the add's job. + # + # Probing FIRST (rather than retrying the add) is what protects the + # login that is already there. delete-then-add is not atomic: an add + # that fails because the network is down leaves luna with no login at + # all, strictly worse than the stale-but-working one we started with. + # Unreachable therefore means skip the refresh entirely and warn. + 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 + + if [ "$gitea_up" = 1 ]; then + # Reachable but the add still fails == a real problem (revoked or + # under-scoped token, gitea rejecting the login), and that stays + # fatal: it is a config error, it will not fix itself on the next + # boot, and it should be loud. + 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 + # Deliberately not fatal. Every other thing this unit does is local, + # and podman-hermes-agent Requires= it — failing here would take + # Telegram and the dashboard down over a transient blip. luna keeps + # git (the credential helper above needs no network to be written) + # and loses only the tea CLI until the next start re-runs this. + echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2 + fi # 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 diff --git a/hosts/mars/luna-sites-README.md b/hosts/mars/luna-sites-README.md new file mode 100644 index 0000000..646c51f --- /dev/null +++ b/hosts/mars/luna-sites-README.md @@ -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//`, 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//` to the port you name in + `/opt/data/sites/.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//` with a `Containerfile` (or + `Dockerfile`), and build it. The directory is uploaded, so this works from + where you are: + + podman build -t localhost/ /opt/data/apps/ + +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 --restart=always \ + -p 127.0.0.1:20001:8080 localhost/ + +3. Register it: + + echo '{"port": 20001}' > /opt/data/sites/.json + +4. Check that it took, then fetch it: + + cat /opt/data/sites-status.txt + curl -si http://127.0.0.1// + + It is now at `http://mars.sol//` for anyone on the home network. + +## Rules the registry enforces + +- `` is lowercase letters, digits and `-`, starts with a letter or + digit, at most 32 characters. The file is `/opt/data/sites/.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 // + +Caddy strips `/` before the request reaches your app, so the app itself +sees `/`, `/style.css`, `/api/items`. The browser, however, is at +`http://mars.sol//`, 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 `//` (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/` redirects to `http://mars.sol//`. + +## 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 -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 `, run it again on the same port. The + JSON file stays as it is. +- Inspect: `podman ps -a`, `podman logs `, `cat /opt/data/sites-status.txt`. +- Remove: `rm /opt/data/sites/.json`, then `podman rm -f `, and + optionally `podman rmi localhost/` and `podman volume rm -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. diff --git a/hosts/mars/luna-sites-test.nix b/hosts/mars/luna-sites-test.nix new file mode 100644 index 0000000..bf9bb07 --- /dev/null +++ b/hosts/mars/luna-sites-test.nix @@ -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. No network in the VM, so it is loaded + # from the store instead of pulled. Runs under luna-apps, which has no + # /nix/store mount — hence the closure inside 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"] + ''; +} diff --git a/hosts/mars/luna-sites.nix b/hosts/mars/luna-sites.nix new file mode 100644 index 0000000..2e3e527 --- /dev/null +++ b/hosts/mars/luna-sites.nix @@ -0,0 +1,334 @@ +{ config, pkgs, ... }: + +# luna-sites — luna (the Hermes agent, hermes-agent.nix) hosts her own web apps +# on mars, LAN-only, at http://mars.sol//, 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/.json {"port": N} hermesHome/sites, hers to write +# ▼ luna-sites.path → luna-sites.service (root): validate, caddy validate, reload +# /var/lib/luna-sites/live/.caddy root-owned, imported by caddy +# /opt/data/sites-status.txt what was accepted, and why not +# +# Why a registry of {name, port} instead of letting her drop Caddyfile +# snippets: a snippet can proxy to anything on this box (the dashboard on +# 9119, the webhook listener on 8644, node-exporter) or file_server anything +# caddy can read, and one syntax error keeps caddy from coming up on the next +# boot. The generator only ever emits one fixed shape from a validated name +# and a port inside portMin..portMax, so none of that is expressible. +# +# Why paths, not .mars.sol: mars has no fixed DHCP lease, and a wildcard +# needs one. `address=/…/` takes an IP, and pihole-FTL's dnsmasq skips +# wildcard --cname entries outside authoritative zones (cache_reload(): +# `if (a->alias[1] != '*' …)`). Moving to subdomains later only changes the +# fragment the generator writes; the registry format stays. +# +# Why a podman socket instead of ssh: what she needs is long-running processes +# OUTSIDE her own container (anything started inside it dies with the +# container, and sits next to her Telegram/gitea tokens). The socket gives +# exactly that and no host shell. It is not a strong boundary on its own — +# rootless podman socket access is code execution as luna-apps, which can read +# whatever that user can — but luna-apps owns nothing and cannot enter +# /var/lib/hermes (0750 root:hermes), so the apps cannot 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 +# memories, so nothing in this file reaches her otherwise — see the dropped +# repo clone in hermes-agent.nix's header for what happens when it doesn't. +# +# 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)"; + # Nothing ever logs in as this user. Only its systemd user manager runs, + # kept up without a session by linger, which is what brings the podman + # socket and podman-restart back after a reboot. + linger = true; + autoSubUidGidRange = true; # rootless podman's user namespace + hashedPassword = "!"; + shell = "${pkgs.shadow}/bin/nologin"; + }; + + # `--restart=always` containers only come back after a reboot through this + # unit — rootless podman has no daemon to remember them. The podman module + # already enables podman.socket for every user's manager; this one is + # scoped 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), which the + # container's uid cannot enter. This re-exposes it to group hermes, and the + # proxy behind it runs as luna-apps, so it holds no access beyond the socket + # it forwards to. + 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 = [ + # The directory, not the socket file: the socket is created by systemd + # at boot, and a file bind mount would pin whatever inode was there when + # the container started. 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 //." 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 never races caddy's own start. Nothing + # orders caddy after THIS unit, which is what keeps the blocking + # `systemctl reload caddy` from waiting on its own start job. + after = [ "caddy.service" ]; + # No start rate limit. The default (5 starts in 10s) is hit by nothing + # more than a handful of quick writes — the VM test does exactly that — + # and when it is, systemd also fails luna-sites.path for good + # (unit-start-limit-hit): every later registration is silently ignored + # until someone runs reset-failed. Bursts are absorbed by the debounce at + # the top of the script 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 + + # Everything that touches luna's tree runs as the container's uid, never + # as root: she controls every path under it, including swapping one for + # a symlink into /etc 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: writes usually come in bursts (several files, or an editor's + # write-then-rename), and every trigger that lands while this oneshot + # is still activating merges into this same start job instead of + # queuing another. One second collapses a burst into one run. + sleep 1 + + # That merging also means an entry written mid-run would otherwise wait + # for the next unrelated change. Compare the registry before and after, + # and go again. Bounded, so a writer in a loop cannot pin the unit. + 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 + ''; + }; +} diff --git a/hosts/terra/configuration.nix b/hosts/terra/configuration.nix index 03dd041..80d4a3d 100644 --- a/hosts/terra/configuration.nix +++ b/hosts/terra/configuration.nix @@ -43,6 +43,51 @@ in # https://nix.dev/permalink/stub-ld ---- programs.nix-ld.enable = true; + # The default set above is deliberately minimal and carries no X11, + # freetype, wayland or xkbcommon, so a prebuilt *graphical* binary dies + # before it draws anything. JetBrains IDEs installed through Toolbox are the + # case that surfaced this: their bundled JBR aborts with `libX11.so.6: + # cannot open shared object file` unless the Toolbox GUI — itself an FHS + # wrapper — is what launches them, which makes them unusable from a terminal + # or from a per-repo devShell. These are the libraries `ldd` reports missing + # across a JBR's own .so files, plus the three it resolves by dlopen rather + # than DT_NEEDED: fontconfig for font discovery, libGL, and libsecret for + # the credential store. Definitions merge, so this adds to the module's base + # list rather than replacing it (zlib is already there). + programs.nix-ld.libraries = with pkgs; [ + freetype + fontconfig + libGL + libxkbcommon + wayland + libsecret + libx11 + libxext + libxi + libxrender + libxtst + libxcursor + libxrandr + libxinerama + libxcb + + # CLion Nova's C++ backend (the clion-radler plugin) is a .NET 10 + # application bundling its own runtime, and .NET refuses to start + # without ICU: libSystem.Globalization.Native.so dlopens libicuuc.so + # and libicui18n.so, and failing that the IDE reports "Couldn't find a + # valid ICU package installed on the system" and comes up degraded. + icu + ]; + + # ---- envfs: serves /bin and /usr/bin from the calling process's PATH ---- + # NixOS ships only /bin/sh, but plenty of third-party tooling writes scripts + # with a hardcoded interpreter. JetBrains Toolbox is the standing example: + # it generates ~/.local/share/JetBrains/Toolbox/scripts/{clion,rider,...} + # with `#!/bin/bash`, so every one of those shims fails with `bad + # interpreter` in any shell. envfs resolves such shebangs against PATH, + # which fixes them all at once instead of per-IDE wrappers. + 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 @@ -96,7 +141,15 @@ in # 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" ]; + # VladimirGav/qwen3.8-27B-14GB-IQ4: dense 27B at IQ4, ~14GB of weights — + # nominally fits the 6800 XT's 16G, but that leaves only ~2G for the KV + # cache and the compositor, so expect partial CPU offload as context grows + # (OLLAMA_CONTEXT_LENGTH below applies to every model on this server). + loadModels = [ + "gemma4:12b" + "qwen3.6:35b-a3b" + "VladimirGav/qwen3.8-27B-14GB-IQ4" + ]; # 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 diff --git a/hosts/terra/home.nix b/hosts/terra/home.nix index 8eb295d..b61e6a5 100644 --- a/hosts/terra/home.nix +++ b/hosts/terra/home.nix @@ -1,6 +1,53 @@ { pkgs, unstable, inputs, ... }: let tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; }; + + # SUDO_ASKPASS helper: renders sudo's password prompt in the quickshell + # shell (HyprChrome/Widgets/Askpass) instead of on the terminal. + # + # sudo does NOT speak polkit — it is setuid + PAM reading the tty, and no + # sudoers option bridges the two — so this is the askpass mechanism, a + # separate path that happens to reuse the polkit dialog's look. `run0` is the + # polkit-native alternative if you want the agent itself. + # + # A package rather than a file in dotfiles/quickshell because SUDO_ASKPASS + # must point at something EXECUTABLE, and xdg.configFile copies keep their + # store mode — which is why open_launcher.sh has to be invoked as + # `bash ` rather than run directly. + # + # The secret comes back over a 0600 fifo, never in argv or the environment, + # so it is not visible in /proc to anything. Cancelling closes the fifo + # without writing: `cat` reads nothing, this exits non-zero, and sudo aborts + # instead of burning a retry on an empty password. + 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 @@ -34,6 +81,12 @@ in # it instead of the root /var/run/docker.sock. 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 +99,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 ]; diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index e8eabff..4dc20e3 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -31,12 +31,19 @@ let lua = lib.generators.mkLuaInline; + # 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; + # 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"; + # wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png"; + 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 +101,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 +124,7 @@ in debug.disable_logs = false; general = { - border_size = 0; + border_size = 2; col = { inactive_border = lua "bg_accent"; active_border = { @@ -182,10 +189,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" ]; } diff --git a/hosts/terra/home/theme.nix b/hosts/terra/home/theme.nix index 396200a..2dc2b5b 100644 --- a/hosts/terra/home/theme.nix +++ b/hosts/terra/home/theme.nix @@ -23,6 +23,22 @@ in }; }; + # The cursor theme. XCURSOR_THEME alone is not enough for Steam: the client + # UI (steamwebhelper) runs inside a pressure-vessel container that rebuilds + # /etc, so the /etc/profiles/per-user/darman/share/icons entry of + # XCURSOR_PATH does not exist in there and libXcursor finds no theme by + # that name — it falls back to the built-in core X11 cursor. $HOME and + # /nix are bind-mounted into the container, so the ~/.icons symlink that + # `dotIcons` (on by default) drops does resolve. Same class of problem as + # the ~/.themes/~/.icons flatpak workaround above. + home.pointerCursor = { + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + size = 24; + gtk.enable = true; + hyprcursor.enable = true; + }; + # 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 diff --git a/services/desktop/librechat.nix b/services/desktop/librechat.nix index 379fc42..ca683de 100644 --- a/services/desktop/librechat.nix +++ b/services/desktop/librechat.nix @@ -36,7 +36,7 @@ # 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" ]; + 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;