Compare commits
3 Commits
fix/waybar
...
2236a0d74c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2236a0d74c | |||
| b7ce5deb4c | |||
| 889a123231 |
287
.pi/extensions/herdr-agent-state.ts
Normal file
287
.pi/extensions/herdr-agent-state.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
// installed by herdr
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=pi
|
||||
// HERDR_INTEGRATION_VERSION=6
|
||||
// @ts-nocheck
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
const HERDR_ENV = process.env.HERDR_ENV;
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||
const socketEndpoint =
|
||||
process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||
const paneId = process.env.HERDR_PANE_ID;
|
||||
const source = "herdr:pi";
|
||||
|
||||
function enabled() {
|
||||
return HERDR_ENV === "1" && !!socketPath && !!paneId;
|
||||
}
|
||||
|
||||
function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
||||
if (!enabled()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (delivered: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
socket.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
|
||||
const socket = net.createConnection(socketEndpoint!);
|
||||
socket.on("error", () => finish(false));
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", () => finish(true));
|
||||
socket.on("end", () => finish(false));
|
||||
timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
timeout.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRequest(request: unknown): Promise<void> {
|
||||
if (await sendRequestAttempt(request, 500)) {
|
||||
return;
|
||||
}
|
||||
await sendRequestAttempt(request, 1500);
|
||||
}
|
||||
|
||||
type AgentState = "working" | "blocked" | "idle";
|
||||
|
||||
type QueuedState = {
|
||||
state: AgentState;
|
||||
message?: string;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
let reportSeq = Date.now() * 1000;
|
||||
let currentAgentSessionId: string | undefined;
|
||||
let currentAgentSessionPath: string | undefined;
|
||||
|
||||
function nextReportSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
function updateSessionRef(ctx: any): void {
|
||||
try {
|
||||
const file = ctx?.sessionManager?.getSessionFile?.();
|
||||
currentAgentSessionPath =
|
||||
typeof file === "string" && file.startsWith("/") ? file : undefined;
|
||||
} catch {
|
||||
currentAgentSessionPath = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = ctx?.sessionManager?.getSessionId?.();
|
||||
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
} catch {
|
||||
currentAgentSessionId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
|
||||
if (currentAgentSessionPath) {
|
||||
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { ...params, agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function currentSessionRef(): Record<string, unknown> | undefined {
|
||||
if (currentAgentSessionPath) {
|
||||
return { agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reportSession(sessionStartSource?: string): Promise<void> {
|
||||
const sessionRef = currentSessionRef();
|
||||
if (!sessionRef) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return sendRequest({
|
||||
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_agent_session",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
session_start_source: sessionStartSource,
|
||||
...sessionRef,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_agent",
|
||||
params: withSessionRef({
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
state,
|
||||
message,
|
||||
seq,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAgent(): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.release_agent",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldReleaseOnSessionShutdown(event: any): boolean {
|
||||
// Pi tears down and rebinds extension runtimes for internal lifecycle actions
|
||||
// such as /reload, /new, /resume, and /fork. Those do not mean the pane's
|
||||
// agent process has exited, and releasing hook authority there can suppress
|
||||
// legitimate reports from the replacement runtime. Only a user/process quit
|
||||
// should release Herdr's full-lifecycle authority.
|
||||
const reason = event?.reason;
|
||||
return reason === "quit";
|
||||
}
|
||||
|
||||
let sendInFlight = false;
|
||||
let queuedState: QueuedState | undefined;
|
||||
|
||||
function queueState(state: AgentState, message?: string): void {
|
||||
queuedState = { state, message, seq: nextReportSeq() };
|
||||
if (!sendInFlight) {
|
||||
void drainStateQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function drainStateQueue(): Promise<void> {
|
||||
if (sendInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendInFlight = true;
|
||||
try {
|
||||
while (queuedState) {
|
||||
const next = queuedState;
|
||||
queuedState = undefined;
|
||||
await sendState(next.state, next.message, next.seq);
|
||||
}
|
||||
} finally {
|
||||
sendInFlight = false;
|
||||
if (queuedState) {
|
||||
void drainStateQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentActive = false;
|
||||
let blockedCount = 0;
|
||||
let blockedMessage: string | undefined;
|
||||
let lastState: AgentState | undefined;
|
||||
let lastMessage: string | undefined;
|
||||
let rootSession = false;
|
||||
|
||||
function desiredState() {
|
||||
if (blockedCount > 0) {
|
||||
return { state: "blocked" as const, message: blockedMessage };
|
||||
}
|
||||
if (agentActive) {
|
||||
return { state: "working" as const, message: undefined };
|
||||
}
|
||||
return { state: "idle" as const, message: undefined };
|
||||
}
|
||||
|
||||
function publishState(force = false) {
|
||||
const next = desiredState();
|
||||
if (!force && next.state === lastState && next.message === lastMessage) {
|
||||
return;
|
||||
}
|
||||
lastState = next.state;
|
||||
lastMessage = next.message;
|
||||
queueState(next.state, next.message);
|
||||
}
|
||||
|
||||
pi.events.on("herdr:blocked", (data) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (!data?.active) {
|
||||
blockedCount = Math.max(0, blockedCount - 1);
|
||||
if (blockedCount === 0) {
|
||||
blockedMessage = undefined;
|
||||
}
|
||||
publishState();
|
||||
return;
|
||||
}
|
||||
|
||||
blockedCount += 1;
|
||||
blockedMessage = data.label;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
if (ctx?.hasUI !== true) {
|
||||
return;
|
||||
}
|
||||
rootSession = true;
|
||||
updateSessionRef(ctx);
|
||||
await reportSession(event?.reason);
|
||||
// A reload can replace this extension mid-run without emitting another agent_start.
|
||||
agentActive = ctx?.isIdle?.() === false;
|
||||
publishState(true);
|
||||
});
|
||||
|
||||
pi.on("agent_start", (_event, ctx) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
updateSessionRef(ctx);
|
||||
void reportSession();
|
||||
agentActive = true;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("agent_settled", (_event, ctx) => {
|
||||
if (!rootSession || ctx?.isIdle?.() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
agentActive = false;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (event) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (shouldReleaseOnSessionShutdown(event)) {
|
||||
await releaseAgent();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
description: Review changes against repository style rules
|
||||
argument-hint: "[target]"
|
||||
---
|
||||
|
||||
Review target: `${ARGUMENTS:-the staged diff}`.
|
||||
Audit the target for violations of the repository style rules.
|
||||
|
||||
Choose skills from the reviewed files:
|
||||
|
||||
- Use the `nix-style` skill for `*.nix` files.
|
||||
- Use the `rust-style` skill for `*.rs` files.
|
||||
- Use the `build-style` skill for `Cargo.toml` files.
|
||||
- Use the `build-style` skill for Bazel `BUILD` files.
|
||||
|
||||
If the target includes multiple matching file types, use all matching skills.
|
||||
If the target is a diff, choose skills from the changed files.
|
||||
Do not use a style skill when no reviewed file matches it.
|
||||
|
||||
For every reviewed `*.nix` file, run:
|
||||
|
||||
```bash
|
||||
bazel run //src/natu:natu -- <reviewed-nix-paths>
|
||||
```
|
||||
|
||||
Pass every reviewed Nix path to Natu.
|
||||
Report every Natu diagnostic as an actionable finding.
|
||||
|
||||
Report only actionable findings.
|
||||
Order findings by severity.
|
||||
Do not report preferences unless a loaded rule states them.
|
||||
|
||||
Use this format for each finding:
|
||||
|
||||
```text
|
||||
`code-path:line`
|
||||
> `smallest reviewed-code excerpt that shows the violation`
|
||||
|
||||
`skill::rule-file:line`: Finding title.
|
||||
Summary: why the code breaks the rule
|
||||
Fix: suggested fix
|
||||
```
|
||||
|
||||
Keep the code excerpt to one to three lines when possible.
|
||||
Use a longer excerpt only when the violation needs surrounding context.
|
||||
Use `...` to omit unrelated lines from a multi-line excerpt.
|
||||
Use a style-skill rule citation in the form `skill::rule-file:line`.
|
||||
Cite Natu diagnostics as `natu::<rule-id>`.
|
||||
Make the fix direct.
|
||||
Do not include alternatives when one fix best follows the cited rule.
|
||||
|
||||
It is acceptable to report no findings when the target follows the loaded rules.
|
||||
@@ -1 +0,0 @@
|
||||
../../src/skills/style/build
|
||||
@@ -1 +0,0 @@
|
||||
../../src/skills/craft-skill
|
||||
@@ -1 +0,0 @@
|
||||
../../src/skills/style/nix
|
||||
@@ -1 +0,0 @@
|
||||
../../src/skills/style/rust
|
||||
@@ -1 +0,0 @@
|
||||
../../src/skills/to-document
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -95,34 +95,10 @@
|
||||
committing a top-down rewrite.
|
||||
- `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`.
|
||||
Use one input style per test target.
|
||||
- Rust tests in this monorepo live in separate `*_test.rs` files, not inline `#[cfg(test)]` modules.
|
||||
- `rnix::ast::HasEntry::entries()` includes assignments and `inherit` statements.
|
||||
Use `attrpath_values()` when code needs only assignment entries.
|
||||
- Ariadne Rust tests are declared in Bazel `BUILD` files; do not add `[[test]]` entries to
|
||||
`src/ariadne/Cargo.toml` unless explicitly requested.
|
||||
- In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`.
|
||||
- The flake does not currently define `formatter.x86_64-linux`.
|
||||
Do not use `nix fmt` as a validation command until a formatter is added.
|
||||
- The `fix` app passes its current directory to Deadnix.
|
||||
Run `nix run .#fix` from the repository root so path exclusions match.
|
||||
- During the Nix refactor, address each issue that comes up by adding either a Natu rule or a new Nix style rule.
|
||||
- Git pathspec searches started in a subdirectory default to that subtree.
|
||||
Use `git -C <repository-root>` or top-anchored pathspecs for repository-wide history searches.
|
||||
- A `**/*.nix` Git pathspec does not include Nix files directly in its starting directory.
|
||||
Include both `*.nix` and `**/*.nix`, or use a recursive `*.nix` pathspec, when linting every Nix file.
|
||||
- Nix `let` bindings are recursive; their source order does not determine binding availability.
|
||||
Treat Natu `let_order` as a readability rule, not an evaluation dependency.
|
||||
- Ariadne extraction must preserve the game-root season prefix for archived source.
|
||||
Different game installs can contain compiled scripts with the same internal path.
|
||||
- Clew owns Ren'Py extraction, preparation, and compiled-script AST export. Ariadne owns analysis over Clew-produced data.
|
||||
- Bazel Python targets require `PYTHON_TOOLCHAIN_ROOT` to point at a Python installation root.
|
||||
- When checking whether a package exists in nixpkgs, check both this repository's pinned nixpkgs inputs
|
||||
and the relevant current upstream channel. A package can exist on current `nixos-unstable` while being
|
||||
absent from the pinned revision or from a different branch such as `nixpkgs-unstable`.
|
||||
- OMP does not discover project skills from `.pi/skills`.
|
||||
Put skills shared by Pi and OMP under `.agents/skills`, or configure OMP's `skills.customDirectories`.
|
||||
- Pi is vendored under `third_party/pi` as a linear vendor import.
|
||||
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
|
||||
Gitea rebases the synthetic upstream root commit without the `third_party/pi` prefix.
|
||||
Preserve `git-subtree-dir` and `git-subtree-split` trailers on Pi vendor commits.
|
||||
- The default Pi model for NixOS user configuration is set in `configs/nixos/modules/shell/pi/default.nix`, not in vendored Pi source.
|
||||
|
||||
187
Cargo.lock
generated
187
Cargo.lock
generated
@@ -2,15 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -66,22 +57,15 @@ name = "ariadne"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"monorepo",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -89,9 +73,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -101,21 +85,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -123,41 +107,6 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "countme"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "googletest"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94"
|
||||
dependencies = [
|
||||
"googletest_macro",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googletest_macro"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -186,31 +135,6 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "monorepo"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "natu"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"googletest",
|
||||
"monorepo",
|
||||
"rnix",
|
||||
"rowan",
|
||||
"status",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -235,68 +159,6 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rnix"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c163bd17372eecdf10d351c34584b7de7c1a33be4e92a32f3fb3f5a7fe3f579b"
|
||||
dependencies = [
|
||||
"rowan",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rowan"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
|
||||
dependencies = [
|
||||
"countme",
|
||||
"hashbrown",
|
||||
"rustc-hash",
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -324,7 +186,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -340,10 +202,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "status"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -352,37 +210,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-size"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
|
||||
13
Cargo.toml
13
Cargo.toml
@@ -2,18 +2,5 @@
|
||||
members = [
|
||||
"src/ariadne",
|
||||
"src/machoke",
|
||||
"src/monorepo",
|
||||
"src/status",
|
||||
"src/natu",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = "4"
|
||||
googletest = "0.14"
|
||||
monorepo = { path = "src/monorepo" }
|
||||
rnix = "=0.14.0"
|
||||
rowan = "0.16"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
status = { path = "src/status" }
|
||||
|
||||
@@ -14,5 +14,4 @@ rust = use_extension("//bazel:rust.bzl", "rust")
|
||||
use_repo(rust, "rust")
|
||||
|
||||
register_toolchains("@python//:python")
|
||||
|
||||
register_toolchains("@rust//:rust")
|
||||
|
||||
45
README.md
45
README.md
@@ -13,8 +13,7 @@ dev/ Monorepo root
|
||||
├── src/ Projects
|
||||
│ └── dotfiles/ Home and system configuration
|
||||
└── third_party/ External source and vendoring support
|
||||
├── crates/ Generated Rust crate sources and Bazel targets
|
||||
└── pi/ Vendored Pi source
|
||||
└── crates/ Generated Rust crate sources and Bazel targets
|
||||
```
|
||||
|
||||
## Rust dependencies
|
||||
@@ -45,45 +44,3 @@ deps = ["//third_party/crates:crate_name"]
|
||||
Commit the generated files under `third_party/crates/`.
|
||||
Do not edit generated files manually.
|
||||
|
||||
## Vendored Pi
|
||||
|
||||
Pi source lives in `third_party/pi/`.
|
||||
The directory is a linear vendor import from upstream Pi.
|
||||
Local fork changes are carried directly in that vendored tree.
|
||||
|
||||
Use the upstream tag that matches the target CLI release.
|
||||
The CLI version lives in `packages/coding-agent/package.json`.
|
||||
|
||||
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
|
||||
Those commands create a synthetic upstream root commit.
|
||||
Gitea rebase applies that root commit without the `third_party/pi` prefix.
|
||||
That causes add/add conflicts on repository root files.
|
||||
|
||||
Update the vendor tree with a linear commit:
|
||||
|
||||
```sh
|
||||
git fetch https://github.com/earendil-works/pi.git refs/tags/v<version>
|
||||
split="$(git rev-parse FETCH_HEAD)"
|
||||
rm -rf third_party/pi
|
||||
mkdir -p third_party/pi
|
||||
git archive --format=tar "$split" | tar -C third_party/pi -xf -
|
||||
git add third_party/pi
|
||||
git commit \
|
||||
-m "chore(vendor): import pi <version>" \
|
||||
-m "Imported as a linear vendor commit so forge rebases do not replay the upstream root tree." \
|
||||
-m "git-subtree-dir: third_party/pi" \
|
||||
-m "git-subtree-split: $split"
|
||||
```
|
||||
|
||||
Keep the vendor import or update commit separate from local edits.
|
||||
Do not remove the `git-subtree-dir` and `git-subtree-split` trailers.
|
||||
They record the upstream source commit for future updates.
|
||||
|
||||
After the upstream update, resolve conflicts in `third_party/pi/`.
|
||||
Keep local fork changes as ordinary commits after the vendor update.
|
||||
Then verify the package:
|
||||
|
||||
```sh
|
||||
nix build .#nixosConfigurations.zeus.config.home-manager.users.alexion.programs.pi-coding-agent.package --no-link --print-out-paths
|
||||
```
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
## Desktop
|
||||
|
||||
- When adding a graphical application, register its workspace icon in `registry.window-icon`.
|
||||
- TODO: Verify this path after the NixOS configuration is migrated.
|
||||
When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
|
||||
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
|
||||
Match the mapping on the window class.
|
||||
Store the icon as the rendered string.
|
||||
Run `hyprctl clients -j | jq -r '.[].class' | sort -u` to list classes in the running session.
|
||||
- TODO: Verify this path after the NixOS configuration is migrated.
|
||||
Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.
|
||||
@@ -40,28 +40,13 @@
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `configs/nixos/registry/stable-uid.nix` receives library functions through `lib`.
|
||||
Qualify its library calls with `lib.`.
|
||||
- Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths.
|
||||
For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths.
|
||||
Guest addresses and routes are host-level configuration because a guest module can be used on multiple hosts.
|
||||
- `sops.secrets.<name>.owner` must name a user known on the decrypting host.
|
||||
Do not set the owner to a guest-only service user unless the host also declares that user.
|
||||
Use a host-visible owner plus a shared group mode when the guest service can read the decrypted secret file through its group.
|
||||
- A read-only guest bind mount cannot contain nested writable bind mounts unless their mount-point
|
||||
directories already exist inside the read-only source.
|
||||
For aggregate Copyparty volumes such as `/srv` with nested `/srv/inbox`, keep the guest bind mount
|
||||
writable and enforce read-only behavior through Copyparty access plus service `ReadWritePaths`.
|
||||
- `deploy-to` builds from the source checkout, records the imported closure as the target system profile,
|
||||
and activates it. Do not rely on a target-local monorepo checkout for remote deployment.
|
||||
- On Pikachu, `nix copy --to ssh://pikachu` accepted a small `nix store add-file` probe,
|
||||
but rejected locally built system paths that lacked trusted signatures.
|
||||
Use a sudo `nix-store --import` path for unsigned deploy closures.
|
||||
- Public DNS entries are not managed through the NixOS configuration.
|
||||
Do not describe NixOS route choices as public-DNS management.
|
||||
- Pikachu deploys from the monorepo checkout copied to `~/dev`.
|
||||
Do not use the old `~/wrk/dotfiles` path for remote rebuild commands.
|
||||
- Do not hand-author `hardware-configuration.nix` for a host that has not been generated on that machine.
|
||||
Generate it on the target system or live ISO, then stage it before evaluating the flake.
|
||||
- Configure the Chaotic Nyx cache only on hosts that select `linuxPackages_cachyos` (currently Neogaia
|
||||
and Zeus), not in the generic host module.
|
||||
- Module-relative paths resolve from the module file. Update them when moving a module into a subdirectory.
|
||||
- On Pikachu, `uptime` does not support `-s`. Use `who -b` to report the boot time.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.actualbudget; # NOLINT(let_order)
|
||||
cfg = config.guests.actualbudget;
|
||||
dataDir = "/data";
|
||||
in
|
||||
{
|
||||
@@ -25,14 +25,6 @@ in
|
||||
}
|
||||
];
|
||||
|
||||
registry.homepage.${config.networking.hostName}.actualbudget = {
|
||||
tab = "Apps";
|
||||
section = "Other";
|
||||
name = "Actual Budget";
|
||||
description = "Finance";
|
||||
icon = "actual-budget.png";
|
||||
};
|
||||
|
||||
guests.actualbudget = {
|
||||
interior = {
|
||||
modules.services.actualbudget = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.copyparty; # NOLINT(let_order)
|
||||
cfg = config.guests.copyparty;
|
||||
|
||||
volumeType = lib.types.submodule {
|
||||
options = {
|
||||
@@ -24,12 +24,6 @@ let
|
||||
description = "Copyparty access map from permission string to account names.";
|
||||
};
|
||||
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Serve this Copyparty volume read-only.";
|
||||
};
|
||||
|
||||
flags = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
@@ -90,15 +84,6 @@ in
|
||||
}
|
||||
];
|
||||
|
||||
registry.homepage.${config.networking.hostName}.copyparty = {
|
||||
tab = "Apps";
|
||||
section = "Files";
|
||||
name = "Copyparty";
|
||||
description = "Files on ${config.networking.hostName}";
|
||||
icon = "copyparty.svg";
|
||||
href = lib.mkIf cfg.reverseProxy.enable "https://${cfg.reverseProxy.route.domain}/?h";
|
||||
};
|
||||
|
||||
guests.copyparty = {
|
||||
interior.modules.services.copyparty = {
|
||||
enable = true;
|
||||
@@ -109,36 +94,31 @@ in
|
||||
inherit (cfg.reverseProxy) trustedSources;
|
||||
};
|
||||
volumes = lib.mapAttrs (_urlPath: volume: {
|
||||
inherit (volume)
|
||||
path
|
||||
access
|
||||
flags
|
||||
readOnly
|
||||
;
|
||||
inherit (volume) path access flags;
|
||||
}) cfg.volumes;
|
||||
};
|
||||
|
||||
mounts = {
|
||||
${cfg.stateDir} = {
|
||||
directory = cfg.statePath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${"/run/secrets/${cfg.passwordSecret}"} = {
|
||||
file = config.sops.secrets.${cfg.passwordSecret}.path;
|
||||
readOnly = true;
|
||||
};
|
||||
}
|
||||
// lib.mapAttrs' (
|
||||
_urlPath: volume:
|
||||
lib.nameValuePair volume.path {
|
||||
directory = volume.hostPath;
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "2770";
|
||||
mounts =
|
||||
{
|
||||
${cfg.stateDir} = {
|
||||
directory = cfg.statePath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${"/run/secrets/${cfg.passwordSecret}"} = {
|
||||
file = config.sops.secrets.${cfg.passwordSecret}.path;
|
||||
readOnly = true;
|
||||
};
|
||||
}
|
||||
) cfg.volumes;
|
||||
// lib.mapAttrs' (_urlPath: volume:
|
||||
lib.nameValuePair volume.path {
|
||||
directory = volume.hostPath;
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "2770";
|
||||
}
|
||||
) cfg.volumes;
|
||||
limits = {
|
||||
memory = lib.mkDefault "2G";
|
||||
cpu = lib.mkDefault "200%";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.homepage; # NOLINT(let_order)
|
||||
environmentFile = "/run/secrets/homepage.env";
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
guests.homepage = {
|
||||
interior.modules.services.homepage = {
|
||||
enable = true;
|
||||
hostName = config.networking.hostName;
|
||||
inherit environmentFile;
|
||||
};
|
||||
|
||||
mounts.${environmentFile} = {
|
||||
file = config.sops.templates."homepage.env".path;
|
||||
readOnly = true;
|
||||
};
|
||||
|
||||
limits = {
|
||||
memory = lib.mkDefault "1G";
|
||||
cpu = lib.mkDefault "100%";
|
||||
tasksMax = lib.mkDefault 256;
|
||||
};
|
||||
};
|
||||
|
||||
sops.secrets = {
|
||||
"services/metadata/latitude" = { };
|
||||
"services/metadata/longitude" = { };
|
||||
};
|
||||
|
||||
sops.templates."homepage.env" = {
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "0440";
|
||||
content = ''
|
||||
HOMEPAGE_VAR_LATITUDE=${config.sops.placeholder."services/metadata/latitude"}
|
||||
HOMEPAGE_VAR_LONGITUDE=${config.sops.placeholder."services/metadata/longitude"}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
guestCfg = config.guests.navidrome;
|
||||
serviceCfg = config.containers.navidrome.config.modules.services.navidrome;
|
||||
in
|
||||
{
|
||||
options.guests.navidrome = {
|
||||
dataPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/navidrome";
|
||||
description = "Host path mounted as Navidrome's data folder.";
|
||||
};
|
||||
|
||||
libraryPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/music";
|
||||
description = "Host path mounted as Navidrome's library folder.";
|
||||
};
|
||||
|
||||
lastfmApiKeySecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "services/navidrome/lastfm/api-key";
|
||||
description = "SOPS secret name containing the Navidrome Last.fm API key.";
|
||||
};
|
||||
|
||||
lastfmSecretSecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "services/navidrome/lastfm/secret";
|
||||
description = "SOPS secret name containing the Navidrome Last.fm secret.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf guestCfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = guestCfg.dataPath != null;
|
||||
message = "guests.navidrome.dataPath must point at the host directory that stores Navidrome data.";
|
||||
}
|
||||
{
|
||||
assertion = guestCfg.libraryPath != null;
|
||||
message = "guests.navidrome.libraryPath must point at the host directory that stores Navidrome's library folder.";
|
||||
}
|
||||
];
|
||||
|
||||
registry.homepage.${config.networking.hostName}.navidrome = {
|
||||
tab = "Apps";
|
||||
section = "Media";
|
||||
name = "Navidrome";
|
||||
description = "Music";
|
||||
icon = "navidrome.png";
|
||||
};
|
||||
|
||||
guests.navidrome = {
|
||||
interior.modules.services.navidrome.enable = true;
|
||||
|
||||
mounts = {
|
||||
${serviceCfg.dataFolder} = {
|
||||
directory = guestCfg.dataPath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.navidrome;
|
||||
group = "storage";
|
||||
mode = "2770";
|
||||
};
|
||||
${serviceCfg.musicFolder} = {
|
||||
directory = guestCfg.libraryPath;
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "2770";
|
||||
};
|
||||
${serviceCfg.environmentFile} = {
|
||||
file = config.sops.templates."navidrome.env".path;
|
||||
readOnly = true;
|
||||
};
|
||||
};
|
||||
|
||||
limits = {
|
||||
memory = lib.mkDefault "2G";
|
||||
cpu = lib.mkDefault "200%";
|
||||
tasksMax = lib.mkDefault 512;
|
||||
};
|
||||
};
|
||||
|
||||
sops.secrets = {
|
||||
${guestCfg.lastfmApiKeySecret}.sopsFile = ../secrets + "/${config.networking.hostName}.yaml";
|
||||
${guestCfg.lastfmSecretSecret}.sopsFile = ../secrets + "/${config.networking.hostName}.yaml";
|
||||
};
|
||||
|
||||
sops.templates."navidrome.env" = {
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "0440";
|
||||
content = ''
|
||||
ND_LASTFM_APIKEY=${config.sops.placeholder.${guestCfg.lastfmApiKeySecret}}
|
||||
ND_LASTFM_SECRET=${config.sops.placeholder.${guestCfg.lastfmSecretSecret}}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.palworld; # NOLINT(let_order)
|
||||
steamDir = "/data/steamcmd";
|
||||
installDir = "/data/server";
|
||||
stateDir = "/data/state";
|
||||
in
|
||||
{
|
||||
options.guests.palworld = {
|
||||
steamPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/steamcmd";
|
||||
description = "Host path bind-mounted as SteamCMD's metadata directory.";
|
||||
};
|
||||
|
||||
installPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/palworld/server";
|
||||
description = "Host path bind-mounted as Palworld's server install directory.";
|
||||
};
|
||||
|
||||
statePath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/palworld/state";
|
||||
description = "Host path bind-mounted as Palworld's durable state directory.";
|
||||
};
|
||||
|
||||
serverName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "palworld";
|
||||
description = "Name shown for the Palworld server.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8211;
|
||||
description = "UDP port for the Palworld server.";
|
||||
};
|
||||
|
||||
publicIP = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Public IP address advertised by the Palworld server.";
|
||||
};
|
||||
|
||||
autoStart = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Start the Palworld server after the guest reaches network readiness.";
|
||||
};
|
||||
|
||||
passwordSecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "services/game-servers/password";
|
||||
description = "SOPS secret name containing the Palworld server password.";
|
||||
};
|
||||
|
||||
adminPasswordSecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "services/game-servers/password";
|
||||
description = "SOPS secret name containing the Palworld admin password.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.steamPath != null;
|
||||
message = "guests.palworld.steamPath must point at the host directory that stores SteamCMD metadata.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.installPath != null;
|
||||
message = "guests.palworld.installPath must point at the host directory that stores Palworld server files.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.statePath != null;
|
||||
message = "guests.palworld.statePath must point at the host directory that stores Palworld durable state.";
|
||||
}
|
||||
];
|
||||
|
||||
guests.palworld = {
|
||||
interior.modules.services.games.palworld = {
|
||||
enable = true;
|
||||
inherit (cfg)
|
||||
serverName
|
||||
port
|
||||
publicIP
|
||||
autoStart
|
||||
;
|
||||
inherit steamDir installDir stateDir;
|
||||
passwordFile = "/run/secrets/${cfg.passwordSecret}";
|
||||
adminPasswordFile = "/run/secrets/${cfg.adminPasswordSecret}";
|
||||
};
|
||||
|
||||
mounts = {
|
||||
${steamDir} = {
|
||||
directory = cfg.steamPath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${installDir} = {
|
||||
directory = cfg.installPath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${stateDir} = {
|
||||
directory = cfg.statePath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${"/run/secrets/${cfg.passwordSecret}"} = {
|
||||
file = config.sops.secrets.${cfg.passwordSecret}.path;
|
||||
readOnly = true;
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
|
||||
${"/run/secrets/${cfg.adminPasswordSecret}"} = {
|
||||
file = config.sops.secrets.${cfg.adminPasswordSecret}.path;
|
||||
readOnly = true;
|
||||
};
|
||||
};
|
||||
|
||||
limits = {
|
||||
memory = lib.mkDefault "8G";
|
||||
cpu = lib.mkDefault "400%";
|
||||
tasksMax = lib.mkDefault 2048;
|
||||
};
|
||||
};
|
||||
|
||||
sops.secrets = {
|
||||
${cfg.passwordSecret} = {
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "0440";
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
|
||||
${cfg.adminPasswordSecret} = {
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "0440";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,11 @@ in
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"music.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = "http://10.23.20.32";
|
||||
};
|
||||
"photos.alexion.dev" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.guests.valheim; # NOLINT(let_order)
|
||||
steamDir = "/data/steamcmd";
|
||||
installDir = "/data/server";
|
||||
stateDir = "/data/state";
|
||||
in
|
||||
{
|
||||
options.guests.valheim = {
|
||||
steamPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/steamcmd";
|
||||
description = "Host path bind-mounted as SteamCMD's metadata directory.";
|
||||
};
|
||||
|
||||
installPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/valheim/server";
|
||||
description = "Host path bind-mounted as Valheim's server install directory.";
|
||||
};
|
||||
|
||||
statePath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/valheim/state";
|
||||
description = "Host path bind-mounted as Valheim's world state directory.";
|
||||
};
|
||||
|
||||
serverName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "valheim";
|
||||
description = "Name shown for the Valheim server.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 2456;
|
||||
description = "Base UDP port for the Valheim server.";
|
||||
};
|
||||
|
||||
passwordSecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "services/game-servers/password";
|
||||
description = "SOPS secret name containing the Valheim server password.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.steamPath != null;
|
||||
message = "guests.valheim.steamPath must point at the host directory that stores SteamCMD metadata.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.installPath != null;
|
||||
message = "guests.valheim.installPath must point at the host directory that stores Valheim server files.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.statePath != null;
|
||||
message = "guests.valheim.statePath must point at the host directory that stores Valheim world state.";
|
||||
}
|
||||
];
|
||||
|
||||
guests.valheim = {
|
||||
interior.modules.services.games.valheim = {
|
||||
enable = true;
|
||||
inherit (cfg) serverName port;
|
||||
inherit steamDir installDir stateDir;
|
||||
passwordFile = "/run/secrets/${cfg.passwordSecret}";
|
||||
};
|
||||
|
||||
mounts = {
|
||||
${steamDir} = {
|
||||
directory = cfg.steamPath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${installDir} = {
|
||||
directory = cfg.installPath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${stateDir} = {
|
||||
directory = cfg.statePath;
|
||||
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||
group = "storage";
|
||||
mode = "0770";
|
||||
};
|
||||
${"/run/secrets/${cfg.passwordSecret}"} = {
|
||||
file = config.sops.secrets.${cfg.passwordSecret}.path;
|
||||
readOnly = true;
|
||||
};
|
||||
};
|
||||
|
||||
limits = {
|
||||
memory = lib.mkDefault "4G";
|
||||
cpu = lib.mkDefault "400%";
|
||||
tasksMax = lib.mkDefault 1024;
|
||||
};
|
||||
};
|
||||
|
||||
sops.secrets.${cfg.passwordSecret} = {
|
||||
owner = "root";
|
||||
group = "storage";
|
||||
mode = "0440";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
inputs,
|
||||
util,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# neogaia — Dell XPS 13 9380 laptop.
|
||||
@@ -13,9 +13,10 @@
|
||||
./disk.nix
|
||||
];
|
||||
|
||||
modules.host.cachyos.enable = true;
|
||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||
|
||||
modules.network.enable = true;
|
||||
# So wifi can be joined from the console.
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
registry.address.neogaia.self = "10.23.50.146";
|
||||
|
||||
@@ -27,9 +28,15 @@
|
||||
peer = {
|
||||
publicKey = "sGEkSD+gEdIMXi4PBN3AhwHGc4tWMtcjOIey3s7yb3I=";
|
||||
endpoint = "100.0.90.192:51820";
|
||||
allowedIPs = [ "10.23.0.0/16" ];
|
||||
};
|
||||
};
|
||||
|
||||
registry.shell-shortcut.neogaia = {
|
||||
vpn-on = "sudo systemctl start wireguard-wg-home.target";
|
||||
vpn-off = ''sudo systemctl stop 'wireguard-wg-home*.service' wireguard-wg-home.target'';
|
||||
};
|
||||
|
||||
modules.monorepo.enable = true;
|
||||
modules.shell.herdr.enable = true;
|
||||
modules.shell.gitea-axi.enable = true;
|
||||
@@ -40,23 +47,4 @@
|
||||
modules.desktop.apps.obsidian.enable = true;
|
||||
modules.desktop.apps.steam.enable = true;
|
||||
|
||||
registry.waybar.neogaia.battery = {
|
||||
order = 70;
|
||||
settings = {
|
||||
format = "{icon} {capacity}%";
|
||||
format-charging = "${util.glyph "f0e7"} {capacity}%";
|
||||
format-icons = [
|
||||
(util.glyph "f244")
|
||||
(util.glyph "f243")
|
||||
(util.glyph "f242")
|
||||
(util.glyph "f241")
|
||||
(util.glyph "f240")
|
||||
];
|
||||
states = {
|
||||
warning = 20;
|
||||
critical = 10;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
_:
|
||||
{ ... }:
|
||||
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
|
||||
# partition and a LUKS container holding btrfs subvolumes.
|
||||
# No swap partition, since swap is zram.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia
|
||||
@@ -15,60 +15,86 @@
|
||||
|
||||
hardware.cpu.intel.updateMicrocode = true;
|
||||
|
||||
modules.network.vlans = {
|
||||
interface = "enp2s0";
|
||||
ids = [ 20 ];
|
||||
systemd.network = {
|
||||
enable = true;
|
||||
netdevs = {
|
||||
"40-br-vlan20".netdevConfig = {
|
||||
Name = "br-vlan20";
|
||||
Kind = "bridge";
|
||||
};
|
||||
"40-vlan20" = {
|
||||
netdevConfig = {
|
||||
Name = "vlan20";
|
||||
Kind = "vlan";
|
||||
};
|
||||
vlanConfig.Id = 20;
|
||||
};
|
||||
};
|
||||
|
||||
networks = {
|
||||
"10-uplink" = {
|
||||
matchConfig = {
|
||||
Name = "enp2s0";
|
||||
MACAddress = "78:55:36:07:af:49";
|
||||
};
|
||||
networkConfig.DHCP = "yes";
|
||||
vlan = [ "vlan20" ];
|
||||
linkConfig.RequiredForOnline = "routable";
|
||||
};
|
||||
"40-vlan20" = {
|
||||
matchConfig.Name = "vlan20";
|
||||
networkConfig.Bridge = "br-vlan20";
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
};
|
||||
"40-br-vlan20" = {
|
||||
matchConfig.Name = "br-vlan20";
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
};
|
||||
};
|
||||
};
|
||||
networking.useDHCP = false;
|
||||
|
||||
modules.network.vlans.ids = [ 20 ];
|
||||
|
||||
boot.zfs.forceImportRoot = false;
|
||||
|
||||
modules.host.zfs = {
|
||||
modules.zfs = {
|
||||
enable = true;
|
||||
hostId = "2346edbd";
|
||||
pools.pikachu = { };
|
||||
};
|
||||
|
||||
registry.address.pikachu.self = "10.23.10.102";
|
||||
|
||||
guests.valheim = {
|
||||
modules.services.steamcmd = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
address = "10.23.20.197";
|
||||
steamPath = "/pikachu/data/steamcmd";
|
||||
installPath = "/pikachu/data/servers/valheim/main";
|
||||
statePath = "/pikachu/media/games/servers/valheim/main";
|
||||
serverName = "main";
|
||||
reverseProxy = {
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "valheim.alexion.dev";
|
||||
protocol = "udp";
|
||||
ports = [
|
||||
config.guests.valheim.port
|
||||
(config.guests.valheim.port + 1)
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
guests.palworld = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
address = "10.23.20.250";
|
||||
steamPath = "/pikachu/data/steamcmd";
|
||||
installPath = "/pikachu/data/servers/palworld/tracion";
|
||||
statePath = "/pikachu/media/games/servers/palworld/tracion";
|
||||
serverName = "Tracion";
|
||||
autoStart = false;
|
||||
adminPasswordSecret = "services/game-servers/admin/password";
|
||||
publicIP = config.guests.palworld.address;
|
||||
reverseProxy = {
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "palworld.alexion.dev";
|
||||
protocol = "udp";
|
||||
ports = [ config.guests.palworld.port ];
|
||||
};
|
||||
};
|
||||
steamDir = "/pikachu/data/steamcmd";
|
||||
serviceName = "valheim";
|
||||
appId = 896660;
|
||||
installDir = "/pikachu/data/servers/valheim/main";
|
||||
stateDir = "/pikachu/media/games/servers/valheim/main";
|
||||
executable = "./valheim_server.x86_64";
|
||||
arguments = [
|
||||
"-nographics"
|
||||
"-batchmode"
|
||||
"-name"
|
||||
"main"
|
||||
"-port"
|
||||
"2456"
|
||||
"-world"
|
||||
"main"
|
||||
"-savedir"
|
||||
config.modules.services.steamcmd.stateDir
|
||||
"-password"
|
||||
"foobarbaz"
|
||||
"-public"
|
||||
"0"
|
||||
];
|
||||
udpPorts = [
|
||||
2456
|
||||
2457
|
||||
2458
|
||||
];
|
||||
};
|
||||
|
||||
guests.actualbudget = {
|
||||
@@ -81,29 +107,11 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "budget.alexion.dev";
|
||||
protocol = "https";
|
||||
port = 5006;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
guests.navidrome = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
mac = "BC:24:11:C9:17:83";
|
||||
dataPath = "/pikachu/data/navidrome";
|
||||
libraryPath = "/pikachu/media/music";
|
||||
address = "10.23.20.43";
|
||||
reverseProxy = {
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "music.alexion.dev";
|
||||
protocol = "https";
|
||||
port = 4533;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
guests.reverse-proxy = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
@@ -111,20 +119,6 @@
|
||||
address = "10.23.20.109";
|
||||
};
|
||||
|
||||
guests.homepage = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
address = "10.23.20.110";
|
||||
reverseProxy = {
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "home";
|
||||
protocol = "http";
|
||||
port = 8082;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
guests.copyparty = {
|
||||
enable = true;
|
||||
vlan = 20;
|
||||
@@ -135,22 +129,14 @@
|
||||
enable = true;
|
||||
route = {
|
||||
domain = "files.alexion.dev";
|
||||
protocol = "https";
|
||||
port = 3923;
|
||||
clientMaxBodySize = "1g";
|
||||
};
|
||||
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
|
||||
};
|
||||
volumes = {
|
||||
"/" = {
|
||||
hostPath = "/pikachu/data/copyparty/root";
|
||||
path = "/srv";
|
||||
access.r = [ "alexion" ];
|
||||
readOnly = true;
|
||||
};
|
||||
"/inbox" = {
|
||||
hostPath = "/pikachu/__inbox";
|
||||
path = "/pikachu/__inbox";
|
||||
path = "/srv/inbox";
|
||||
access.A = [ "alexion" ];
|
||||
flags = [
|
||||
"d2d"
|
||||
@@ -160,7 +146,7 @@
|
||||
};
|
||||
"/media" = {
|
||||
hostPath = "/pikachu/media";
|
||||
path = "/pikachu/media";
|
||||
path = "/srv/media";
|
||||
access.A = [ "alexion" ];
|
||||
flags = [
|
||||
"d2d"
|
||||
@@ -168,13 +154,6 @@
|
||||
"dthumb"
|
||||
];
|
||||
};
|
||||
"/opds" = {
|
||||
hostPath = "/pikachu/data/opds";
|
||||
path = "/pikachu/data/opds";
|
||||
access.r = [ "alexion" ];
|
||||
readOnly = true;
|
||||
flags = [ "opds" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
_:
|
||||
{ ... }:
|
||||
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
|
||||
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
|
||||
{
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu
|
||||
@@ -1,32 +1,25 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# zeus — Ryzen desktop with AMD graphics.
|
||||
# Disk layout is in ./disk.nix.
|
||||
# `fileSystems` are derived from it, none declared here.
|
||||
{
|
||||
imports = [
|
||||
./disk.nix
|
||||
]
|
||||
++ lib.optional (builtins.pathExists ./hardware-configuration.nix) ./hardware-configuration.nix;
|
||||
imports = [ ./disk.nix ] ++ lib.optional (builtins.pathExists ./hardware-configuration.nix) ./hardware-configuration.nix;
|
||||
|
||||
nixpkgs.hostPlatform = "x86_64-linux";
|
||||
|
||||
modules.host.cachyos.enable = true;
|
||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||
|
||||
hardware.graphics = {
|
||||
enable = true;
|
||||
enable32Bit = true;
|
||||
};
|
||||
|
||||
modules.network.enable = true;
|
||||
|
||||
services.logind.settings.Login = {
|
||||
HandlePowerKey = "ignore";
|
||||
HandlePowerKeyLongPress = "ignore";
|
||||
};
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
registry.address.zeus.self = "10.23.50.221";
|
||||
|
||||
@@ -39,29 +32,16 @@
|
||||
modules.shell.herdr.enable = true;
|
||||
modules.shell.gitea-axi.enable = true;
|
||||
modules.shell.pi.enable = true;
|
||||
modules.shell.omp.enable = true;
|
||||
|
||||
modules.desktop.enable = true;
|
||||
modules.desktop.apps.obsidian.enable = true;
|
||||
modules.desktop.apps.r2modman.enable = true;
|
||||
modules.desktop.apps.signal.enable = true;
|
||||
modules.desktop.apps.steam.enable = true;
|
||||
modules.desktop.apps.vencord.enable = true;
|
||||
|
||||
home-manager.users.${config.user.name}.wayland.windowManager.hyprland.settings.monitor = [
|
||||
{
|
||||
output = "HDMI-A-1";
|
||||
mode = "1920x1080@120";
|
||||
position = "-1080x0";
|
||||
scale = 1;
|
||||
transform = 3;
|
||||
}
|
||||
{
|
||||
output = "DP-3";
|
||||
mode = "2560x1440@239.96";
|
||||
position = "0x0";
|
||||
scale = 1;
|
||||
}
|
||||
"HDMI-A-1,1920x1080@120,0x0,1,transform,3"
|
||||
"DP-3,2560x1440@239.96,1080x0,1"
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
_:
|
||||
{ ... }:
|
||||
# zeus's disk layout for disko: two NVMe disks with one EFI system partition
|
||||
# and two LUKS containers joined into one btrfs filesystem.
|
||||
# No swap partition, since swap is zram.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
# The shared foundation for every host and guest.
|
||||
let
|
||||
stateVersion = "26.05";
|
||||
in
|
||||
{
|
||||
imports = [ inputs.home-manager.nixosModules.home-manager ];
|
||||
|
||||
@@ -13,14 +17,14 @@
|
||||
type = lib.types.str;
|
||||
default = "alexion";
|
||||
description = ''
|
||||
Names the primary user account.
|
||||
Host configuration uses this name for the Home Manager user.
|
||||
Primary interactive user for this system.
|
||||
This drives the system account and Home Manager user together.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
system.stateVersion = "26.05";
|
||||
system.stateVersion = stateVersion;
|
||||
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
nix.settings.experimental-features = [
|
||||
@@ -40,5 +44,56 @@
|
||||
|
||||
time.timeZone = "America/New_York";
|
||||
i18n.defaultLocale = "en_GB.UTF-8";
|
||||
|
||||
home-manager = {
|
||||
useGlobalPkgs = true;
|
||||
useUserPackages = true;
|
||||
users.${config.user.name} = {
|
||||
home.username = config.user.name;
|
||||
home.homeDirectory = "/home/${config.user.name}";
|
||||
home.stateVersion = stateVersion;
|
||||
home.packages = with pkgs; [
|
||||
eza
|
||||
wget
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
registry.shell-shortcut.${config.networking.hostName} = {
|
||||
ls = {
|
||||
command = "eza -alg --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
ll = {
|
||||
command = "eza -lg --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
lt = {
|
||||
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
".." = "cd ..";
|
||||
"..." = "cd ../..";
|
||||
"...." = "cd ../../..";
|
||||
|
||||
cp = "cp -v";
|
||||
tarnow = "tar -acf ";
|
||||
untar = "tar -zxvf ";
|
||||
wget = "wget -c ";
|
||||
grep = "grep --color=auto";
|
||||
fgrep = "fgrep --color=auto";
|
||||
egrep = "egrep --color=auto";
|
||||
jctl = "journalctl -p 3 -xb";
|
||||
|
||||
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
|
||||
cleanup = "sudo nix-collect-garbage -d";
|
||||
};
|
||||
|
||||
modules.shell.fish.enable = lib.mkDefault true;
|
||||
modules.shell.fish.defaultShell = lib.mkDefault true;
|
||||
modules.shell.tmux.enable = lib.mkDefault true;
|
||||
modules.shell.nvim.enable = lib.mkDefault true;
|
||||
modules.shell.git.enable = lib.mkDefault true;
|
||||
modules.shell.yazi.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
|
||||
18
configs/nixos/modules/desktop/apps/alacritty.nix
Normal file
18
configs/nixos/modules/desktop/apps/alacritty.nix
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Alacritty as the desktop terminal.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.alacritty;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.alacritty.enable = lib.mkEnableOption "Alacritty as the desktop terminal";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.alacritty ];
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,13 @@
|
||||
inputs,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Configures Firefox as the desktop browser.
|
||||
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.firefox; # NOLINT(let_order)
|
||||
cfg = config.modules.desktop.apps.firefox;
|
||||
user = config.user.name;
|
||||
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.firefox = {
|
||||
@@ -18,25 +19,19 @@ in
|
||||
type = lib.types.str;
|
||||
default = "1.0";
|
||||
description = ''
|
||||
Scales Firefox UI and page content.
|
||||
|
||||
Sets `layout.css.devPixelsPerPx`.
|
||||
Use a larger value for high-DPI displays.
|
||||
Firefox UI and page scale, passed to `layout.css.devPixelsPerPx`.
|
||||
Override it on hosts that need a larger browser scale, such as small
|
||||
high-DPI laptop panels.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
registry.window-icon.${config.networking.hostName}.firefox = {
|
||||
class = ".*[Ff]irefox.*";
|
||||
icon = util.glyph "f269";
|
||||
};
|
||||
|
||||
home-manager.users.${config.user.name} = hm: {
|
||||
home-manager.users.${user} = hm: {
|
||||
programs.firefox = {
|
||||
enable = true;
|
||||
|
||||
# Applies policies that users cannot change in Firefox.
|
||||
# Enterprise policies: enforced and unchangeable from the browser UI.
|
||||
policies = {
|
||||
DisableTelemetry = true;
|
||||
DisableFirefoxStudies = true;
|
||||
@@ -45,7 +40,7 @@ in
|
||||
DontCheckDefaultBrowser = true;
|
||||
DisableFirefoxAccounts = true;
|
||||
|
||||
# Removes sponsored content from the new-tab page.
|
||||
# Strip the monetized surfaces from the new-tab page.
|
||||
FirefoxHome = {
|
||||
SponsoredTopSites = false;
|
||||
SponsoredPocket = false;
|
||||
@@ -54,19 +49,23 @@ in
|
||||
};
|
||||
|
||||
profiles.default = {
|
||||
isDefault = true;
|
||||
|
||||
extensions = {
|
||||
packages = with inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system}; [
|
||||
packages = with firefoxAddons; [
|
||||
ublock-origin
|
||||
proton-pass
|
||||
sponsorblock
|
||||
];
|
||||
|
||||
# Home Manager overwrites Nord theme changes made in Firefox.
|
||||
# The Nord chrome theme is a declared extension setting, so home-manager
|
||||
# owns the extension-settings store, overwriting runtime changes to it.
|
||||
force = true;
|
||||
};
|
||||
|
||||
# Stylix uses a near-white selection background.
|
||||
# Use the Nord selection color because its text stays readable.
|
||||
# Stylix's Nord mapping paints the selected address-bar result a
|
||||
# near-white grey, leaving its light text unreadable. Darken that one
|
||||
# highlight to the Nord selection grey, from the same scheme.
|
||||
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
|
||||
let
|
||||
c = hm.config.lib.stylix.colors;
|
||||
@@ -78,26 +77,32 @@ in
|
||||
};
|
||||
|
||||
settings = {
|
||||
# Scale the UI and page by a fixed factor.
|
||||
# Left at auto (-1), Firefox reads the panel's 1.5x and inflates its
|
||||
# whole chrome while point-sized apps stay put.
|
||||
# A shade under that brings it into line without dropping to true
|
||||
# 1:1, which reads too small at this DPI.
|
||||
"layout.css.devPixelsPerPx" = cfg.uiScale;
|
||||
|
||||
# Enables Firefox Color after Firefox sideloads the add-on.
|
||||
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
|
||||
# chrome theme, which Firefox otherwise leaves disabled.
|
||||
"extensions.autoDisableScopes" = 0;
|
||||
|
||||
# Removes sponsored content outside Firefox policies.
|
||||
# Sponsored surfaces the policies above do not reach.
|
||||
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
|
||||
"browser.newtabpage.activity-stream.showSponsored" = false;
|
||||
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
|
||||
};
|
||||
|
||||
search = {
|
||||
# Home Manager replaces the engine list when this setting is enabled.
|
||||
# Engines added in Firefox do not survive a rebuild.
|
||||
# Declaring search overwrites Firefox's own engine list wholesale,
|
||||
# so engines added later in the UI do not survive a rebuild.
|
||||
force = true;
|
||||
default = "ddg";
|
||||
|
||||
# Hides commercial search engines.
|
||||
# Bangs still reach these engines.
|
||||
# Entries with only `metaData` configure Firefox built-in engines.
|
||||
# The general-purpose commercial engines, hidden to leave a lean
|
||||
# DuckDuckGo-and-Wikipedia list. They stay reachable through bangs.
|
||||
# An engine carrying only metaData is treated as a builtin.
|
||||
engines = {
|
||||
google.metaData.hidden = true;
|
||||
bing.metaData.hidden = true;
|
||||
@@ -109,7 +114,8 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Applies the Nord theme through Stylix's Firefox Color add-on.
|
||||
# Nord chrome for the one profile, applied through the Stylix-managed
|
||||
# Firefox Color add-on that colorTheme enables.
|
||||
stylix.targets.firefox = {
|
||||
enable = true;
|
||||
profileNames = [ "default" ];
|
||||
@@ -127,8 +133,8 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Firefox creates `profiles.ini` on its first launch.
|
||||
# Force replacement during Home Manager activation.
|
||||
# Firefox writes profiles.ini itself on first launch, so home-manager is
|
||||
# told to own the file rather than fail activation refusing to clobber it.
|
||||
home.file."${hm.config.programs.firefox.configPath}/profiles.ini".force = true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,30 +2,17 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Ghostty as the desktop terminal.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.ghostty;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.apps.ghostty.enable {
|
||||
registry.keybind.${config.networking.hostName}.ghostty = {
|
||||
keys = "SUPER + Return";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = lib.getExe pkgs.ghostty;
|
||||
};
|
||||
};
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.ghostty = {
|
||||
class = "com.mitchellh.ghostty";
|
||||
icon = util.glyph "f120";
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Obsidian as the desktop note-taking vault.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.obsidian;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.obsidian.enable =
|
||||
lib.mkEnableOption "Obsidian as the desktop note-taking vault";
|
||||
options.modules.desktop.apps.obsidian.enable = lib.mkEnableOption "Obsidian as the desktop note-taking vault";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.apps.obsidian.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.obsidian = {
|
||||
class = "obsidian";
|
||||
icon = util.glyph "f02d";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# r2modman, a Thunderstore mod manager.
|
||||
let
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.r2modman.enable =
|
||||
lib.mkEnableOption "r2modman, a Thunderstore mod manager";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.apps.r2modman.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.r2modman ];
|
||||
};
|
||||
}
|
||||
@@ -2,22 +2,17 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Signal desktop messenger.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.signal;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.signal.enable = lib.mkEnableOption "Signal desktop messenger";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.apps.signal.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.signal-desktop ];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.signal = {
|
||||
class = "[Ss]ignal";
|
||||
icon = util.glyph "f4ad";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Steam game launcher and runtime integration.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.steam;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.apps.steam.enable {
|
||||
programs.gamemode.enable = true;
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
programs.steam = {
|
||||
enable = true;
|
||||
package = pkgs.steam.override {
|
||||
@@ -20,14 +20,5 @@
|
||||
};
|
||||
|
||||
hardware.steam-hardware.enable = true;
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.gamescope
|
||||
];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.steam = {
|
||||
class = "[Ss]team";
|
||||
icon = util.glyph "f1b6";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Vesktop, a Discord client with Vencord built in.
|
||||
let
|
||||
cfg = config.modules.desktop.apps.vencord;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.apps.vencord.enable =
|
||||
lib.mkEnableOption "Vesktop, a Discord client with Vencord built in";
|
||||
options.modules.desktop.apps.vencord.enable = lib.mkEnableOption "Vesktop, a Discord client with Vencord built in";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.apps.vencord.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.vesktop ];
|
||||
|
||||
registry.window-icon.${config.networking.hostName}.vesktop = {
|
||||
class = "[Vv]esktop";
|
||||
icon = util.glyph "f1ff";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{ config, lib, ... }:
|
||||
# The desktop aggregator: one flag brings up the whole graphical session.
|
||||
let
|
||||
cfg = config.modules.desktop;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
|
||||
|
||||
# Each piece is turned on at default priority, so a host can still override
|
||||
# any one of them while the single flag above enables the whole desktop.
|
||||
config = lib.mkIf config.modules.desktop.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
modules.desktop.apps.firefox.enable = lib.mkDefault true;
|
||||
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
|
||||
modules.desktop.session.hyprland.enable = lib.mkDefault true;
|
||||
@@ -23,6 +26,5 @@
|
||||
modules.desktop.utilities.screenshot.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.theming.enable = lib.mkDefault true;
|
||||
modules.desktop.utilities.userdirs.enable = lib.mkDefault true;
|
||||
registry.env.${config.networking.hostName}.NIXOS_OZONE_WL = "1";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
util,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# The Hyprland compositor.
|
||||
let
|
||||
cfg = config.modules.desktop.session.hyprland;
|
||||
user = config.user.name;
|
||||
cursor = config.stylix.cursor;
|
||||
|
||||
# Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory.
|
||||
workspaceBinds = lib.concatMap (n: [
|
||||
"$mod, ${toString n}, workspace, ${toString n}"
|
||||
"$mod SHIFT, ${toString n}, movetoworkspace, ${toString n}"
|
||||
]) (lib.range 1 9);
|
||||
|
||||
# Flip the tiling strategy between the two built-in layouts, since neither a
|
||||
# dispatcher nor a keyword toggles it on its own.
|
||||
toggleLayout = pkgs.writeShellScript "hypr-toggle-layout" ''
|
||||
if [ "$(hyprctl getoption -j general:layout | ${pkgs.jq}/bin/jq -r .str)" = dwindle ]; then
|
||||
hyprctl keyword general:layout master
|
||||
else
|
||||
hyprctl keyword general:layout dwindle
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.hyprland.enable = lib.mkEnableOption "the Hyprland compositor";
|
||||
options.modules.desktop.session.hyprland = {
|
||||
enable = lib.mkEnableOption "the Hyprland compositor";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.hyprland.enable {
|
||||
blur = lib.mkEnableOption ''
|
||||
window blur. Off by default as the single biggest battery cost on a
|
||||
laptop, left on for a host with the headroom to spend it'';
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# This program integration owns the session, portals, and polkit, launched
|
||||
# through the universal Wayland session manager.
|
||||
programs.hyprland = {
|
||||
@@ -17,7 +43,7 @@
|
||||
withUWSM = true;
|
||||
};
|
||||
|
||||
home-manager.users.${config.user.name}.wayland.windowManager.hyprland = {
|
||||
home-manager.users.${user}.wayland.windowManager.hyprland = {
|
||||
enable = true;
|
||||
|
||||
# One package drives the whole session, so there is never a version split.
|
||||
@@ -29,216 +55,136 @@
|
||||
# uwsm owns the systemd graphical-session targets.
|
||||
systemd.enable = false;
|
||||
|
||||
# Write the native Lua Hyprland config. Home Manager renders the Nix
|
||||
# settings below as hl.* calls.
|
||||
configType = "lua";
|
||||
# Write the native hyprlang hyprland.conf, whose variable and bind syntax
|
||||
# the settings below are expressed in.
|
||||
configType = "hyprlang";
|
||||
|
||||
settings = {
|
||||
config = {
|
||||
input = {
|
||||
kb_layout = config.services.xserver.xkb.layout;
|
||||
repeat_delay = 250;
|
||||
repeat_rate = 45;
|
||||
accel_profile = "flat";
|
||||
touchpad = {
|
||||
natural_scroll = true;
|
||||
tap_to_click = true;
|
||||
disable_while_typing = true;
|
||||
};
|
||||
};
|
||||
"$mod" = "SUPER";
|
||||
"$terminal" = "ghostty";
|
||||
|
||||
general = {
|
||||
gaps_in = 4;
|
||||
gaps_out = 8;
|
||||
border_size = 2;
|
||||
layout = "dwindle";
|
||||
};
|
||||
|
||||
decoration = {
|
||||
rounding = 6;
|
||||
blur.enabled = false;
|
||||
};
|
||||
|
||||
xwayland.force_zero_scaling = true;
|
||||
|
||||
dwindle.preserve_split = true;
|
||||
};
|
||||
|
||||
env = lib.mapAttrsToList (name: value: {
|
||||
_args = [
|
||||
name
|
||||
value
|
||||
];
|
||||
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.env);
|
||||
|
||||
bind = lib.mapAttrsToList (_name: keybind: {
|
||||
_args = [
|
||||
keybind.keys
|
||||
(lib.generators.mkLuaInline (
|
||||
{
|
||||
exec = "hl.dsp.exec_cmd(${lib.generators.toLua { } keybind.action.command})";
|
||||
focus = "hl.dsp.focus(${
|
||||
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
|
||||
})";
|
||||
"move-window" = "hl.dsp.window.move(${
|
||||
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
|
||||
})";
|
||||
"resize-window" = "hl.dsp.window.resize(${
|
||||
lib.generators.toLua { } (
|
||||
{
|
||||
down = {
|
||||
x = 0;
|
||||
y = 10;
|
||||
relative = true;
|
||||
};
|
||||
left = {
|
||||
x = -10;
|
||||
y = 0;
|
||||
relative = true;
|
||||
};
|
||||
right = {
|
||||
x = 10;
|
||||
y = 0;
|
||||
relative = true;
|
||||
};
|
||||
up = {
|
||||
x = 0;
|
||||
y = -10;
|
||||
relative = true;
|
||||
};
|
||||
}
|
||||
.${keybind.action.direction}
|
||||
)
|
||||
})";
|
||||
"toggle-floating" = ''hl.dsp.window.float({ action = "toggle" })'';
|
||||
"toggle-fullscreen" = "hl.dsp.window.fullscreen()";
|
||||
"close-window" = "hl.dsp.window.close()";
|
||||
}
|
||||
.${keybind.action.kind}
|
||||
))
|
||||
]
|
||||
++ lib.optional keybind.repeating {
|
||||
repeating = true;
|
||||
}
|
||||
++ lib.optional keybind.locked {
|
||||
locked = true;
|
||||
};
|
||||
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.keybind);
|
||||
};
|
||||
};
|
||||
registry.keybind.${config.networking.hostName} = lib.mkMerge [
|
||||
# Generate numeric workspace keybinds.
|
||||
(lib.listToAttrs (
|
||||
lib.concatMap (workspace: [
|
||||
{
|
||||
name = "hyprland-workspace-${toString workspace}";
|
||||
value = {
|
||||
keys = "SUPER + ${toString workspace}";
|
||||
action = {
|
||||
kind = "focus";
|
||||
inherit workspace;
|
||||
};
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "hyprland-move-to-workspace-${toString workspace}";
|
||||
value = {
|
||||
keys = "SUPER + SHIFT + ${toString workspace}";
|
||||
action = {
|
||||
kind = "move-window";
|
||||
inherit workspace;
|
||||
};
|
||||
};
|
||||
}
|
||||
]) (lib.range 1 9)
|
||||
))
|
||||
|
||||
# Generate directional keybinds.
|
||||
(lib.listToAttrs (
|
||||
lib.concatMap
|
||||
(
|
||||
{ direction, key }:
|
||||
[
|
||||
{
|
||||
name = "hyprland-focus-${direction}";
|
||||
value = {
|
||||
keys = "SUPER + ${key}";
|
||||
action = {
|
||||
kind = "focus";
|
||||
inherit direction;
|
||||
};
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "hyprland-move-${direction}";
|
||||
value = {
|
||||
keys = "SUPER + SHIFT + ${key}";
|
||||
action = {
|
||||
kind = "move-window";
|
||||
inherit direction;
|
||||
};
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "hyprland-resize-${direction}";
|
||||
value = {
|
||||
keys = "SUPER + ALT + ${key}";
|
||||
repeating = true;
|
||||
action = {
|
||||
kind = "resize-window";
|
||||
inherit direction;
|
||||
};
|
||||
};
|
||||
}
|
||||
]
|
||||
)
|
||||
# Session variables handed to the compositor directly.
|
||||
# UWSM launches the session without the shell profile that would carry
|
||||
# them, so a variable the compositor or its children must see is set
|
||||
# here rather than through home-manager's sessionVariables.
|
||||
env =
|
||||
[
|
||||
{
|
||||
key = "H";
|
||||
direction = "left";
|
||||
}
|
||||
{
|
||||
key = "J";
|
||||
direction = "down";
|
||||
}
|
||||
{
|
||||
key = "K";
|
||||
direction = "up";
|
||||
}
|
||||
{
|
||||
key = "L";
|
||||
direction = "right";
|
||||
}
|
||||
# Chromium and Electron apps read this to select native Wayland;
|
||||
# nixpkgs wrappers (Obsidian's included) gate their Wayland flags on
|
||||
# it, so without it they run under XWayland and blur at this DPI.
|
||||
"NIXOS_OZONE_WL,1"
|
||||
]
|
||||
))
|
||||
{
|
||||
hyprland-toggle-floating = {
|
||||
keys = "SUPER + Space";
|
||||
action.kind = "toggle-floating";
|
||||
# Bibata ships XCursor only.
|
||||
# The hyprcursor variables name the same theme, which Hyprland
|
||||
# resolves through its XCursor fallback.
|
||||
++ lib.optionals (cursor != null) [
|
||||
"XCURSOR_THEME,${cursor.name}"
|
||||
"XCURSOR_SIZE,${toString cursor.size}"
|
||||
"HYPRCURSOR_THEME,${cursor.name}"
|
||||
"HYPRCURSOR_SIZE,${toString cursor.size}"
|
||||
];
|
||||
|
||||
input = {
|
||||
kb_layout = "us";
|
||||
# Caps is a second Escape.
|
||||
# Shift+Caps still toggles a real CapsLock.
|
||||
kb_options = "caps:escape_shifted_capslock";
|
||||
# Snappy key repeat.
|
||||
repeat_delay = 250;
|
||||
repeat_rate = 45;
|
||||
accel_profile = "flat";
|
||||
touchpad = {
|
||||
natural_scroll = true;
|
||||
tap-to-click = true;
|
||||
disable_while_typing = true;
|
||||
};
|
||||
};
|
||||
|
||||
hyprland-toggle-fullscreen = {
|
||||
keys = "SUPER + F";
|
||||
action.kind = "toggle-fullscreen";
|
||||
general = {
|
||||
gaps_in = 4;
|
||||
gaps_out = 8;
|
||||
border_size = 2;
|
||||
layout = "dwindle";
|
||||
};
|
||||
|
||||
hyprland-close-window = {
|
||||
keys = "SUPER + SHIFT + Q";
|
||||
action.kind = "close-window";
|
||||
decoration = {
|
||||
rounding = 6;
|
||||
blur.enabled = cfg.blur;
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
registry.waybar.${config.networking.hostName}."hyprland/workspaces" = {
|
||||
position = "left";
|
||||
order = 10;
|
||||
settings = {
|
||||
format = "{id} {windows}";
|
||||
format-window-separator = " ";
|
||||
window-rewrite-default = util.glyph "f2d0";
|
||||
window-rewrite = lib.mapAttrs' (
|
||||
_name: windowIcon: lib.nameValuePair "class<${windowIcon.class}>" windowIcon.icon
|
||||
) (lib.attrByPath [ config.networking.hostName ] { } config.registry.window-icon);
|
||||
# XWayland clients render at the panel's native resolution instead of
|
||||
# being raster-scaled by the compositor at the fractional monitor scale.
|
||||
xwayland.force_zero_scaling = true;
|
||||
|
||||
animations = {
|
||||
enabled = true;
|
||||
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
|
||||
# Durations are in centiseconds.
|
||||
# Short values keep the motion subtle.
|
||||
animation = [
|
||||
"windows, 1, 3, ease"
|
||||
"fade, 1, 3, ease"
|
||||
# Layer surfaces like the launcher fade in a touch quicker than windows.
|
||||
"layersIn, 1, 2, ease"
|
||||
"fadeLayersIn, 1, 2, ease"
|
||||
"workspaces, 1, 3, ease"
|
||||
"border, 1, 3, ease"
|
||||
];
|
||||
};
|
||||
|
||||
dwindle = {
|
||||
preserve_split = true;
|
||||
};
|
||||
|
||||
bind = [
|
||||
"$mod, Return, exec, $terminal"
|
||||
|
||||
# Move focus.
|
||||
"$mod, H, movefocus, l"
|
||||
"$mod, J, movefocus, d"
|
||||
"$mod, K, movefocus, u"
|
||||
"$mod, L, movefocus, r"
|
||||
|
||||
# Move the window within the layout.
|
||||
"$mod SHIFT, H, movewindow, l"
|
||||
"$mod SHIFT, J, movewindow, d"
|
||||
"$mod SHIFT, K, movewindow, u"
|
||||
"$mod SHIFT, L, movewindow, r"
|
||||
|
||||
# Resize the active window.
|
||||
"$mod ALT, H, resizeactive, -40 0"
|
||||
"$mod ALT, J, resizeactive, 0 40"
|
||||
"$mod ALT, K, resizeactive, 0 -40"
|
||||
"$mod ALT, L, resizeactive, 40 0"
|
||||
|
||||
"$mod, Space, togglefloating,"
|
||||
"$mod, F, fullscreen,"
|
||||
# togglesplit is a dwindle layout message, reached through layoutmsg.
|
||||
"$mod, T, layoutmsg, togglesplit"
|
||||
"$mod SHIFT, T, exec, ${toggleLayout}"
|
||||
"$mod SHIFT, Q, killactive,"
|
||||
"$mod CTRL, Q, forcekillactive,"
|
||||
]
|
||||
++ workspaceBinds;
|
||||
|
||||
# Volume and brightness keys repeat while held, each raising a popup
|
||||
# through the OSD client.
|
||||
# Volume is capped at 100 percent.
|
||||
# The client floors brightness so a full hold cannot black the screen out.
|
||||
binde = [
|
||||
", XF86AudioRaiseVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100"
|
||||
", XF86AudioLowerVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume lower"
|
||||
", XF86MonBrightnessUp, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness raise"
|
||||
", XF86MonBrightnessDown, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness lower"
|
||||
];
|
||||
|
||||
# Mute and media transport still fire while the session is locked.
|
||||
bindl = [
|
||||
", XF86AudioMute, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle"
|
||||
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
|
||||
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
|
||||
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,34 +5,62 @@
|
||||
...
|
||||
}:
|
||||
# hypridle: idle-triggered locking and display power management.
|
||||
{
|
||||
options.modules.desktop.session.hyprland.hypridle.enable =
|
||||
lib.mkEnableOption "hypridle idle management";
|
||||
let
|
||||
cfg = config.modules.desktop.session.hyprland.hypridle;
|
||||
user = config.user.name;
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.hyprland.hypridle.enable {
|
||||
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
|
||||
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
|
||||
|
||||
# The guard drops the call when a locker is already up, so no idle trigger
|
||||
# stacks a second hyprlock over the first.
|
||||
lockCmd = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}";
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.hyprland.hypridle = {
|
||||
enable = lib.mkEnableOption "hypridle idle management";
|
||||
|
||||
lockTimeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 300;
|
||||
description = "Seconds of inactivity before the screen locks.";
|
||||
};
|
||||
|
||||
screenOffTimeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 360;
|
||||
description = "Seconds of inactivity before the displays are powered off.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Closing the lid suspends, and every suspend locks first through the
|
||||
# before_sleep_cmd below, so the lid always lands at a locked screen.
|
||||
services.logind.settings.Login.HandleLidSwitch = "suspend";
|
||||
|
||||
home-manager.users.${config.user.name}.services.hypridle = {
|
||||
home-manager.users.${user}.services.hypridle = {
|
||||
enable = true;
|
||||
|
||||
settings = {
|
||||
general = {
|
||||
# The guard drops the call when a locker is already up, so no idle
|
||||
# trigger stacks a second hyprlock over the first.
|
||||
lock_cmd = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
|
||||
lock_cmd = lockCmd;
|
||||
before_sleep_cmd = "loginctl lock-session";
|
||||
# Waking pokes the compositor in case firmware leaves displays dark.
|
||||
after_sleep_cmd = "${lib.getExe' config.programs.hyprland.package "hyprctl"} dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
|
||||
# Waking restores the displays the screen-off listener may have cut.
|
||||
after_sleep_cmd = "${hyprctl} dispatch dpms on";
|
||||
};
|
||||
|
||||
listener = [
|
||||
# Lock on idle.
|
||||
{
|
||||
timeout = 300;
|
||||
timeout = cfg.lockTimeout;
|
||||
on-timeout = "loginctl lock-session";
|
||||
}
|
||||
# Power the displays off a little later, restoring them on any activity.
|
||||
{
|
||||
timeout = cfg.screenOffTimeout;
|
||||
on-timeout = "${hyprctl} dispatch dpms off";
|
||||
on-resume = "${hyprctl} dispatch dpms on";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,63 +2,53 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# The lock screen: a hyprlock session-lock surface the compositor owns.
|
||||
let
|
||||
sleepIcon = util.glyph [
|
||||
"db81"
|
||||
"dcb2"
|
||||
];
|
||||
sleepOffIcon = util.glyph [
|
||||
"db81"
|
||||
"dcb3"
|
||||
];
|
||||
cfg = config.modules.desktop.session.hyprland.hyprlock;
|
||||
user = config.user.name;
|
||||
|
||||
# The hyprlock this module installs, used by the lock keybind below.
|
||||
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
|
||||
|
||||
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
|
||||
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
|
||||
|
||||
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
|
||||
unit=lockscreen-idle-inhibit.service
|
||||
|
||||
refresh_waybar() {
|
||||
${lib.getExe' pkgs.procps "pkill"} -RTMIN+9 waybar >/dev/null 2>&1 || true
|
||||
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
|
||||
${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
|
||||
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
|
||||
${pkgs.systemd}/bin/systemctl --user stop "$unit"
|
||||
refresh_waybar
|
||||
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ${lib.getExe' pkgs.systemd "systemd-run"} \
|
||||
if ${pkgs.systemd}/bin/systemd-run \
|
||||
--user \
|
||||
--collect \
|
||||
--unit=lockscreen-idle-inhibit \
|
||||
--description="Disable idle-triggered screen locking" \
|
||||
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
|
||||
${pkgs.systemd}/bin/systemd-inhibit \
|
||||
--what=idle \
|
||||
--who=lockscreen-hotkey \
|
||||
--why="lock screen disabled by hotkey" \
|
||||
${lib.getExe' pkgs.coreutils "sleep"} infinity >/dev/null; then
|
||||
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
|
||||
refresh_waybar
|
||||
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepOffIcon}"
|
||||
fi
|
||||
'';
|
||||
|
||||
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
|
||||
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet lockscreen-idle-inhibit.service; then
|
||||
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
|
||||
else
|
||||
printf '{"text":"","class":"idle"}\n'
|
||||
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.hyprland.hyprlock.enable =
|
||||
lib.mkEnableOption "the hyprlock lock screen";
|
||||
options.modules.desktop.session.hyprland.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
|
||||
home-manager.users.${config.user.name} = {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# Colors and the lock-screen background come from Stylix's hyprlock target,
|
||||
# so only geometry and behaviour are set here.
|
||||
programs.hyprlock = {
|
||||
@@ -91,36 +81,14 @@ in
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
registry.keybind.${config.networking.hostName} = {
|
||||
hyprlock-lock = {
|
||||
keys = "SUPER + X";
|
||||
action = {
|
||||
kind = "exec";
|
||||
# The guard drops the call when a locker is already up, so no manual
|
||||
# trigger stacks a second hyprlock over the first.
|
||||
command = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
|
||||
};
|
||||
};
|
||||
|
||||
hyprlock-toggle-idle-inhibit = {
|
||||
keys = "SUPER + CTRL + X";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${idleLockToggle}";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
registry.waybar.${config.networking.hostName}."custom/idle-lock" = {
|
||||
order = 20;
|
||||
settings = {
|
||||
return-type = "json";
|
||||
exec = "${idleLockStatus}";
|
||||
interval = "once";
|
||||
signal = 9;
|
||||
};
|
||||
# Lock on Super+X.
|
||||
# The guard drops the keypress when a locker is already up, so a second
|
||||
# hyprlock never stacks over the first.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"
|
||||
"$mod CTRL, X, exec, ${idleLockToggle}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,20 +6,18 @@
|
||||
}:
|
||||
# Text login: greetd running the tuigreet greeter, which starts the session
|
||||
# through the universal Wayland session manager.
|
||||
let
|
||||
cfg = config.modules.desktop.session.login;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.login.enable =
|
||||
lib.mkEnableOption "greetd with the tuigreet text greeter";
|
||||
options.modules.desktop.session.login.enable = lib.mkEnableOption "greetd with the tuigreet text greeter";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.login.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.greetd = {
|
||||
enable = true;
|
||||
settings.default_session.command = lib.escapeShellArgs [
|
||||
(lib.getExe pkgs.tuigreet)
|
||||
"--time"
|
||||
"--remember"
|
||||
"--cmd"
|
||||
"${lib.getExe config.programs.uwsm.package} start -e -D Hyprland hyprland.desktop"
|
||||
];
|
||||
settings.default_session.command =
|
||||
"${lib.getExe pkgs.tuigreet} --time --remember "
|
||||
+ "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,29 +2,20 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
|
||||
let
|
||||
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
|
||||
if ${lib.getExe' pkgs.mako "makoctl"} mode 2>/dev/null | ${lib.getExe pkgs.gnugrep} -qx dnd; then
|
||||
printf '{"text":"${util.glyph "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
|
||||
else
|
||||
printf '{"text":"${util.glyph "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
|
||||
fi
|
||||
'';
|
||||
cfg = config.modules.desktop.session.mako;
|
||||
user = config.user.name;
|
||||
|
||||
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
|
||||
${lib.getExe' pkgs.mako "makoctl"} mode -t dnd >/dev/null 2>&1
|
||||
${lib.getExe' pkgs.procps "pkill"} -RTMIN+8 waybar
|
||||
'';
|
||||
makoctl = "${pkgs.mako}/bin/makoctl";
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.mako.enable {
|
||||
home-manager.users.${config.user.name} = {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# Colors and the popup font come from Stylix's mako target, so only
|
||||
# behaviour is set here.
|
||||
services.mako = {
|
||||
@@ -41,25 +32,13 @@ in
|
||||
"mode=dnd".invisible = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
registry.keybind.${config.networking.hostName}.mako-history = {
|
||||
keys = "SUPER + N";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${lib.getExe' pkgs.mako "makoctl"} restore";
|
||||
};
|
||||
};
|
||||
|
||||
registry.waybar.${config.networking.hostName}."custom/dnd" = {
|
||||
order = 80;
|
||||
settings = {
|
||||
return-type = "json";
|
||||
exec = "${dndStatus}";
|
||||
on-click = "${dndToggle}";
|
||||
interval = "once";
|
||||
signal = 8;
|
||||
};
|
||||
# Recall the last notification from history, keyboard-only like the rest
|
||||
# of the session.
|
||||
# $mod is defined by the compositor config these binds share.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod, N, exec, ${makoctl} restore"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{ config, lib, ... }:
|
||||
# XDG desktop portal routing for in-app screen sharing and file dialogs.
|
||||
let
|
||||
cfg = config.modules.desktop.session.portals;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.portals.enable =
|
||||
lib.mkEnableOption "XDG desktop portals for in-app screen sharing and file dialogs";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.portals.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
# The backend packages arrive with the compositor, so only the routing is set
|
||||
# here.
|
||||
# Screen sharing, screenshots, and global shortcuts need the compositor's
|
||||
|
||||
@@ -6,29 +6,31 @@
|
||||
}:
|
||||
# rofi as the desktop launcher and dmenu-style menu frontend.
|
||||
let
|
||||
cfg = config.modules.desktop.session.rofi;
|
||||
user = config.user.name;
|
||||
|
||||
# A session menu built on the same themed rofi, so the power actions look
|
||||
# like every other menu the launcher drives.
|
||||
powerMenu = pkgs.writeShellScript "rofi-power-menu" ''
|
||||
chosen=$(printf '%s\n' Lock Suspend 'Log out' Reboot 'Shut down' \
|
||||
| ${lib.getExe pkgs.rofi} -dmenu -i -p Power)
|
||||
| ${pkgs.rofi}/bin/rofi -dmenu -i -p Power)
|
||||
case "$chosen" in
|
||||
Lock) ${lib.getExe' pkgs.systemd "loginctl"} lock-session ;;
|
||||
Suspend) ${lib.getExe' pkgs.systemd "systemctl"} suspend ;;
|
||||
'Log out') ${lib.getExe' pkgs.systemd "loginctl"} terminate-session "$XDG_SESSION_ID" ;;
|
||||
Reboot) ${lib.getExe' pkgs.systemd "systemctl"} reboot ;;
|
||||
'Shut down') ${lib.getExe' pkgs.systemd "systemctl"} poweroff ;;
|
||||
Lock) ${pkgs.systemd}/bin/loginctl lock-session ;;
|
||||
Suspend) ${pkgs.systemd}/bin/systemctl suspend ;;
|
||||
'Log out') ${pkgs.systemd}/bin/loginctl terminate-session "$XDG_SESSION_ID" ;;
|
||||
Reboot) ${pkgs.systemd}/bin/systemctl reboot ;;
|
||||
'Shut down') ${pkgs.systemd}/bin/systemctl poweroff ;;
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.rofi.enable {
|
||||
home-manager.users.${config.user.name} = hm: {
|
||||
# Stylix supplies the Nord theme.
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# No theme is set here: Stylix's rofi target supplies the Nord one.
|
||||
programs.rofi = {
|
||||
enable = true;
|
||||
font = lib.mkForce "JetBrains Mono 12";
|
||||
|
||||
# Application launch is the only mode, kept minimal so the prompt opens
|
||||
# as fast as rofi can read the desktop entries.
|
||||
@@ -37,31 +39,14 @@ in
|
||||
modi = "drun";
|
||||
show-icons = false;
|
||||
};
|
||||
|
||||
theme.window = {
|
||||
border = hm.config.lib.formats.rasi.mkLiteral "2px";
|
||||
border-color = hm.config.lib.formats.rasi.mkLiteral "#FFFFFF";
|
||||
border-radius = hm.config.lib.formats.rasi.mkLiteral "8px";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
registry.keybind.${config.networking.hostName} = {
|
||||
rofi-launcher = {
|
||||
keys = "SUPER + R";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${lib.getExe pkgs.rofi} -show drun";
|
||||
};
|
||||
};
|
||||
|
||||
rofi-power-menu = {
|
||||
keys = "SUPER + SHIFT + X";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${powerMenu}";
|
||||
};
|
||||
};
|
||||
# The launcher and the session menu, kept next to the tool they drive.
|
||||
# $mod is defined by the compositor config these binds share.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod, R, exec, ${pkgs.rofi}/bin/rofi -show drun"
|
||||
"$mod SHIFT, X, exec, ${powerMenu}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,56 +6,165 @@
|
||||
}:
|
||||
# Waybar: a top status bar reading system state at a glance.
|
||||
let
|
||||
moduleNames =
|
||||
position:
|
||||
map (entry: entry.name) (
|
||||
lib.sort (left: right: left.module.order < right.module.order) (
|
||||
lib.filter (entry: entry.module.position == position) moduleEntries
|
||||
)
|
||||
);
|
||||
cfg = config.modules.desktop.session.waybar;
|
||||
vpnClient = config.modules.network.vpn-client;
|
||||
user = config.user.name;
|
||||
|
||||
moduleEntries = lib.mapAttrsToList (name: module: {
|
||||
inherit name module;
|
||||
}) modules;
|
||||
# A Nerd Font Private-Use-Area codepoint as a literal character, decoded
|
||||
# through JSON so the glyph survives as bytes rather than an editor paste.
|
||||
g = code: builtins.fromJSON ''"\u${code}"'';
|
||||
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
|
||||
|
||||
modules = lib.attrByPath [ config.networking.hostName ] { } config.registry.waybar;
|
||||
# Renders and toggles mako's do-not-disturb mode.
|
||||
# With no mako daemon answering, the status reads "notifications on", keeping
|
||||
# the widget inert rather than broken.
|
||||
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
|
||||
if ${pkgs.mako}/bin/makoctl mode 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx dnd; then
|
||||
printf '{"text":"${g "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
|
||||
else
|
||||
printf '{"text":"${g "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
|
||||
fi
|
||||
'';
|
||||
|
||||
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
|
||||
${pkgs.mako}/bin/makoctl mode -t dnd >/dev/null 2>&1
|
||||
${pkgs.procps}/bin/pkill -RTMIN+8 waybar
|
||||
'';
|
||||
|
||||
# Lit while a wf-recorder capture is running.
|
||||
# Empty text collapses the widget when idle, so it shows nothing until then.
|
||||
recStatus = pkgs.writeShellScript "waybar-recording-status" ''
|
||||
if ${pkgs.procps}/bin/pgrep -x wf-recorder >/dev/null; then
|
||||
printf '{"text":"${g "f03d"}","tooltip":"Recording","class":"recording"}\n'
|
||||
else
|
||||
printf '{"text":"","class":"idle"}\n'
|
||||
fi
|
||||
'';
|
||||
|
||||
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
|
||||
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet lockscreen-idle-inhibit.service; then
|
||||
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
|
||||
else
|
||||
printf '{"text":"","class":"idle"}\n'
|
||||
fi
|
||||
'';
|
||||
|
||||
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
|
||||
interface=${lib.escapeShellArg vpnClient.interface}
|
||||
|
||||
if ! ${pkgs.iproute2}/bin/ip link show dev "$interface" >/dev/null 2>&1; then
|
||||
printf '{"text":"","class":"idle"}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$interface" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
|
||||
|
||||
${pkgs.jq}/bin/jq -cn \
|
||||
--arg text "" \
|
||||
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
|
||||
--arg class "active" \
|
||||
'{ text: $text, tooltip: $tooltip, class: $class }'
|
||||
'';
|
||||
|
||||
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
|
||||
active=$(
|
||||
${pkgs.networkmanager}/bin/nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status \
|
||||
| ${pkgs.gawk}/bin/awk -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
|
||||
)
|
||||
|
||||
if [ -z "$active" ]; then
|
||||
${pkgs.jq}/bin/jq -cn \
|
||||
--arg text "${g "f127"} off" \
|
||||
--arg tooltip "Network disconnected" \
|
||||
--arg class "disconnected" \
|
||||
'{ text: $text, tooltip: $tooltip, class: $class }'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=: read -r device type _state connection <<< "$active"
|
||||
|
||||
case "$type" in
|
||||
wifi) icon="${g "f1eb"}" ;;
|
||||
ethernet) icon="LAN" ;;
|
||||
*) icon="NET" ;;
|
||||
esac
|
||||
|
||||
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$device" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
|
||||
|
||||
${pkgs.jq}/bin/jq -cn \
|
||||
--arg text "$icon $connection" \
|
||||
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
|
||||
--arg class "$type" \
|
||||
'{ text: $text, tooltip: $tooltip, class: $class }'
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.session.waybar.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
# A symbols font backs the module glyphs, so pango falls back to it for the
|
||||
# codepoints Stylix's monospace font does not carry.
|
||||
fonts.packages = [ pkgs.nerd-fonts.symbols-only ];
|
||||
|
||||
home-manager.users.${config.user.name}.programs.waybar = {
|
||||
home-manager.users.${user}.programs.waybar = {
|
||||
enable = true;
|
||||
|
||||
# A user service bound to graphical-session.target, which uwsm activates,
|
||||
# so the bar comes up with the session and no compositor exec-once.
|
||||
systemd.enable = true;
|
||||
|
||||
settings.mainBar = lib.mkMerge [
|
||||
{
|
||||
layer = "top";
|
||||
position = "top";
|
||||
height = 30;
|
||||
spacing = 8;
|
||||
margin-right = 8;
|
||||
|
||||
modules-left = moduleNames "left";
|
||||
modules-center = moduleNames "center";
|
||||
modules-right = moduleNames "right";
|
||||
style = ''
|
||||
.modules-right {
|
||||
margin-right: 8px;
|
||||
}
|
||||
(lib.mapAttrs (_name: module: module.settings) modules)
|
||||
];
|
||||
};
|
||||
|
||||
registry.waybar.${config.networking.hostName} = {
|
||||
clock = {
|
||||
position = "center";
|
||||
order = 10;
|
||||
settings = {
|
||||
#custom-dnd {
|
||||
margin-right: 8px;
|
||||
}
|
||||
'';
|
||||
|
||||
settings.mainBar = {
|
||||
layer = "top";
|
||||
position = "top";
|
||||
height = 30;
|
||||
spacing = 8;
|
||||
|
||||
modules-left = [ "hyprland/workspaces" ];
|
||||
modules-center = [ "clock" ];
|
||||
modules-right = [
|
||||
"custom/recording"
|
||||
"custom/idle-lock"
|
||||
"mpris"
|
||||
"wireplumber"
|
||||
]
|
||||
++ lib.optional vpnClient.enable "custom/vpn"
|
||||
++ [
|
||||
"custom/network"
|
||||
"battery"
|
||||
"custom/dnd"
|
||||
];
|
||||
|
||||
# Per-application icons for the windows on each workspace, mapped from
|
||||
# window class with a neutral glyph for anything unlisted.
|
||||
"hyprland/workspaces" = {
|
||||
on-click = "activate";
|
||||
format = "{id} {windows}";
|
||||
format-window-separator = " ";
|
||||
window-rewrite-default = g "f2d0";
|
||||
window-rewrite = {
|
||||
"class<Alacritty>" = g "f120";
|
||||
"class<com.mitchellh.ghostty>" = g "f120";
|
||||
"class<.*[Ff]irefox.*>" = g "f269";
|
||||
"class<chromium.*>" = g "f268";
|
||||
"class<[Cc]ode>" = g "f121";
|
||||
"class<obsidian>" = g "f02d";
|
||||
"class<[Ss]ignal>" = g "f4ad";
|
||||
"class<[Ss]team>" = g "f1b6";
|
||||
"class<[Vv]esktop>" = g "f1ff";
|
||||
};
|
||||
};
|
||||
|
||||
clock = {
|
||||
format = "{:%a %d %b %H:%M}";
|
||||
tooltip-format = "<tt>{calendar}</tt>";
|
||||
calendar = {
|
||||
@@ -64,6 +173,77 @@ in
|
||||
format.today = "<b><u>{}</u></b>";
|
||||
};
|
||||
};
|
||||
|
||||
mpris = {
|
||||
format = "${g "f001"} {title}";
|
||||
format-paused = "${g "f04c"} {title}";
|
||||
max-length = 40;
|
||||
on-click = "play-pause";
|
||||
on-scroll-up = "next";
|
||||
on-scroll-down = "previous";
|
||||
};
|
||||
|
||||
wireplumber = {
|
||||
format = "{icon} {volume}%";
|
||||
format-muted = "${g "f026"} muted";
|
||||
format-icons = [
|
||||
(g "f026")
|
||||
(g "f027")
|
||||
(g "f028")
|
||||
];
|
||||
on-click = "${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
|
||||
};
|
||||
|
||||
"custom/vpn" = {
|
||||
return-type = "json";
|
||||
exec = "${vpnStatus}";
|
||||
interval = 5;
|
||||
};
|
||||
|
||||
"custom/network" = {
|
||||
return-type = "json";
|
||||
exec = "${networkStatus}";
|
||||
interval = 5;
|
||||
};
|
||||
|
||||
battery = {
|
||||
format = "{icon} {capacity}%";
|
||||
format-charging = "${g "f0e7"} {capacity}%";
|
||||
format-icons = [
|
||||
(g "f244")
|
||||
(g "f243")
|
||||
(g "f242")
|
||||
(g "f241")
|
||||
(g "f240")
|
||||
];
|
||||
states = {
|
||||
warning = 20;
|
||||
critical = 10;
|
||||
};
|
||||
};
|
||||
|
||||
"custom/dnd" = {
|
||||
return-type = "json";
|
||||
exec = "${dndStatus}";
|
||||
on-click = "${dndToggle}";
|
||||
interval = "once";
|
||||
signal = 8;
|
||||
};
|
||||
|
||||
# Recording state has no event to hook, so the widget samples the
|
||||
# wf-recorder process once a second.
|
||||
"custom/recording" = {
|
||||
return-type = "json";
|
||||
exec = "${recStatus}";
|
||||
interval = 1;
|
||||
};
|
||||
|
||||
"custom/idle-lock" = {
|
||||
return-type = "json";
|
||||
exec = "${idleLockStatus}";
|
||||
interval = "once";
|
||||
signal = 9;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
# PipeWire as the desktop audio server.
|
||||
let
|
||||
mediaKey = keys: command: {
|
||||
inherit keys;
|
||||
locked = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${lib.getExe pkgs.playerctl} ${command}";
|
||||
};
|
||||
};
|
||||
cfg = config.modules.desktop.utilities.audio;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.audio.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Realtime scheduling for the audio threads, so playback survives load.
|
||||
security.rtkit.enable = true;
|
||||
|
||||
@@ -28,39 +15,5 @@ in
|
||||
alsa.enable = true;
|
||||
pulse.enable = true;
|
||||
};
|
||||
|
||||
registry.keybind.${config.networking.hostName} = {
|
||||
media-play = mediaKey "XF86AudioPlay" "play-pause";
|
||||
media-next = mediaKey "XF86AudioNext" "next";
|
||||
media-previous = mediaKey "XF86AudioPrev" "previous";
|
||||
};
|
||||
|
||||
registry.waybar.${config.networking.hostName} = {
|
||||
mpris = {
|
||||
order = 30;
|
||||
settings = {
|
||||
format = "${util.glyph "f001"} {title}";
|
||||
format-paused = "${util.glyph "f04c"} {title}";
|
||||
max-length = 40;
|
||||
on-click = "play-pause";
|
||||
on-scroll-up = "next";
|
||||
on-scroll-down = "previous";
|
||||
};
|
||||
};
|
||||
|
||||
wireplumber = {
|
||||
order = 40;
|
||||
settings = {
|
||||
format = "{icon} {volume}%";
|
||||
format-muted = "${util.glyph "f026"} muted";
|
||||
format-icons = [
|
||||
(util.glyph "f026")
|
||||
(util.glyph "f027")
|
||||
(util.glyph "f028")
|
||||
];
|
||||
on-click = "${lib.getExe' pkgs.wireplumber "wpctl"} set-mute @DEFAULT_AUDIO_SINK@ toggle";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,23 +6,29 @@
|
||||
}:
|
||||
# Clipboard history: cliphist records every copy, picked back through rofi.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.clipboard;
|
||||
user = config.user.name;
|
||||
|
||||
cliphist = "${pkgs.cliphist}/bin/cliphist";
|
||||
rofi = "${pkgs.rofi}/bin/rofi";
|
||||
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
|
||||
|
||||
# The picker reuses the themed rofi, so history looks like every other menu
|
||||
# the launcher drives.
|
||||
# decode is needed because list emits id-prefixed lines rather than the copied
|
||||
# bytes, so the chosen id has to be resolved back before it can be re-copied.
|
||||
picker = pkgs.writeShellScript "clipboard-picker" ''
|
||||
${lib.getExe pkgs.cliphist} list \
|
||||
| ${lib.getExe pkgs.rofi} -dmenu -i -p Clipboard \
|
||||
| ${lib.getExe pkgs.cliphist} decode \
|
||||
| ${lib.getExe' pkgs.wl-clipboard "wl-copy"}
|
||||
${cliphist} list \
|
||||
| ${rofi} -dmenu -i -p Clipboard \
|
||||
| ${cliphist} decode \
|
||||
| ${wl-copy}
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.clipboard.enable =
|
||||
lib.mkEnableOption "cliphist clipboard history";
|
||||
options.modules.desktop.utilities.clipboard.enable = lib.mkEnableOption "cliphist clipboard history";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
|
||||
home-manager.users.${config.user.name} = {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# wl-copy and wl-paste on PATH, so the shell can pipe into and out of the
|
||||
# clipboard.
|
||||
# The watchers and picker above reach wl-clipboard by store path, so this
|
||||
@@ -32,14 +38,11 @@ in
|
||||
# Two watchers record text and images to history, bound to the graphical
|
||||
# session so uwsm starts and stops them with it.
|
||||
services.cliphist.enable = true;
|
||||
};
|
||||
|
||||
registry.keybind.${config.networking.hostName}.clipboard-history = {
|
||||
keys = "SUPER + SHIFT + V";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${picker}";
|
||||
};
|
||||
# $mod is defined by the compositor config these binds share.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod SHIFT, V, exec, ${picker}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,60 +6,13 @@
|
||||
}:
|
||||
# SwayOSD: a transient on-screen popup for volume and brightness changes.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.osd;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.osd.enable =
|
||||
lib.mkEnableOption "the SwayOSD volume and brightness popup";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.osd.enable {
|
||||
registry.keybind.${config.networking.hostName} = {
|
||||
volume-up = {
|
||||
keys = "XF86AudioRaiseVolume";
|
||||
repeating = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100";
|
||||
};
|
||||
};
|
||||
|
||||
volume-down = {
|
||||
keys = "XF86AudioLowerVolume";
|
||||
repeating = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume lower";
|
||||
};
|
||||
};
|
||||
|
||||
brightness-up = {
|
||||
keys = "XF86MonBrightnessUp";
|
||||
repeating = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${pkgs.swayosd}/bin/swayosd-client --brightness raise";
|
||||
};
|
||||
};
|
||||
|
||||
brightness-down = {
|
||||
keys = "XF86MonBrightnessDown";
|
||||
repeating = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${pkgs.swayosd}/bin/swayosd-client --brightness lower";
|
||||
};
|
||||
};
|
||||
|
||||
mute = {
|
||||
keys = "XF86AudioMute";
|
||||
locked = true;
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle";
|
||||
};
|
||||
};
|
||||
};
|
||||
options.modules.desktop.utilities.osd.enable = lib.mkEnableOption "the SwayOSD volume and brightness popup";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ pkgs.swayosd ];
|
||||
|
||||
# The udev rule chgrps each backlight's brightness node to `video` and adds
|
||||
|
||||
@@ -2,11 +2,22 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Keybound screen recording: pick a region, then toggle a video-only capture.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.recording;
|
||||
user = config.user.name;
|
||||
|
||||
slurp = "${pkgs.slurp}/bin/slurp";
|
||||
wf-recorder = "${pkgs.wf-recorder}/bin/wf-recorder";
|
||||
notify-send = "${pkgs.libnotify}/bin/notify-send";
|
||||
pgrep = "${pkgs.procps}/bin/pgrep";
|
||||
pkill = "${pkgs.procps}/bin/pkill";
|
||||
date = "${pkgs.coreutils}/bin/date";
|
||||
mkdir = "${pkgs.coreutils}/bin/mkdir";
|
||||
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
|
||||
|
||||
# A single key both starts and stops.
|
||||
# A running wf-recorder is stopped with SIGINT so it finalises the file.
|
||||
# Otherwise slurp picks a region and wf-recorder runs in the foreground until
|
||||
@@ -14,51 +25,30 @@ let
|
||||
# reports it saved.
|
||||
# No -a means no audio is captured.
|
||||
toggle = pkgs.writeShellScript "screen-record-toggle" ''
|
||||
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
|
||||
${lib.getExe' pkgs.procps "pkill"} -INT -x wf-recorder
|
||||
if ${pgrep} -x wf-recorder >/dev/null; then
|
||||
${pkill} -INT -x wf-recorder
|
||||
exit 0
|
||||
fi
|
||||
|
||||
region=$(${lib.getExe pkgs.slurp}) || exit 0
|
||||
dir="$(${lib.getExe' pkgs.xdg-user-dirs "xdg-user-dir"} VIDEOS)/Recordings"
|
||||
${lib.getExe' pkgs.coreutils "mkdir"} -p "$dir"
|
||||
file="$dir/recording-$(${lib.getExe' pkgs.coreutils "date"} +%Y%m%d-%H%M%S).mp4"
|
||||
region=$(${slurp}) || exit 0
|
||||
dir="$(${xdgUserDir} VIDEOS)/Recordings"
|
||||
${mkdir} -p "$dir"
|
||||
file="$dir/recording-$(${date} +%Y%m%d-%H%M%S).mp4"
|
||||
|
||||
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording started" "Region capture, no audio."
|
||||
${lib.getExe pkgs.wf-recorder} -g "$region" -f "$file"
|
||||
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording saved" "$file"
|
||||
'';
|
||||
|
||||
# Lit while a wf-recorder capture is running.
|
||||
# Empty text collapses the widget when idle, so it shows nothing until then.
|
||||
recordingStatus = pkgs.writeShellScript "waybar-recording-status" ''
|
||||
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
|
||||
printf '{"text":"${util.glyph "f03d"}","tooltip":"Recording","class":"recording"}\n'
|
||||
else
|
||||
printf '{"text":"","class":"idle"}\n'
|
||||
fi
|
||||
${notify-send} -a "Screen recording" "Recording started" "Region capture, no audio."
|
||||
${wf-recorder} -g "$region" -f "$file"
|
||||
${notify-send} -a "Screen recording" "Recording saved" "$file"
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.recording.enable =
|
||||
lib.mkEnableOption "wf-recorder screen recording";
|
||||
options.modules.desktop.utilities.recording.enable = lib.mkEnableOption "wf-recorder screen recording";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.recording.enable {
|
||||
registry.keybind.${config.networking.hostName}.recording-toggle = {
|
||||
keys = "SUPER + SHIFT + R";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${toggle}";
|
||||
};
|
||||
};
|
||||
|
||||
registry.waybar.${config.networking.hostName}."custom/recording" = {
|
||||
order = 10;
|
||||
settings = {
|
||||
return-type = "json";
|
||||
exec = "${recordingStatus}";
|
||||
interval = 1;
|
||||
};
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# $mod is defined by the compositor config these binds share.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
"$mod SHIFT, R, exec, ${toggle}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,26 +4,25 @@
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Keyboard-driven screenshots copy to the clipboard before Satty opens for
|
||||
# annotation. Satty copies annotated screenshots when it saves them.
|
||||
# Keyboard-driven screenshots that open in satty for annotation, then land in
|
||||
# both the clipboard and a dated file.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.screenshot;
|
||||
user = config.user.name;
|
||||
|
||||
grimblast = "${pkgs.grimblast}/bin/grimblast";
|
||||
satty = "${pkgs.satty}/bin/satty";
|
||||
tee = "${pkgs.coreutils}/bin/tee";
|
||||
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
|
||||
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
|
||||
|
||||
# Copy the original screenshot before Satty opens.
|
||||
# Satty copies and saves annotated screenshots when the user presses Enter.
|
||||
# satty's copy action also saves, so one keystroke lands the shot in both the
|
||||
# clipboard and a file.
|
||||
capture =
|
||||
target:
|
||||
pkgs.writeShellScript "screenshot-${target}" ''
|
||||
dir="$(${xdgUserDir} PICTURES)/Screenshots"
|
||||
mkdir -p "$dir"
|
||||
${grimblast} save ${target} - \
|
||||
| ${tee} >(${wl-copy}) \
|
||||
| ${satty} --filename - \
|
||||
--output-filename "$dir/screenshot-%Y%m%d-%H%M%S.png" \
|
||||
--copy-command ${wl-copy} \
|
||||
@@ -33,46 +32,26 @@ let
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.screenshot.enable =
|
||||
lib.mkEnableOption "screenshots via grimblast and satty";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.screenshot.enable {
|
||||
registry.keybind.${config.networking.hostName} = {
|
||||
screenshot-area = {
|
||||
keys = "Print";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${capture "area"}";
|
||||
};
|
||||
};
|
||||
|
||||
screenshot-active = {
|
||||
keys = "SHIFT + Print";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${capture "active"}";
|
||||
};
|
||||
};
|
||||
|
||||
screenshot-screen = {
|
||||
keys = "CTRL + Print";
|
||||
action = {
|
||||
kind = "exec";
|
||||
command = "${capture "screen"}";
|
||||
};
|
||||
};
|
||||
};
|
||||
options.modules.desktop.utilities.screenshot.enable = lib.mkEnableOption "screenshots via grimblast and satty";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
# satty is a one-shot annotation surface, so float it rather than letting
|
||||
# it claim a tile in the layout.
|
||||
wayland.windowManager.hyprland.settings.window_rule = [
|
||||
{
|
||||
match.class = "^(com\\.gabm\\.satty)$";
|
||||
float = true;
|
||||
}
|
||||
# windowrule v3 syntax: space-separated field/value tokens, comma
|
||||
# separated, with matchers under a match: prefix and effects bare.
|
||||
wayland.windowManager.hyprland.settings.windowrule = [
|
||||
"float 1, match:class ^(com\\.gabm\\.satty)$"
|
||||
];
|
||||
|
||||
# Print with plain/Shift/Ctrl for region/window/full.
|
||||
# Super+L is already the hjkl focus and movement bind, so screenshots take
|
||||
# the Print key instead.
|
||||
wayland.windowManager.hyprland.settings.bind = [
|
||||
", Print, exec, ${capture "area"}"
|
||||
"SHIFT, Print, exec, ${capture "active"}"
|
||||
"CTRL, Print, exec, ${capture "screen"}"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
}:
|
||||
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.theming;
|
||||
user = config.user.name;
|
||||
|
||||
# A single static Nord wallpaper, drawn as a vertical gradient across the
|
||||
# Polar Night shades so it needs no committed binary and no network fetch.
|
||||
@@ -14,13 +16,12 @@ let
|
||||
-size 2560x1440 gradient:'#2E3440'-'#3B4252' \
|
||||
"$out"
|
||||
'';
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.theming.enable =
|
||||
lib.mkEnableOption "Nord theming of the graphical layer via Stylix";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.theming.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
stylix = {
|
||||
enable = true;
|
||||
|
||||
@@ -58,10 +59,6 @@ in
|
||||
};
|
||||
};
|
||||
};
|
||||
registry.env.${config.networking.hostName} = {
|
||||
HYPRCURSOR_THEME = config.stylix.cursor.name;
|
||||
HYPRCURSOR_SIZE = toString config.stylix.cursor.size;
|
||||
};
|
||||
|
||||
home-manager.users.${user} = {
|
||||
# Stylix drives the cursor through home-manager's pointer-cursor config,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
# Declares the XDG user directories, so a tool that queries them lands in a
|
||||
# well-known folder a host can relocate from one place.
|
||||
let
|
||||
cfg = config.modules.desktop.utilities.userdirs;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.desktop.utilities.userdirs.enable = lib.mkEnableOption "XDG user directories";
|
||||
|
||||
config = lib.mkIf config.modules.desktop.utilities.userdirs.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Write ~/.config/user-dirs.dirs from the option defaults.
|
||||
home-manager.users.${user}.xdg.userDirs.enable = true;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.modules.guest;
|
||||
in
|
||||
{
|
||||
options.modules.guest.enable = lib.mkEnableOption "nested guest configuration";
|
||||
options.modules.guest.enable = lib.mkEnableOption "nested-guest base configuration";
|
||||
|
||||
config = lib.mkIf config.modules.guest.enable {
|
||||
modules.network.ssh.enable = lib.mkDefault false;
|
||||
config = lib.mkIf cfg.enable {
|
||||
modules.network.ssh.enable = lib.mkDefault true;
|
||||
modules.network.ssh.hostKeys.restore = lib.mkDefault false;
|
||||
};
|
||||
}
|
||||
|
||||
52
configs/nixos/modules/host.nix
Normal file
52
configs/nixos/modules/host.nix
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.host;
|
||||
user = config.user;
|
||||
hostSecrets = ../secrets + "/${config.networking.hostName}.yaml";
|
||||
passwordSecret = "${user.name}-password";
|
||||
in
|
||||
{
|
||||
options.modules.host.enable = lib.mkEnableOption "physical-host base configuration";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
|
||||
nix.settings.extra-trusted-public-keys = [
|
||||
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
|
||||
];
|
||||
|
||||
nix.gc.automatic = true;
|
||||
nix.gc.dates = "Mon 03:15";
|
||||
nix.gc.options = "--delete-older-than 30d";
|
||||
|
||||
nix.optimise.automatic = true;
|
||||
nix.optimise.dates = [ "Thu 03:45" ];
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.systemd-boot.configurationLimit = 15;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
zramSwap.enable = true;
|
||||
|
||||
modules.network.ssh.enable = true;
|
||||
modules.network.ssh.hostKeys.sopsFile = hostSecrets;
|
||||
modules.network.ssh.userKey.sopsFile = hostSecrets;
|
||||
|
||||
services.xserver.xkb.layout = "us";
|
||||
services.xserver.xkb.options = "caps:escape_shifted_capslock";
|
||||
console.useXkbConfig = true;
|
||||
|
||||
sops.defaultSopsFile = ../secrets/shared.yaml;
|
||||
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
|
||||
sops.age.sshKeyPaths = [ ];
|
||||
sops.gnupg.sshKeyPaths = [ ];
|
||||
|
||||
users.mutableUsers = false;
|
||||
sops.secrets.${passwordSecret}.neededForUsers = true;
|
||||
users.users.${user.name}.hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.modules.host.cachyos.enable = lib.mkEnableOption "the CachyOS kernel";
|
||||
|
||||
config = lib.mkIf config.modules.host.cachyos.enable {
|
||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||
|
||||
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
|
||||
nix.settings.extra-trusted-public-keys = [
|
||||
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hostSecrets = ../../secrets + "/${config.networking.hostName}.yaml";
|
||||
in
|
||||
{
|
||||
options.modules.host.enable = lib.mkEnableOption "physical host configuration";
|
||||
|
||||
config = lib.mkIf config.modules.host.enable {
|
||||
nix.gc.automatic = true;
|
||||
nix.gc.dates = "Mon 03:15";
|
||||
nix.gc.options = "--delete-older-than 30d";
|
||||
|
||||
nix.optimise.automatic = true;
|
||||
nix.optimise.dates = [ "Thu 03:45" ];
|
||||
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.systemd-boot.configurationLimit = 15;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
zramSwap.enable = true;
|
||||
|
||||
modules.network.ssh.enable = true;
|
||||
modules.network.ssh.hostKeys.sopsFile = hostSecrets;
|
||||
modules.network.ssh.userKey.sopsFile = hostSecrets;
|
||||
|
||||
services.xserver.xkb.layout = "us";
|
||||
console.useXkbConfig = true;
|
||||
|
||||
sops.defaultSopsFile = ../../secrets/shared.yaml;
|
||||
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
|
||||
sops.age.sshKeyPaths = [ ];
|
||||
sops.gnupg.sshKeyPaths = [ ];
|
||||
|
||||
users.mutableUsers = false;
|
||||
sops.secrets."${config.user.name}-password".neededForUsers = true;
|
||||
users.users.${config.user.name}.hashedPasswordFile =
|
||||
config.sops.secrets."${config.user.name}-password".path;
|
||||
|
||||
security.sudo.extraConfig = ''
|
||||
Defaults env_keep += "EDITOR VISUAL"
|
||||
'';
|
||||
|
||||
home-manager = {
|
||||
useGlobalPkgs = true;
|
||||
useUserPackages = true;
|
||||
users.${config.user.name} = {
|
||||
home.username = config.user.name;
|
||||
home.homeDirectory = "/home/${config.user.name}";
|
||||
home.stateVersion = config.system.stateVersion;
|
||||
home.sessionVariables = lib.attrByPath [ config.networking.hostName ] { } config.registry.env;
|
||||
home.packages = with pkgs; [
|
||||
eza
|
||||
unzip
|
||||
wget
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
registry.shell-shortcut.${config.networking.hostName} = {
|
||||
ls = {
|
||||
command = "eza -alg --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
ll = {
|
||||
command = "eza -lg --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
lt = {
|
||||
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
||||
abbreviation = false;
|
||||
};
|
||||
".." = "cd ..";
|
||||
"..." = "cd ../..";
|
||||
"...." = "cd ../../..";
|
||||
|
||||
cp = "cp -v";
|
||||
tarnow = "tar -acf ";
|
||||
untar = "tar -zxvf ";
|
||||
wget = "wget -c ";
|
||||
grep = "grep --color=auto";
|
||||
fgrep = "fgrep --color=auto";
|
||||
egrep = "egrep --color=auto";
|
||||
jctl = "journalctl -p 3 -xb";
|
||||
|
||||
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
|
||||
cleanup = "sudo nix-collect-garbage -d";
|
||||
sops-host = {
|
||||
command = "sudo --preserve-env=EDITOR,VISUAL env SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt nix shell nixpkgs#sops nixpkgs#neovim -c sops";
|
||||
abbreviation = false;
|
||||
};
|
||||
};
|
||||
|
||||
modules.shell.fish.enable = lib.mkDefault true;
|
||||
modules.shell.fish.defaultShell = lib.mkDefault true;
|
||||
modules.shell.tmux.enable = lib.mkDefault true;
|
||||
modules.shell.nvim.enable = lib.mkDefault true;
|
||||
modules.shell.git.enable = lib.mkDefault true;
|
||||
modules.shell.yazi.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
# Imports existing ZFS pools that hold durable service state.
|
||||
let
|
||||
cfg = config.modules.host.zfs; # NOLINT(let_order)
|
||||
in
|
||||
{
|
||||
options.modules.host.zfs = {
|
||||
enable = lib.mkEnableOption "ZFS pool imports for durable service state";
|
||||
|
||||
pools = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
tank = {
|
||||
media = "/srv/media";
|
||||
downloads = "/srv/downloads";
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Lists pools that NixOS imports at boot.
|
||||
Maps each dataset name to its mount point.
|
||||
NixOS does not create or destroy these pools during rebuilds.
|
||||
An empty map imports the pool without declaring file systems.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
boot.supportedFilesystems = [ "zfs" ];
|
||||
|
||||
# This module derives a stable host ID from the configured hostname.
|
||||
networking.hostId = builtins.substring 0 8 (
|
||||
builtins.hashString "sha256" config.networking.hostName
|
||||
);
|
||||
|
||||
boot.zfs.extraPools = lib.attrNames cfg.pools;
|
||||
|
||||
fileSystems = lib.mkMerge (
|
||||
lib.mapAttrsToList (
|
||||
pool: mounts:
|
||||
lib.mapAttrs' (
|
||||
dataset: mountpoint:
|
||||
lib.nameValuePair mountpoint {
|
||||
device = "${pool}/${dataset}";
|
||||
fsType = "zfs";
|
||||
}
|
||||
) mounts
|
||||
) cfg.pools
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,13 @@
|
||||
...
|
||||
}:
|
||||
# Tools for developing this monorepo.
|
||||
let
|
||||
cfg = config.modules.monorepo;
|
||||
in
|
||||
{
|
||||
options.modules.monorepo.enable = lib.mkEnableOption "tools for developing this monorepo";
|
||||
|
||||
config = lib.mkIf config.modules.monorepo.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
modules.shell.bazel.enable = true;
|
||||
modules.shell.python.enable = true;
|
||||
modules.shell.rust.enable = true;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# NetworkManager-managed networking.
|
||||
let
|
||||
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
|
||||
active=$(
|
||||
${lib.getExe' pkgs.networkmanager "nmcli"} -t -f DEVICE,TYPE,STATE,CONNECTION device status \
|
||||
| ${lib.getExe' pkgs.gawk "awk"} -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
|
||||
)
|
||||
|
||||
if [ -z "$active" ]; then
|
||||
${lib.getExe pkgs.jq} -cn \
|
||||
--arg text "${util.glyph "f127"} off" \
|
||||
--arg tooltip "Network disconnected" \
|
||||
--arg class "disconnected" \
|
||||
'{ text: $text, tooltip: $tooltip, class: $class }'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=: read -r device type _state connection <<< "$active"
|
||||
|
||||
case "$type" in
|
||||
wifi) icon="${util.glyph "f1eb"}" ;;
|
||||
ethernet) icon="LAN" ;;
|
||||
*) icon="NET" ;;
|
||||
esac
|
||||
|
||||
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$device" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
|
||||
|
||||
${lib.getExe pkgs.jq} -cn \
|
||||
--arg text "$icon $connection" \
|
||||
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
|
||||
--arg class "$type" \
|
||||
'{ text: $text, tooltip: $tooltip, class: $class }'
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.network.enable = lib.mkEnableOption "NetworkManager-managed networking";
|
||||
|
||||
config = lib.mkIf config.modules.network.enable {
|
||||
networking.networkmanager.enable = true;
|
||||
|
||||
registry.waybar.${config.networking.hostName}."custom/network" = {
|
||||
order = 60;
|
||||
settings = {
|
||||
return-type = "json";
|
||||
exec = "${networkStatus}";
|
||||
interval = 5;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,207 +4,343 @@
|
||||
registry,
|
||||
...
|
||||
}:
|
||||
# Configures inbound and outbound SSH access.
|
||||
# SSH on this machine, in both directions.
|
||||
let
|
||||
cfg = config.modules.network.ssh;
|
||||
user = config.user.name; # NOLINT(let_order)
|
||||
user = config.user.name;
|
||||
|
||||
# NOLINT(let_order)
|
||||
workstationNames = [
|
||||
"neogaia"
|
||||
"zeus"
|
||||
];
|
||||
serverNames = [ "pikachu" ];
|
||||
inherit (lib)
|
||||
concatLists
|
||||
concatStringsSep
|
||||
elem
|
||||
filter
|
||||
genAttrs
|
||||
hasAttr
|
||||
imap0
|
||||
listToAttrs
|
||||
mapAttrs
|
||||
mapAttrsToList
|
||||
mkIf
|
||||
mkMerge
|
||||
mkOption
|
||||
nameValuePair
|
||||
optional
|
||||
optionalAttrs
|
||||
types
|
||||
unique
|
||||
;
|
||||
|
||||
isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order)
|
||||
isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order)
|
||||
hostKeySecret = type: "ssh-host-${type}-key";
|
||||
userKeySecret = "ssh-user-ed25519-key";
|
||||
|
||||
# NOLINT(let_order)
|
||||
clientKeyFor = host: builtins.readFile (../../hosts + "/${host}/ssh_user_ed25519_key.pub");
|
||||
hostKeysFor =
|
||||
host:
|
||||
map builtins.readFile [
|
||||
(../../hosts + "/${host}/ssh_host_ed25519_key.pub")
|
||||
(../../hosts + "/${host}/ssh_host_rsa_key.pub")
|
||||
];
|
||||
targetType = types.submodule (
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
hostName = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
description = ''
|
||||
The network address OpenSSH connects to for this target.
|
||||
'';
|
||||
};
|
||||
|
||||
# NOLINT(let_order)
|
||||
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
|
||||
hostName = registry.address.${name}.self;
|
||||
clientKey = clientKeyFor name;
|
||||
hostKeys = hostKeysFor name;
|
||||
});
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = config.user.name;
|
||||
description = ''
|
||||
The remote login name OpenSSH uses for this target.
|
||||
'';
|
||||
};
|
||||
|
||||
extraTargets = {
|
||||
# TODO: Pin Gitea's host key after migrating it from Proxmox.
|
||||
gitea = {
|
||||
hostName = "git.alexion.dev";
|
||||
port = 2022;
|
||||
user = "gitea";
|
||||
aliases = [
|
||||
"gitea"
|
||||
"git.alexion.dev"
|
||||
];
|
||||
};
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 22;
|
||||
description = ''
|
||||
The TCP port OpenSSH uses for this target.
|
||||
'';
|
||||
};
|
||||
|
||||
# NOLINT(let_order)
|
||||
mkTarget = name: target: {
|
||||
hostName = target.hostName or name;
|
||||
user = target.user or user;
|
||||
port = target.port or 22;
|
||||
aliases = target.aliases or [ name ];
|
||||
clientKey = target.clientKey or null;
|
||||
hostKeys = target.hostKeys or [ ];
|
||||
};
|
||||
targets = lib.mapAttrs mkTarget (managedTargets // extraTargets); # NOLINT(let_order)
|
||||
aliases = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ name ];
|
||||
description = ''
|
||||
Host patterns written into the generated OpenSSH client block.
|
||||
'';
|
||||
};
|
||||
|
||||
clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names); # NOLINT(let_order)
|
||||
# NOLINT(let_order)
|
||||
defaultAuthorizedKeys = lib.flatten (
|
||||
clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
|
||||
clientKey = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
The public key this target offers when it connects outward.
|
||||
Other machines admit this key according to the host groups below.
|
||||
'';
|
||||
};
|
||||
|
||||
hostKeys = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
The public keys this target presents when it accepts inbound SSH.
|
||||
These keys generate system-wide known-host entries.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
outboundTargetNames = # NOLINT(let_order)
|
||||
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
|
||||
|
||||
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
|
||||
|
||||
workstationNames = targetNamesIn cfg.hosts.workstations;
|
||||
serverNames = targetNamesIn cfg.hosts.servers;
|
||||
|
||||
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
|
||||
|
||||
currentHost = config.networking.hostName;
|
||||
isServer = elem currentHost cfg.hosts.servers;
|
||||
isWorkstation = elem currentHost cfg.hosts.workstations;
|
||||
|
||||
defaultAuthorizedKeys = lib.flatten (
|
||||
clientKeysFor workstationNames
|
||||
++ optional isServer (clientKeysFor serverNames)
|
||||
);
|
||||
|
||||
outboundTargetNames =
|
||||
let
|
||||
groupTargets =
|
||||
if isServer then
|
||||
builtins.attrNames extraTargets ++ serverNames
|
||||
[ "gitea" ] ++ cfg.hosts.servers
|
||||
else if isWorkstation then
|
||||
builtins.attrNames extraTargets ++ serverNames ++ workstationNames
|
||||
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
|
||||
else
|
||||
builtins.attrNames extraTargets;
|
||||
[ "gitea" ];
|
||||
in
|
||||
lib.filter (name: name != config.networking.hostName) groupTargets;
|
||||
filter (name: name != currentHost) (targetNamesIn groupTargets);
|
||||
|
||||
knownHosts = lib.listToAttrs (
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
sshSettingsFor = name:
|
||||
let
|
||||
target = cfg.targets.${name};
|
||||
in
|
||||
{
|
||||
header = "Host ${concatStringsSep " " target.aliases}";
|
||||
HostName = target.hostName;
|
||||
User = target.user;
|
||||
}
|
||||
// optionalAttrs (target.port != 22) { Port = target.port; };
|
||||
|
||||
knownHostNamesFor = target:
|
||||
let
|
||||
names = unique (target.aliases ++ [ target.hostName ]);
|
||||
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
|
||||
in
|
||||
map withPort names;
|
||||
|
||||
knownHosts = listToAttrs (
|
||||
concatLists (
|
||||
mapAttrsToList (
|
||||
targetName: target:
|
||||
lib.imap0 (
|
||||
i: key:
|
||||
lib.nameValuePair "${targetName}-${toString i}" {
|
||||
imap0 (i: key:
|
||||
nameValuePair "${targetName}-${toString i}" {
|
||||
hostNames = knownHostNamesFor target;
|
||||
publicKey = keyWithoutComment key;
|
||||
}
|
||||
) target.hostKeys
|
||||
) targets
|
||||
) cfg.targets
|
||||
)
|
||||
);
|
||||
knownHostNamesFor =
|
||||
target:
|
||||
let
|
||||
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
|
||||
names = lib.unique (target.aliases ++ [ target.hostName ]);
|
||||
in
|
||||
map withPort names;
|
||||
|
||||
keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
|
||||
|
||||
sshSettingsFor =
|
||||
name:
|
||||
let
|
||||
target = targets.${name};
|
||||
in
|
||||
{
|
||||
header = "Host ${lib.concatStringsSep " " target.aliases}";
|
||||
HostName = target.hostName;
|
||||
User = target.user;
|
||||
}
|
||||
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
|
||||
|
||||
hostKeySecret = type: "ssh-host-${type}-key";
|
||||
hostKeyTypes = [
|
||||
"ed25519"
|
||||
"rsa"
|
||||
];
|
||||
userKeySecret = "ssh-user-ed25519-key";
|
||||
|
||||
in
|
||||
{
|
||||
options.modules.network.ssh = {
|
||||
enable = lib.mkEnableOption "the OpenSSH daemon";
|
||||
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
||||
|
||||
hostKeys.restore = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
hosts = {
|
||||
servers = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ "pikachu" ];
|
||||
description = ''
|
||||
Hosts that serve durable services.
|
||||
They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
|
||||
'';
|
||||
};
|
||||
|
||||
workstations = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [
|
||||
"neogaia"
|
||||
"zeus"
|
||||
];
|
||||
description = ''
|
||||
Hosts the operator works from.
|
||||
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
targets = mkOption {
|
||||
type = types.attrsOf targetType;
|
||||
default = {
|
||||
gitea = {
|
||||
hostName = "git.alexion.dev";
|
||||
port = 2022;
|
||||
user = "gitea";
|
||||
aliases = [
|
||||
"gitea"
|
||||
"git.alexion.dev"
|
||||
];
|
||||
};
|
||||
|
||||
neogaia = {
|
||||
hostName = registry.address.neogaia.self;
|
||||
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
|
||||
hostKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
|
||||
];
|
||||
};
|
||||
|
||||
zeus = {
|
||||
hostName = registry.address.zeus.self;
|
||||
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus";
|
||||
hostKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4GBPt966LR4LpmXgiMMkepIqNIlXTUBh4zdNeek/lN root@zeus"
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC3xOxGAJCG9PE0FVufgJX9VRO64IUUlRGHSOfYfAI4Mit46xKsr7z/OvqPPE9z8rOIcVPbLkc/mkMuHbDgolleunAFCIeuIIYpkL4o4/TZECh9BtN2WdPhjcD7S0rV+rR7p24Qy5bTCSwZmsQq8QxHTVPjjBngtPGRSpKVrZAuHCcdCJ13hBWAvnaav5KysKC89nMj/7FIh02v8kipGfq5LGCZ20Qp2lmv2DS24NhBPXzcw8pLN47I5GYVkZh/0RWOByteo3eXtKCF+JCwfUPCTP+zHGD+1tbO8//B2fkt3Pe9lUrn5eM2ZnX1emXSWfQng3cMqkftzWqvBwv0sSDU+9YV6H2Ury+GrrfukpBtQVe9PQcBWqbRPhxuagncR+af/tx0ZohZS0t74m3WgLtZ4r2mpTZS4oBmRsfvWzMFGSgwtva+3Y+NsLjPPM1Cz5Dn05JJlidzcABfsUg2H23vkmO2otaQI01dUni+szlRn4w6sVyxNcvY2fQGjmt9k7jmVI5D2A3yl+OLJguAAlw3ia335X1tNUG8FoI/VyZNhOckIihmbTtihNIg37Ro4yiuPRhnUWFvg2hcBCa9hzNP7XDUPnoCRQyJ4adQHQA/x4Kh+HJ6pZqtGcdqU80fzmWtrLkWG6v5/NRKs6soYDup7oQIKbJo9TtXvqtzmVmovQ== root@zeus"
|
||||
];
|
||||
};
|
||||
|
||||
pikachu = {
|
||||
hostName = registry.address.pikachu.self;
|
||||
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
|
||||
hostKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
|
||||
];
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
SSH targets known to the fleet.
|
||||
The inventory holds connection details plus public keys used for authorization and host verification.
|
||||
'';
|
||||
};
|
||||
|
||||
extraAuthorizedKeys = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Additional client public keys admitted by this host.
|
||||
'';
|
||||
};
|
||||
|
||||
authorizedKeys = mkOption {
|
||||
type = types.nullOr (types.listOf types.str);
|
||||
default = null;
|
||||
description = ''
|
||||
Complete override for client public keys admitted by this host.
|
||||
Leave null to derive access from `modules.network.ssh.hosts` and `modules.network.ssh.targets`.
|
||||
'';
|
||||
};
|
||||
|
||||
extraSettings = mkOption {
|
||||
type = types.attrsOf types.anything;
|
||||
default = { };
|
||||
description = ''
|
||||
Additional OpenSSH client settings merged into the generated Home Manager configuration.
|
||||
'';
|
||||
};
|
||||
|
||||
hostKeys.restore = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Restores host keys from secrets.
|
||||
Preserves the fingerprint across reimages.
|
||||
Guests disable this option and generate their own host keys.
|
||||
Restore the host keys from secrets rather than letting the daemon generate its own.
|
||||
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
|
||||
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
|
||||
'';
|
||||
};
|
||||
|
||||
hostKeys.sopsFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
hostKeys.sopsFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Sets encrypted file that contains host private keys.
|
||||
Names each secret `ssh-host-<type>-key`.
|
||||
Requires a value when `restore` is enabled.
|
||||
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
|
||||
Required when `restore` is on.
|
||||
'';
|
||||
};
|
||||
|
||||
userKey.sopsFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
hostKeys.types = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [
|
||||
"ed25519"
|
||||
"rsa"
|
||||
];
|
||||
description = ''
|
||||
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
|
||||
'';
|
||||
};
|
||||
|
||||
userKey.sopsFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Sets encrypted file that contains the SSH client private key.
|
||||
Names the secret `ssh-user-ed25519-key`.
|
||||
Guests leave this unset because they do not authenticate to remote servers.
|
||||
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
|
||||
Left unset on a machine that authenticates to no remote server, such as a guest.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
config = mkIf cfg.enable (
|
||||
mkMerge [
|
||||
{
|
||||
services.openssh.enable = true;
|
||||
|
||||
users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys;
|
||||
users.users.${user}.openssh.authorizedKeys.keys =
|
||||
if cfg.authorizedKeys != null then
|
||||
cfg.authorizedKeys
|
||||
else
|
||||
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
|
||||
|
||||
programs.ssh.knownHosts = knownHosts;
|
||||
|
||||
home-manager.users.${user}.programs.ssh = {
|
||||
enable = true;
|
||||
enableDefaultConfig = false;
|
||||
settings = lib.mapAttrs (name: _: sshSettingsFor name) (
|
||||
lib.genAttrs outboundTargetNames (name: name)
|
||||
);
|
||||
settings =
|
||||
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
|
||||
// cfg.extraSettings;
|
||||
};
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.hostKeys.restore {
|
||||
(mkIf cfg.hostKeys.restore {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.hostKeys.sopsFile != null;
|
||||
message = "Set `modules.network.ssh.hostKeys.sopsFile` when host-key restoration is enabled.";
|
||||
message = "modules.network.ssh.hostKeys.restore requires modules.network.ssh.hostKeys.sopsFile to name the encrypted host keys.";
|
||||
}
|
||||
];
|
||||
|
||||
sops.secrets = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: {
|
||||
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
||||
inherit (cfg.hostKeys) sopsFile;
|
||||
mode = "0400";
|
||||
restartUnits = [ "sshd.service" ];
|
||||
});
|
||||
|
||||
services.openssh.hostKeys = [ ];
|
||||
services.openssh.extraConfig = lib.concatStringsSep "" (
|
||||
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes
|
||||
services.openssh.extraConfig = concatStringsSep "" (
|
||||
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
|
||||
);
|
||||
})
|
||||
|
||||
(lib.mkIf (cfg.userKey.sopsFile != null) {
|
||||
(mkIf (cfg.userKey.sopsFile != null) {
|
||||
sops.secrets.${userKeySecret} = {
|
||||
inherit (cfg.userKey) sopsFile;
|
||||
mode = "0400";
|
||||
owner = user;
|
||||
};
|
||||
|
||||
home-manager.users.${user}.programs.ssh.settings."*" = {
|
||||
IdentityFile = config.sops.secrets.${userKeySecret}.path;
|
||||
IdentitiesOnly = true;
|
||||
};
|
||||
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
|
||||
config.sops.secrets.${userKeySecret}.path;
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
@@ -4,17 +4,24 @@
|
||||
util,
|
||||
...
|
||||
}:
|
||||
# Configures bridges for tagged VLANs on a physical interface that uses DHCP.
|
||||
# The host networking foundation: per-VLAN bridges over a tagged trunk.
|
||||
let
|
||||
cfg = config.modules.network.vlans; # NOLINT(let_order)
|
||||
cfg = config.modules.network.vlans;
|
||||
|
||||
inherit (util) bridgeName;
|
||||
|
||||
vlanName = id: "${cfg.trunk}.${toString id}";
|
||||
in
|
||||
{
|
||||
options.modules.network.vlans = {
|
||||
interface = lib.mkOption {
|
||||
enable = lib.mkEnableOption "host VLAN bridges over a tagged trunk";
|
||||
|
||||
trunk = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "enp1s0";
|
||||
description = ''
|
||||
Names the physical interface that carries untagged DHCP traffic and tagged VLANs.
|
||||
Physical interface that carries 802.1Q-tagged traffic for every VLAN.
|
||||
Each declared VLAN attaches to its own bridge.
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -27,37 +34,66 @@ in
|
||||
30
|
||||
];
|
||||
description = ''
|
||||
Creates bridges for the specified tagged VLAN IDs.
|
||||
|
||||
VLAN ID N creates bridge `br-vlanN`.
|
||||
Tagged VLAN ids to materialize.
|
||||
Each id N creates one bridge named `br-vlanN`.
|
||||
'';
|
||||
};
|
||||
|
||||
management = {
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.ints.between 1 4094);
|
||||
default = null;
|
||||
description = ''
|
||||
VLAN whose bridge carries the host's own management address.
|
||||
The value must be one of `ids`.
|
||||
Left null, the host takes no address on any bridge.
|
||||
'';
|
||||
};
|
||||
|
||||
address = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "10.0.10.2/24";
|
||||
description = ''
|
||||
Host static management address in CIDR form.
|
||||
Left null, the host takes its management address by DHCP.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (cfg.ids != [ ]) {
|
||||
networking = {
|
||||
useDHCP = false;
|
||||
networkmanager.unmanaged = [
|
||||
"interface-name:${cfg.interface}"
|
||||
]
|
||||
++ map (id: "interface-name:${util.bridgeName id}") cfg.ids
|
||||
++ map (id: "interface-name:vlan${toString id}") cfg.ids;
|
||||
};
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.management.vlan == null || lib.elem cfg.management.vlan cfg.ids;
|
||||
message = "modules.network.vlans.management.vlan (${toString cfg.management.vlan}) must name one of modules.network.vlans.ids.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.management.address == null || cfg.management.vlan != null;
|
||||
message = "modules.network.vlans.management.address needs modules.network.vlans.management.vlan to say which bridge carries it.";
|
||||
}
|
||||
];
|
||||
|
||||
# networkd owns these interfaces so NetworkManager does not contend for them.
|
||||
networking.networkmanager.unmanaged =
|
||||
[ "interface-name:${cfg.trunk}" ]
|
||||
++ map (id: "interface-name:${bridgeName id}") cfg.ids
|
||||
++ map (id: "interface-name:${vlanName id}") cfg.ids;
|
||||
|
||||
systemd.network = {
|
||||
enable = true;
|
||||
|
||||
netdevs = lib.mkMerge (
|
||||
map (id: {
|
||||
"40-${util.bridgeName id}" = {
|
||||
"40-${bridgeName id}" = {
|
||||
netdevConfig = {
|
||||
Name = util.bridgeName id;
|
||||
Name = bridgeName id;
|
||||
Kind = "bridge";
|
||||
};
|
||||
};
|
||||
"40-vlan${toString id}" = {
|
||||
"40-${vlanName id}" = {
|
||||
netdevConfig = {
|
||||
Name = "vlan${toString id}";
|
||||
Name = vlanName id;
|
||||
Kind = "vlan";
|
||||
};
|
||||
vlanConfig.Id = id;
|
||||
@@ -67,25 +103,37 @@ in
|
||||
|
||||
networks = lib.mkMerge (
|
||||
[
|
||||
# The trunk carries only tagged frames and takes no address.
|
||||
{
|
||||
"10-${cfg.interface}" = {
|
||||
matchConfig.Name = cfg.interface;
|
||||
networkConfig.DHCP = "yes";
|
||||
vlan = map (id: "vlan${toString id}") cfg.ids;
|
||||
linkConfig.RequiredForOnline = "routable";
|
||||
"30-${cfg.trunk}" = {
|
||||
matchConfig.Name = cfg.trunk;
|
||||
networkConfig.LinkLocalAddressing = "no";
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
vlan = map vlanName cfg.ids;
|
||||
};
|
||||
}
|
||||
]
|
||||
++ map (id: {
|
||||
"40-vlan${toString id}" = {
|
||||
matchConfig.Name = "vlan${toString id}";
|
||||
networkConfig.Bridge = util.bridgeName id;
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
};
|
||||
"40-${util.bridgeName id}" = {
|
||||
matchConfig.Name = util.bridgeName id;
|
||||
# Enslaved to its bridge, carrying no address itself.
|
||||
"40-${vlanName id}" = {
|
||||
matchConfig.Name = vlanName id;
|
||||
networkConfig.Bridge = bridgeName id;
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
};
|
||||
# The management bridge carries the host address.
|
||||
# Other bridges provide L2 segments for guests.
|
||||
"40-${bridgeName id}" = lib.mkMerge [
|
||||
{
|
||||
matchConfig.Name = bridgeName id;
|
||||
linkConfig.RequiredForOnline = "no";
|
||||
}
|
||||
(lib.mkIf (cfg.management.vlan == id) (
|
||||
if cfg.management.address == null then
|
||||
{ networkConfig.DHCP = "yes"; }
|
||||
else
|
||||
{ address = [ cfg.management.address ]; }
|
||||
))
|
||||
];
|
||||
}) cfg.ids
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
util,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.network.vpn-client; # NOLINT(let_order)
|
||||
|
||||
vpnIcon = util.glyph [
|
||||
"db83"
|
||||
"dc9d"
|
||||
];
|
||||
|
||||
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
|
||||
interface=${lib.escapeShellArg cfg.interface}
|
||||
|
||||
if ! ${lib.getExe' pkgs.iproute2 "ip"} link show dev "$interface" >/dev/null 2>&1; then
|
||||
printf '{"text":"","class":"idle"}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$interface" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
|
||||
|
||||
${lib.getExe pkgs.jq} -cn \
|
||||
--arg text "${vpnIcon}" \
|
||||
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
|
||||
--arg class "active" \
|
||||
'{ text: $text, tooltip: $tooltip, class: $class }'
|
||||
'';
|
||||
cfg = config.modules.network.vpn-client;
|
||||
in
|
||||
{
|
||||
options.modules.network.vpn-client = {
|
||||
@@ -37,31 +13,61 @@ in
|
||||
interface = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "wg-home";
|
||||
description = "Names the WireGuard interface.";
|
||||
description = "WireGuard interface name.";
|
||||
};
|
||||
|
||||
address = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "10.23.100.3/32";
|
||||
description = "Sets the client tunnel address in CIDR notation.";
|
||||
description = "Client tunnel address in CIDR notation.";
|
||||
};
|
||||
|
||||
privateKeySecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "wireguard-home-private-key";
|
||||
description = "SOPS secret containing the WireGuard client private key.";
|
||||
};
|
||||
|
||||
autoStart = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to start the VPN tunnel during boot.";
|
||||
};
|
||||
|
||||
mtu = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = 1280;
|
||||
description = "Tunnel MTU, or null to use the WireGuard default.";
|
||||
};
|
||||
|
||||
sopsFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Names the encrypted file that contains the WireGuard private key.";
|
||||
description = "Encrypted secrets file that contains privateKeySecret.";
|
||||
};
|
||||
|
||||
peer = {
|
||||
publicKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Sets the WireGuard public key for the remote peer.";
|
||||
description = "WireGuard public key for the remote peer.";
|
||||
};
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "203.0.113.10:51820";
|
||||
description = "Sets the WireGuard endpoint for the remote peer.";
|
||||
description = "WireGuard endpoint for the remote peer.";
|
||||
};
|
||||
|
||||
allowedIPs = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "10.23.0.0/16" ];
|
||||
description = "Routes to send through the VPN tunnel.";
|
||||
};
|
||||
|
||||
persistentKeepalive = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = 25;
|
||||
description = "Persistent keepalive interval in seconds, or null to disable it.";
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -70,39 +76,28 @@ in
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.sopsFile != null;
|
||||
message = "Set modules.network.vpn-client.sopsFile when the VPN client is enabled.";
|
||||
message = "modules.network.vpn-client.sopsFile must name the encrypted secrets file containing the WireGuard private key.";
|
||||
}
|
||||
];
|
||||
|
||||
sops.secrets.wireguard-home-private-key.sopsFile = cfg.sopsFile;
|
||||
sops.secrets.${cfg.privateKeySecret}.sopsFile = cfg.sopsFile;
|
||||
|
||||
registry.shell-shortcut.${config.networking.hostName} = {
|
||||
vpn-on = "sudo systemctl start wireguard-${cfg.interface}.target";
|
||||
vpn-off = "sudo systemctl stop 'wireguard-${cfg.interface}*.service' wireguard-${cfg.interface}.target";
|
||||
systemd.targets."wireguard-${cfg.interface}" = lib.mkIf (!cfg.autoStart) {
|
||||
wantedBy = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
registry.waybar.${config.networking.hostName}."custom/vpn" = {
|
||||
order = 50;
|
||||
settings = {
|
||||
return-type = "json";
|
||||
exec = "${vpnStatus}";
|
||||
interval = 5;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.targets."wireguard-${cfg.interface}".wantedBy = lib.mkForce [ ];
|
||||
|
||||
networking.wireguard.interfaces.${cfg.interface} = {
|
||||
ips = [ cfg.address ];
|
||||
mtu = 1280;
|
||||
privateKeyFile = config.sops.secrets.wireguard-home-private-key.path;
|
||||
mtu = cfg.mtu;
|
||||
privateKeyFile = config.sops.secrets.${cfg.privateKeySecret}.path;
|
||||
|
||||
peers = [
|
||||
{
|
||||
inherit (cfg.peer) publicKey endpoint;
|
||||
allowedIPs = [ "10.23.0.0/16" ];
|
||||
persistentKeepalive = 25;
|
||||
({
|
||||
inherit (cfg.peer) publicKey endpoint allowedIPs;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.peer.persistentKeepalive != null) {
|
||||
inherit (cfg.peer) persistentKeepalive;
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.actualbudget; # NOLINT(let_order)
|
||||
cfg = config.modules.services.actualbudget;
|
||||
in
|
||||
{
|
||||
options.modules.services.actualbudget = {
|
||||
@@ -45,14 +45,14 @@ in
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
uid = config.registry."stable-uid".${config.networking.hostName}.actualbudget;
|
||||
inherit (cfg) group;
|
||||
group = cfg.group;
|
||||
};
|
||||
|
||||
services.actual = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
inherit (cfg) user;
|
||||
inherit (cfg) group;
|
||||
user = cfg.user;
|
||||
group = cfg.group;
|
||||
settings = {
|
||||
inherit (cfg) port dataDir;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.copyparty; # NOLINT(let_order)
|
||||
cfg = config.modules.services.copyparty;
|
||||
|
||||
volumeType = lib.types.submodule {
|
||||
options = {
|
||||
@@ -23,12 +23,6 @@ let
|
||||
description = "Copyparty access map from permission string to account names.";
|
||||
};
|
||||
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Serve this volume read-only.";
|
||||
};
|
||||
|
||||
flags = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
@@ -38,6 +32,37 @@ let
|
||||
};
|
||||
};
|
||||
|
||||
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
|
||||
|
||||
hasFlag = name: flags: lib.any (flag: lib.hasPrefix "${name}:" flag || lib.hasPrefix "${name}=" flag) flags;
|
||||
|
||||
defaultPermissionFlags = flags:
|
||||
lib.optionals (!(hasFlag "chmod_d" flags)) [ "chmod_d: 2770" ]
|
||||
++ lib.optionals (!(hasFlag "chmod_f" flags)) [ "chmod_f: 660" ];
|
||||
|
||||
renderFlags = flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") (flags ++ defaultPermissionFlags flags);
|
||||
|
||||
renderVolume = urlPath: volume: ''
|
||||
[${urlPath}]
|
||||
${volume.path}
|
||||
accs:
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
|
||||
${lib.optionalString (volume.flags != [ ]) ''
|
||||
flags:
|
||||
${renderFlags volume.flags}
|
||||
''}
|
||||
'';
|
||||
|
||||
renderTrustedSource = source: " xff-src: ${source}";
|
||||
|
||||
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
|
||||
rproxy: 1
|
||||
xff-hdr: x-forwarded-for
|
||||
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
|
||||
'';
|
||||
|
||||
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
|
||||
|
||||
configScript = pkgs.writeShellScript "copyparty-config" ''
|
||||
set -eu
|
||||
|
||||
@@ -63,40 +88,6 @@ let
|
||||
${renderedVolumes}
|
||||
EOF
|
||||
'';
|
||||
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
|
||||
|
||||
hasFlag =
|
||||
name: flags: lib.any (flag: lib.hasPrefix "${name}:" flag || lib.hasPrefix "${name}=" flag) flags;
|
||||
|
||||
defaultPermissionFlags =
|
||||
flags:
|
||||
lib.optionals (!(hasFlag "chmod_d" flags)) [ "chmod_d: 2770" ]
|
||||
++ lib.optionals (!(hasFlag "chmod_f" flags)) [ "chmod_f: 660" ];
|
||||
|
||||
renderFlags =
|
||||
flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") (flags ++ defaultPermissionFlags flags);
|
||||
|
||||
renderVolume = urlPath: volume: ''
|
||||
[${urlPath}]
|
||||
${volume.path}
|
||||
accs:
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
|
||||
${lib.optionalString (volume.flags != [ ]) ''
|
||||
flags:
|
||||
${renderFlags volume.flags}
|
||||
''}
|
||||
'';
|
||||
|
||||
renderTrustedSource = source: " xff-src: ${source}";
|
||||
|
||||
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
|
||||
rproxy: 1
|
||||
xff-hdr: x-forwarded-for
|
||||
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
|
||||
'';
|
||||
|
||||
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
|
||||
|
||||
in
|
||||
{
|
||||
options.modules.services.copyparty = {
|
||||
@@ -176,7 +167,7 @@ in
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
uid = config.registry."stable-uid".${config.networking.hostName}.copyparty;
|
||||
inherit (cfg) group;
|
||||
group = cfg.group;
|
||||
home = cfg.stateDir;
|
||||
};
|
||||
|
||||
@@ -205,10 +196,7 @@ in
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ReadWritePaths = [
|
||||
cfg.stateDir
|
||||
]
|
||||
++ map (volume: volume.path) (lib.filter (volume: !volume.readOnly) (lib.attrValues cfg.volumes));
|
||||
ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.modules.services.games.palworld = {
|
||||
enable = lib.mkEnableOption "Palworld dedicated server";
|
||||
|
||||
steamDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/steamcmd";
|
||||
description = "Directory where SteamCMD stores metadata and cache files.";
|
||||
};
|
||||
|
||||
installDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/servers/palworld/tracion";
|
||||
description = "Directory where SteamCMD installs the Palworld server files.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/state/palworld/tracion";
|
||||
description = "Directory where Palworld stores durable server state.";
|
||||
};
|
||||
|
||||
serverName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "palworld";
|
||||
description = "Name shown for the Palworld server.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8211;
|
||||
description = "UDP port for the Palworld server.";
|
||||
};
|
||||
|
||||
publicIP = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Public IP address advertised by the Palworld server.";
|
||||
};
|
||||
|
||||
maxPlayers = lib.mkOption {
|
||||
type = lib.types.ints.between 1 32;
|
||||
default = 32;
|
||||
description = "Maximum player count for the Palworld server.";
|
||||
};
|
||||
|
||||
publicLobby = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Advertise the server in Palworld's public lobby.";
|
||||
};
|
||||
|
||||
autoStart = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Start the Palworld server after the guest reaches network readiness.";
|
||||
};
|
||||
|
||||
rcon = {
|
||||
enable = lib.mkEnableOption "Palworld RCON" // {
|
||||
default = true;
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 25575;
|
||||
description = "TCP port for Palworld RCON.";
|
||||
};
|
||||
};
|
||||
|
||||
passwordFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Runtime file containing the Palworld server password.";
|
||||
};
|
||||
|
||||
adminPasswordFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Runtime file containing the Palworld admin password.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf config.modules.services.games.palworld.enable (
|
||||
let
|
||||
service = config.modules.services.games.palworld;
|
||||
savedDir = "${service.installDir}/Pal/Saved";
|
||||
startScript = pkgs.writeShellScript "palworld-start" ''
|
||||
set -eu
|
||||
|
||||
${pkgs.coreutils}/bin/install -d \
|
||||
-m 0770 \
|
||||
${lib.escapeShellArg service.stateDir} \
|
||||
${lib.escapeShellArg (builtins.dirOf savedDir)}
|
||||
|
||||
if [ -L ${lib.escapeShellArg savedDir} ]; then
|
||||
current_target="$(${pkgs.coreutils}/bin/readlink ${lib.escapeShellArg savedDir})"
|
||||
if [ "$current_target" != ${lib.escapeShellArg service.stateDir} ]; then
|
||||
${pkgs.coreutils}/bin/rm ${lib.escapeShellArg savedDir}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -d ${lib.escapeShellArg savedDir} ] && [ ! -L ${lib.escapeShellArg savedDir} ]; then
|
||||
if ${pkgs.findutils}/bin/find ${lib.escapeShellArg service.stateDir} -mindepth 1 -maxdepth 1 | ${pkgs.gnugrep}/bin/grep -q .; then
|
||||
${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir}
|
||||
else
|
||||
${pkgs.coreutils}/bin/cp -a ${lib.escapeShellArg savedDir}/. ${lib.escapeShellArg service.stateDir}/
|
||||
${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -e ${lib.escapeShellArg savedDir} ]; then
|
||||
${pkgs.coreutils}/bin/ln -s ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir}
|
||||
fi
|
||||
|
||||
password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.passwordFile})"
|
||||
admin_password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.adminPasswordFile})"
|
||||
|
||||
exec ./PalServer.sh \
|
||||
"$@" \
|
||||
"-serverpassword=$password" \
|
||||
"-adminpassword=$admin_password"
|
||||
'';
|
||||
in
|
||||
{
|
||||
modules.services.steamcmd = {
|
||||
enable = true;
|
||||
inherit (service) steamDir installDir stateDir;
|
||||
serviceName = "palworld-${lib.toLower service.serverName}";
|
||||
appId = 2394010;
|
||||
inherit (service) autoStart;
|
||||
executable = "${startScript}";
|
||||
arguments = [
|
||||
"-useperfthreads"
|
||||
"-NoAsyncLoadingThread"
|
||||
"-UseMultithreadForDS"
|
||||
"-port=${toString service.port}"
|
||||
"-publicport=${toString service.port}"
|
||||
"-servername=${service.serverName}"
|
||||
"-players=${toString service.maxPlayers}"
|
||||
]
|
||||
++ lib.optional service.publicLobby "-publiclobby"
|
||||
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
|
||||
++ lib.optional service.rcon.enable "-rcon";
|
||||
udpPorts = [ service.port ];
|
||||
tcpPorts = [ service.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.modules.services.games.valheim = {
|
||||
enable = lib.mkEnableOption "Valheim dedicated server";
|
||||
|
||||
steamDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/steamcmd";
|
||||
description = "Directory where SteamCMD stores metadata and cache files.";
|
||||
};
|
||||
|
||||
installDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/servers/valheim/main";
|
||||
description = "Directory where SteamCMD installs the Valheim server files.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/data/state/valheim/main";
|
||||
description = "Directory where Valheim stores world data.";
|
||||
};
|
||||
|
||||
serverName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "valheim";
|
||||
description = "Name shown for the Valheim server.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 2456;
|
||||
description = "Base UDP port for the Valheim server.";
|
||||
};
|
||||
|
||||
passwordFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Runtime file containing the Valheim server password.";
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
config = lib.mkIf config.modules.services.games.valheim.enable (
|
||||
let
|
||||
service = config.modules.services.games.valheim;
|
||||
startScript = pkgs.writeShellScript "valheim-start" ''
|
||||
set -eu
|
||||
|
||||
password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.passwordFile})"
|
||||
exec ./valheim_server.x86_64 "$@" -password "$password"
|
||||
'';
|
||||
in
|
||||
{
|
||||
modules.services.steamcmd = {
|
||||
enable = true;
|
||||
inherit (service) steamDir installDir stateDir;
|
||||
serviceName = "valheim";
|
||||
appId = 896660;
|
||||
executable = "${startScript}";
|
||||
arguments = [
|
||||
"-nographics"
|
||||
"-batchmode"
|
||||
"-name"
|
||||
service.serverName
|
||||
"-port"
|
||||
(toString service.port)
|
||||
"-world"
|
||||
service.serverName
|
||||
"-savedir"
|
||||
service.stateDir
|
||||
"-public"
|
||||
"0"
|
||||
"-modifier"
|
||||
"raids"
|
||||
"none"
|
||||
];
|
||||
udpPorts = [
|
||||
service.port
|
||||
(service.port + 1)
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
registry,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.homepage; # NOLINT(let_order)
|
||||
|
||||
derivedAllowedHosts = lib.unique (
|
||||
lib.optional (selfRoute != null) selfRoute.domain
|
||||
++ lib.optionals (selfAddress != null) [
|
||||
selfAddress
|
||||
"${selfAddress}:${toString cfg.listenPort}"
|
||||
]
|
||||
);
|
||||
selfRoute = selfEntry.route or null;
|
||||
|
||||
selfEntry = registry.join cfg.hostName cfg.entryName;
|
||||
selfAddress = selfEntry.address or null;
|
||||
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
|
||||
|
||||
mkTab =
|
||||
tab:
|
||||
let
|
||||
sections = entriesByTab.${tab};
|
||||
in
|
||||
{
|
||||
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
|
||||
};
|
||||
|
||||
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
|
||||
|
||||
insertEntry =
|
||||
acc: item:
|
||||
acc
|
||||
// {
|
||||
${item.homepage.tab} = (acc.${item.homepage.tab} or { }) // {
|
||||
${item.homepage.section} = (acc.${item.homepage.tab}.${item.homepage.section} or [ ]) ++ [
|
||||
{ ${item.homepage.name} = mkServiceAttrs item; }
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
|
||||
|
||||
homepageEntries = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: entries:
|
||||
lib.mapAttrsToList (entry: homepage: {
|
||||
inherit host entry homepage;
|
||||
composite = registry.join host entry;
|
||||
}) entries
|
||||
) registry.homepage
|
||||
);
|
||||
|
||||
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
|
||||
|
||||
tabOrder = [
|
||||
"Apps"
|
||||
"Download"
|
||||
"System"
|
||||
];
|
||||
|
||||
mkRegistryPath =
|
||||
registryName: host: entry:
|
||||
"registry.${registryName}.${host}.${entry}";
|
||||
|
||||
mkHref =
|
||||
item:
|
||||
if item.homepage.href != null then
|
||||
item.homepage.href
|
||||
else if (item.composite.route or null) != null then
|
||||
if item.composite.route.protocol == "https" then
|
||||
"https://${item.composite.route.domain}"
|
||||
else if item.composite.route.protocol == "http" then
|
||||
"http://${item.composite.route.domain}"
|
||||
else
|
||||
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching HTTP route entry"
|
||||
else
|
||||
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry";
|
||||
|
||||
mkServiceAttrs =
|
||||
item:
|
||||
lib.filterAttrs (_: value: value != null) {
|
||||
href = mkHref item;
|
||||
inherit (item.homepage) description icon;
|
||||
};
|
||||
|
||||
sectionOrderByTab = {
|
||||
Apps = [
|
||||
"Media"
|
||||
"Files"
|
||||
"Other"
|
||||
];
|
||||
Download = [
|
||||
"Library Managers"
|
||||
"Other Utilities"
|
||||
"Download Clients"
|
||||
];
|
||||
System = [
|
||||
"Monitoring"
|
||||
"Network"
|
||||
"Machines"
|
||||
"Docker"
|
||||
];
|
||||
};
|
||||
|
||||
orderedSections =
|
||||
tab: sections:
|
||||
let
|
||||
sectionOrder = sectionOrderByTab.${tab} or [ ];
|
||||
in
|
||||
sectionOrder ++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
|
||||
|
||||
in
|
||||
{
|
||||
options.modules.services.homepage = {
|
||||
enable = lib.mkEnableOption "Homepage Dashboard";
|
||||
|
||||
listenPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8082;
|
||||
description = "Port for Homepage to bind to.";
|
||||
};
|
||||
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Host registry key that owns the Homepage guest entry.";
|
||||
};
|
||||
|
||||
entryName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "homepage";
|
||||
description = "Registry entry name for this Homepage service.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.listOf lib.types.str);
|
||||
default = null;
|
||||
description = "Host headers accepted by Homepage. Defaults to matching address and route registry entries.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/run/secrets/homepage.env";
|
||||
description = "Environment file passed to Homepage.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.homepage-dashboard = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
inherit (cfg) listenPort;
|
||||
environmentFiles = [ cfg.environmentFile ];
|
||||
allowedHosts = lib.concatStringsSep "," (
|
||||
if cfg.allowedHosts == null then derivedAllowedHosts else cfg.allowedHosts
|
||||
);
|
||||
services = homepageServices;
|
||||
widgets = [
|
||||
{
|
||||
greeting = {
|
||||
text_size = "xl";
|
||||
text = "tracion";
|
||||
};
|
||||
}
|
||||
{
|
||||
openmeteo = {
|
||||
units = "metric";
|
||||
latitude = "{{HOMEPAGE_VAR_LATITUDE}}";
|
||||
longitude = "{{HOMEPAGE_VAR_LONGITUDE}}";
|
||||
cache = 5;
|
||||
};
|
||||
}
|
||||
{
|
||||
datetime = {
|
||||
text_size = "xl";
|
||||
locale = "en";
|
||||
format = {
|
||||
dateStyle = "full";
|
||||
timeStyle = "medium";
|
||||
hour12 = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
settings = {
|
||||
title = "Tracion";
|
||||
theme = "dark";
|
||||
color = "slate";
|
||||
showStats = false;
|
||||
hideVersion = true;
|
||||
bookmarksStyle = "icons";
|
||||
useEqualHeights = true;
|
||||
layout = {
|
||||
Apps = {
|
||||
style = "row";
|
||||
columns = 1;
|
||||
tab = "Apps";
|
||||
Media = {
|
||||
style = "row";
|
||||
columns = 2;
|
||||
};
|
||||
Files = {
|
||||
style = "row";
|
||||
columns = 2;
|
||||
};
|
||||
Other = {
|
||||
style = "row";
|
||||
columns = 3;
|
||||
iconsOnly = true;
|
||||
};
|
||||
};
|
||||
Download = {
|
||||
style = "row";
|
||||
columns = 1;
|
||||
tab = "Download";
|
||||
};
|
||||
System = {
|
||||
tab = "System";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.navidrome; # NOLINT(let_order)
|
||||
in
|
||||
{
|
||||
options.modules.services.navidrome = {
|
||||
enable = lib.mkEnableOption "Navidrome";
|
||||
|
||||
dataFolder = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/navidrome";
|
||||
readOnly = true;
|
||||
description = "Path for Navidrome's `DataFolder` setting.";
|
||||
};
|
||||
|
||||
musicFolder = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/music";
|
||||
readOnly = true;
|
||||
description = "Path for Navidrome's `MusicFolder` setting.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/run/secrets/navidrome.env";
|
||||
readOnly = true;
|
||||
description = "Path to Navidrome's environment file.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
registry."stable-uid".${config.networking.hostName}.navidrome = 20004;
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.enable {
|
||||
users.users.navidrome = {
|
||||
isSystemUser = true;
|
||||
uid = config.registry."stable-uid".${config.networking.hostName}.navidrome;
|
||||
group = "storage";
|
||||
home = cfg.dataFolder;
|
||||
};
|
||||
|
||||
services.navidrome = {
|
||||
enable = true;
|
||||
user = "navidrome";
|
||||
group = "storage";
|
||||
openFirewall = true;
|
||||
inherit (cfg) environmentFile;
|
||||
settings = {
|
||||
Address = "0.0.0.0";
|
||||
Port = 4533;
|
||||
DataFolder = cfg.dataFolder;
|
||||
MusicFolder = cfg.musicFolder;
|
||||
EnableSharing = true;
|
||||
};
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -5,61 +5,33 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.modules.services.reverse-proxy;
|
||||
|
||||
generatedUdpStreams = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
entry: route: lib.optionals (route.protocol == "udp") (map (mkUdpStream host entry) route.ports)
|
||||
) routes
|
||||
)
|
||||
) registry.route
|
||||
);
|
||||
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
|
||||
|
||||
mkUdpStream =
|
||||
host: entry: port:
|
||||
mkBackend = address: route: "http://${address}:${toString route.port}";
|
||||
|
||||
mkVirtualHost = host: entry:
|
||||
let
|
||||
address =
|
||||
compositeEntry.address
|
||||
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
|
||||
compositeEntry = registry.join host entry;
|
||||
route = compositeEntry.route;
|
||||
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
|
||||
in
|
||||
{
|
||||
inherit
|
||||
host
|
||||
entry
|
||||
port
|
||||
address
|
||||
;
|
||||
inherit host entry;
|
||||
name = route.domain;
|
||||
value = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/".proxyPass = mkBackend address route;
|
||||
};
|
||||
};
|
||||
|
||||
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
|
||||
server {
|
||||
listen ${toString stream.port} udp;
|
||||
proxy_pass ${stream.address}:${toString stream.port};
|
||||
}
|
||||
'') generatedUdpStreams;
|
||||
generatedVirtualHosts =
|
||||
if duplicateDomainMessages != [ ] then
|
||||
throw (builtins.head duplicateDomainMessages)
|
||||
else
|
||||
lib.listToAttrs generatedVirtualHostList;
|
||||
|
||||
duplicateDomainMessages = lib.concatLists (
|
||||
generatedVirtualHostList = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
domain: virtualHosts:
|
||||
if lib.length virtualHosts > 1 then
|
||||
[
|
||||
"duplicate route domain ${domain} in ${
|
||||
lib.concatMapStringsSep " and " (
|
||||
virtualHost: mkRegistryPath virtualHost.registryName virtualHost.host virtualHost.entry
|
||||
) virtualHosts
|
||||
}"
|
||||
]
|
||||
else
|
||||
[ ]
|
||||
) virtualHostsByDomain
|
||||
host: routes:
|
||||
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry) routes
|
||||
) registry.route
|
||||
);
|
||||
|
||||
virtualHostsByDomain = lib.foldl' (
|
||||
@@ -70,69 +42,34 @@ let
|
||||
}
|
||||
) { } generatedVirtualHostList;
|
||||
|
||||
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
|
||||
|
||||
generatedHttpsVirtualHostList = lib.concatLists (
|
||||
duplicateDomainMessages = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
|
||||
lib.filterAttrs (_entry: route: route.protocol == "https") routes
|
||||
)
|
||||
) registry.route
|
||||
domain: virtualHosts:
|
||||
if lib.length virtualHosts > 1 then
|
||||
[
|
||||
"duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath "route" virtualHost.host virtualHost.entry) virtualHosts}"
|
||||
]
|
||||
else
|
||||
[ ]
|
||||
) virtualHostsByDomain
|
||||
);
|
||||
|
||||
mkVirtualHost =
|
||||
host: entry: mode:
|
||||
let
|
||||
isHttp = mode == "http";
|
||||
address =
|
||||
compositeEntry.address
|
||||
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
|
||||
compositeEntry = registry.join host entry;
|
||||
inherit (compositeEntry) route;
|
||||
inherit (route) port;
|
||||
in
|
||||
{
|
||||
inherit host entry;
|
||||
registryName = "route";
|
||||
name = route.domain;
|
||||
value = {
|
||||
forceSSL = !isHttp;
|
||||
enableACME = !isHttp;
|
||||
extraConfig = lib.optionalString (route.clientMaxBodySize != null) ''
|
||||
client_max_body_size ${route.clientMaxBodySize};
|
||||
'';
|
||||
locations."/".proxyPass = mkBackend address port;
|
||||
};
|
||||
};
|
||||
|
||||
mkBackend = address: port: "http://${address}:${toString port}";
|
||||
|
||||
generatedHttpVirtualHostList = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
host: routes:
|
||||
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") (
|
||||
lib.filterAttrs (_entry: route: route.protocol == "http") routes
|
||||
)
|
||||
) registry.route
|
||||
);
|
||||
|
||||
mkRegistryPath =
|
||||
registryName: host: entry:
|
||||
"registry.${registryName}.${host}.${entry}";
|
||||
|
||||
generatedVirtualHosts =
|
||||
if duplicateDomainMessages != [ ] then
|
||||
throw (builtins.head duplicateDomainMessages)
|
||||
else
|
||||
lib.listToAttrs generatedVirtualHostList;
|
||||
in
|
||||
{
|
||||
options.modules.services.reverse-proxy = {
|
||||
enable = lib.mkEnableOption "the public edge reverse proxy";
|
||||
};
|
||||
|
||||
config = lib.mkIf config.modules.services.reverse-proxy.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
networking.firewall.allowedUDPPorts = map (stream: stream.port) generatedUdpStreams;
|
||||
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
@@ -152,7 +89,6 @@ in
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = true;
|
||||
streamConfig = lib.mkIf (generatedUdpStreams != [ ]) generatedStreamConfig;
|
||||
virtualHosts = generatedVirtualHosts;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -59,18 +59,6 @@
|
||||
default = [ ];
|
||||
description = "UDP ports opened for the game service.";
|
||||
};
|
||||
|
||||
tcpPorts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.port;
|
||||
default = [ ];
|
||||
description = "TCP ports opened for the game service.";
|
||||
};
|
||||
|
||||
autoStart = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Start the game service after the guest reaches network readiness.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
@@ -80,10 +68,10 @@
|
||||
|
||||
(lib.mkIf config.modules.services.steamcmd.enable (
|
||||
let
|
||||
group = "storage";
|
||||
|
||||
service = config.modules.services.steamcmd;
|
||||
user = "steamcmd";
|
||||
group = "storage";
|
||||
|
||||
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
|
||||
set -eu
|
||||
|
||||
@@ -125,11 +113,11 @@
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${service.steamDir} 0770 ${user} ${group} - -"
|
||||
"Z ${service.steamDir} 0770 ${user} ${group} - -"
|
||||
"z ${service.steamDir} 0770 ${user} ${group} - -"
|
||||
"d ${service.installDir} 0770 ${user} ${group} - -"
|
||||
"Z ${service.installDir} 0770 ${user} ${group} - -"
|
||||
"z ${service.installDir} 0770 ${user} ${group} - -"
|
||||
"d ${service.stateDir} 0770 ${user} ${group} - -"
|
||||
"Z ${service.stateDir} 0770 ${user} ${group} - -"
|
||||
"z ${service.stateDir} 0770 ${user} ${group} - -"
|
||||
];
|
||||
|
||||
systemd.services = {
|
||||
@@ -144,18 +132,6 @@
|
||||
WorkingDirectory = "-${service.steamDir}";
|
||||
ExecStartPre = "+${prepareScript}";
|
||||
ExecStart = updateScript;
|
||||
TimeoutStartSec = "15min";
|
||||
};
|
||||
};
|
||||
|
||||
"${service.serviceName}-autostart" = {
|
||||
description = "Queue ${service.serviceName} game server startup";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = lib.mkIf service.autoStart [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${pkgs.systemd}/bin/systemctl --no-block start ${service.serviceName}.service";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -165,25 +141,24 @@
|
||||
"network-online.target"
|
||||
"steamcmd-${service.serviceName}-update.service"
|
||||
];
|
||||
wants = [
|
||||
"network-online.target"
|
||||
"steamcmd-${service.serviceName}-update.service"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = user;
|
||||
Group = group;
|
||||
WorkingDirectory = "-${service.installDir}";
|
||||
ExecStartPre = "+${prepareScript}";
|
||||
ExecStartPre = [
|
||||
"+${prepareScript}"
|
||||
updateScript
|
||||
];
|
||||
ExecStart = startScript;
|
||||
Restart = "on-failure";
|
||||
TimeoutStartSec = "15min";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedUDPPorts = service.udpPorts;
|
||||
networking.firewall.allowedTCPPorts = service.tcpPorts;
|
||||
}
|
||||
))
|
||||
];
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
}:
|
||||
# Bazel for the primary user's shell environment.
|
||||
let
|
||||
cfg = config.modules.shell.bazel;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.bazel.enable =
|
||||
lib.mkEnableOption "Bazel for the primary user's shell environment";
|
||||
options.modules.shell.bazel.enable = lib.mkEnableOption "Bazel for the primary user's shell environment";
|
||||
|
||||
config = lib.mkIf config.modules.shell.bazel.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.bazel ];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
}:
|
||||
# direnv for the primary user, configured through home-manager.
|
||||
let
|
||||
cfg = config.modules.shell.direnv;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.direnv.enable =
|
||||
lib.mkEnableOption "direnv for the primary user, with nix-direnv shell caching";
|
||||
|
||||
config = lib.mkIf config.modules.shell.direnv.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.programs.direnv = {
|
||||
enable = true;
|
||||
silent = true;
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
}:
|
||||
# fish for the primary user, configured through home-manager.
|
||||
let
|
||||
cfg = config.modules.shell.fish; # NOLINT(let_order)
|
||||
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
|
||||
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
|
||||
cfg = config.modules.shell.fish;
|
||||
user = config.user.name;
|
||||
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
|
||||
envVars = lib.attrByPath [ config.networking.hostName ] { } config.registry.env-var;
|
||||
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
|
||||
exportEnvVar = name: value: ''
|
||||
set -gx ${name} ${lib.escapeShellArg value}
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.modules.shell.fish = {
|
||||
@@ -95,14 +99,17 @@ in
|
||||
};
|
||||
|
||||
deploy-to = {
|
||||
description = "Build the current checkout locally, import it on a host, and activate it";
|
||||
description = "Deploy the current dotfiles branch to a host through target-local nixos-rebuild";
|
||||
body = builtins.readFile ./functions/deploy-to.fish;
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
# Rendered by home-manager into ~/.config/fish/config.fish.
|
||||
interactiveShellInit = builtins.readFile ./config.fish;
|
||||
interactiveShellInit = lib.concatStringsSep "\n" (
|
||||
(lib.mapAttrsToList exportEnvVar envVars)
|
||||
++ [ (builtins.readFile ./config.fish) ]
|
||||
);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
if test (count $argv) -ne 1
|
||||
echo "usage: deploy-to <host>" >&2
|
||||
if test (count $argv) -lt 1; or test (count $argv) -gt 2
|
||||
echo "usage: deploy-to <host> [branch]" >&2
|
||||
return 2
|
||||
end
|
||||
|
||||
set host $argv[1]
|
||||
set branch $argv[2]
|
||||
set branch_source explicit
|
||||
|
||||
if test -z "$branch"
|
||||
set branch_source current
|
||||
set branch (git branch --show-current)
|
||||
end
|
||||
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||
or begin
|
||||
@@ -11,85 +18,76 @@ or begin
|
||||
return 2
|
||||
end
|
||||
|
||||
set repository_root (git rev-parse --show-toplevel)
|
||||
or return $status
|
||||
|
||||
set untracked_nix_files (git -C $repository_root ls-files --others --exclude-standard -- '*.nix' flake.nix flake.lock)
|
||||
if test (count $untracked_nix_files) -gt 0
|
||||
echo "deploy-to: warning: untracked Nix files are not part of Git flake evaluation" >&2
|
||||
printf ' %s\n' $untracked_nix_files >&2
|
||||
end
|
||||
|
||||
set installable "$repository_root#nixosConfigurations.$host.config.system.build.toplevel"
|
||||
|
||||
echo "deploy-to: building $installable" >&2
|
||||
set system_path (nix build --no-link --print-out-paths $installable)
|
||||
or begin
|
||||
echo "deploy-to: build failed for $host" >&2
|
||||
return $status
|
||||
end
|
||||
|
||||
if test (count $system_path) -ne 1
|
||||
echo "deploy-to: expected one build output, got "(count $system_path) >&2
|
||||
set untracked_lines (git status --porcelain --untracked-files=normal | string match -r '^\?\? ')
|
||||
if test (count $untracked_lines) -gt 0
|
||||
echo "deploy-to: refusing to deploy untracked files" >&2
|
||||
echo "deploy-to: git add new files that Nix must see, or remove them" >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
set closure_paths (nix-store -qR $system_path)
|
||||
or begin
|
||||
echo "deploy-to: closure query failed for $host" >&2
|
||||
return $status
|
||||
end
|
||||
git fetch origin
|
||||
or return $status
|
||||
|
||||
echo "deploy-to: checking which closure paths $host is missing" >&2
|
||||
set missing_paths (printf '%s\n' $closure_paths | ssh $host 'xargs -r nix-store --check-validity --print-invalid')
|
||||
or begin
|
||||
echo "deploy-to: remote closure check failed for $host" >&2
|
||||
return $status
|
||||
end
|
||||
|
||||
if test (count $missing_paths) -gt 0
|
||||
set nar_file (mktemp --suffix=.nar)
|
||||
or return $status
|
||||
set remote_nar "/tmp/deploy-to-$host-"(date +%s)"-$fish_pid.nar"
|
||||
|
||||
echo "deploy-to: exporting "(count $missing_paths)" missing closure paths for $system_path" >&2
|
||||
nix-store --export $missing_paths >$nar_file
|
||||
or begin
|
||||
set export_status $status
|
||||
rm -f $nar_file
|
||||
echo "deploy-to: closure export failed for $host" >&2
|
||||
return $export_status
|
||||
if test -z "$branch"; or not git rev-parse --verify --quiet refs/remotes/origin/$branch >/dev/null
|
||||
if test "$branch_source" = explicit
|
||||
echo "deploy-to: origin/$branch does not exist" >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
echo "deploy-to: copying closure archive to $host:$remote_nar" >&2
|
||||
scp $nar_file $host:$remote_nar
|
||||
set branch_source upstream
|
||||
set upstream_ref (git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)
|
||||
set branch (string replace -r '^origin/' '' -- $upstream_ref)
|
||||
end
|
||||
|
||||
if test -z "$branch"; or not git rev-parse --verify --quiet refs/remotes/origin/$branch >/dev/null
|
||||
set branch_source default
|
||||
set origin_head (git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
|
||||
set branch (string replace -r '^origin/' '' -- $origin_head)
|
||||
end
|
||||
|
||||
if test -z "$branch"
|
||||
echo "deploy-to: could not determine a deploy branch" >&2
|
||||
return 2
|
||||
end
|
||||
|
||||
set remote_ref refs/remotes/origin/$branch
|
||||
git rev-parse --verify --quiet $remote_ref >/dev/null
|
||||
or begin
|
||||
echo "deploy-to: origin/$branch does not exist" >&2
|
||||
return 1
|
||||
end
|
||||
|
||||
set has_local_changes false
|
||||
if not git diff --quiet $remote_ref --
|
||||
set has_local_changes true
|
||||
end
|
||||
|
||||
set escaped_branch (string escape -- $branch)
|
||||
set escaped_host (string escape -- $host)
|
||||
set remote_command "cd ~/dev && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && git clean -fd"
|
||||
|
||||
set patch_file ""
|
||||
set remote_patch_file ""
|
||||
if test "$has_local_changes" = true
|
||||
set patch_file (mktemp)
|
||||
set remote_patch_file "/tmp/deploy-to-$host.patch"
|
||||
git diff --binary $remote_ref -- >$patch_file
|
||||
or begin
|
||||
rm -f $patch_file
|
||||
return $status
|
||||
end
|
||||
|
||||
scp $patch_file $host:$remote_patch_file
|
||||
set scp_status $status
|
||||
rm -f $nar_file
|
||||
rm -f $patch_file
|
||||
if test $scp_status -ne 0
|
||||
echo "deploy-to: closure archive copy failed for $host" >&2
|
||||
return $scp_status
|
||||
end
|
||||
|
||||
echo "deploy-to: importing closure on $host" >&2
|
||||
ssh -t $host "sudo sh -c 'nix-store --import < $remote_nar' && rm -f $remote_nar"
|
||||
or begin
|
||||
echo "deploy-to: closure import failed for $host" >&2
|
||||
return $status
|
||||
end
|
||||
else
|
||||
echo "deploy-to: $host already has the full closure for $system_path" >&2
|
||||
set escaped_remote_patch_file (string escape -- $remote_patch_file)
|
||||
set remote_command "$remote_command && git apply --index $escaped_remote_patch_file && rm -f $escaped_remote_patch_file"
|
||||
end
|
||||
|
||||
echo "deploy-to: recording $system_path as the system profile on $host" >&2
|
||||
ssh -t $host sudo nix-env --profile /nix/var/nix/profiles/system --set $system_path
|
||||
or begin
|
||||
echo "deploy-to: system profile update failed for $host" >&2
|
||||
return $status
|
||||
end
|
||||
set remote_command "$remote_command && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
|
||||
|
||||
echo "deploy-to: activating $system_path on $host" >&2
|
||||
ssh -t $host sudo "$system_path/bin/switch-to-configuration" switch
|
||||
or begin
|
||||
echo "deploy-to: activation failed for $host" >&2
|
||||
return $status
|
||||
end
|
||||
ssh -t $host $remote_command
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
}:
|
||||
# git for the primary user, configured through home-manager.
|
||||
let
|
||||
cfg = config.modules.shell.git;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.git.enable =
|
||||
lib.mkEnableOption "git for the primary user, carrying the operator's commit identity";
|
||||
|
||||
config = lib.mkIf config.modules.shell.git.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.programs.git = {
|
||||
enable = true;
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
}:
|
||||
# gitea-axi for the primary user, installed through its own home-manager module.
|
||||
let
|
||||
cfg = config.modules.shell.gitea-axi;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.gitea-axi.enable =
|
||||
lib.mkEnableOption "gitea-axi, an agent-ergonomic CLI for Gitea issues and pull requests";
|
||||
|
||||
config = lib.mkIf config.modules.shell.gitea-axi.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.sharedModules = [ inputs.gitea-axi.homeModules.default ];
|
||||
home-manager.users.${user}.programs.gitea-axi.enable = true;
|
||||
};
|
||||
|
||||
@@ -6,58 +6,42 @@
|
||||
}:
|
||||
# Herdr, a terminal multiplexer for coding agents.
|
||||
let
|
||||
cfg = config.modules.shell.herdr;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.herdr.enable =
|
||||
lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
|
||||
options.modules.shell.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
|
||||
|
||||
config = lib.mkIf config.modules.shell.herdr.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
|
||||
|
||||
home-manager.users.${user} = lib.mkMerge [
|
||||
{
|
||||
home.packages = [ pkgs.herdr ];
|
||||
home-manager.users.${user} = {
|
||||
home.packages = [ pkgs.herdr ];
|
||||
|
||||
xdg.configFile."herdr/config.toml".text = ''
|
||||
onboarding = false
|
||||
xdg.configFile."herdr/config.toml".text = ''
|
||||
[keys]
|
||||
prefix = "ctrl+space"
|
||||
detach = "prefix+d"
|
||||
reload_config = "prefix+r"
|
||||
new_workspace = "prefix+c"
|
||||
new_tab = "prefix+shift+c"
|
||||
rename_workspace = "prefix+comma"
|
||||
rename_tab = "prefix+<"
|
||||
split_vertical = "prefix+backslash"
|
||||
split_horizontal = "prefix+minus"
|
||||
switch_workspace = "prefix+1..9"
|
||||
switch_tab = "prefix+shift+1..9"
|
||||
focus_pane_left = "prefix+h"
|
||||
focus_pane_down = "prefix+j"
|
||||
focus_pane_up = "prefix+k"
|
||||
focus_pane_right = "prefix+l"
|
||||
|
||||
[keys]
|
||||
prefix = "ctrl+space"
|
||||
detach = "prefix+d"
|
||||
reload_config = "prefix+r"
|
||||
new_workspace = "prefix+c"
|
||||
new_tab = "prefix+shift+c"
|
||||
rename_workspace = "prefix+comma"
|
||||
rename_tab = "prefix+<"
|
||||
split_vertical = "prefix+backslash"
|
||||
split_horizontal = "prefix+minus"
|
||||
switch_workspace = "prefix+1..9"
|
||||
switch_tab = "prefix+shift+1..9"
|
||||
focus_pane_left = "prefix+h"
|
||||
focus_pane_down = "prefix+j"
|
||||
focus_pane_up = "prefix+k"
|
||||
focus_pane_right = "prefix+l"
|
||||
[ui]
|
||||
prompt_new_tab_name = false
|
||||
|
||||
[ui]
|
||||
prompt_new_tab_name = false
|
||||
|
||||
[experimental]
|
||||
kitty_graphics = true
|
||||
'';
|
||||
}
|
||||
(lib.mkIf config.modules.shell.pi.enable {
|
||||
home.file.".pi/agent/extensions/herdr-agent-state.ts" = {
|
||||
source = "${pkgs.herdr.src}/src/integration/assets/pi/herdr-agent-state.ts";
|
||||
force = true;
|
||||
};
|
||||
})
|
||||
(lib.mkIf config.modules.shell.omp.enable {
|
||||
home.file.".omp/agent/extensions/herdr-omp-agent-state.ts" = {
|
||||
source = "${pkgs.herdr.src}/src/integration/assets/omp/herdr-agent-state.ts";
|
||||
force = true;
|
||||
};
|
||||
})
|
||||
];
|
||||
[experimental]
|
||||
kitty_graphics = true
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
}:
|
||||
# Neovim for the primary user, configured declaratively through nixvim.
|
||||
let
|
||||
cfg = config.modules.shell.nvim;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.nvim.enable =
|
||||
lib.mkEnableOption "Neovim, configured declaratively via nixvim";
|
||||
options.modules.shell.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
|
||||
|
||||
config = lib.mkIf config.modules.shell.nvim.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
registry.shell-shortcut.${config.networking.hostName} = {
|
||||
vi = "nvim";
|
||||
vim = "nvim";
|
||||
@@ -31,7 +31,6 @@ in
|
||||
ripgrep # backs the picker's live grep
|
||||
fd # backs the picker's file finder
|
||||
wl-clipboard # provides a non-blocking clipboard provider on Wayland
|
||||
nixd # provides Nix language-server support
|
||||
];
|
||||
|
||||
globals.mapleader = " ";
|
||||
@@ -189,8 +188,6 @@ in
|
||||
installRustc = false;
|
||||
installRustfmt = false;
|
||||
};
|
||||
|
||||
servers.nixd.enable = true;
|
||||
};
|
||||
|
||||
gitsigns = {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Oh My Pi, installed alongside upstream Pi as the `omp` command.
|
||||
let
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.omp = {
|
||||
enable = lib.mkEnableOption "Oh My Pi, a batteries-included fork of Pi";
|
||||
};
|
||||
|
||||
config = lib.mkIf config.modules.shell.omp.enable {
|
||||
home-manager.users.${user}.home.packages = [ pkgs.omp ];
|
||||
};
|
||||
}
|
||||
@@ -8,26 +8,31 @@
|
||||
# home-manager, which ships the package and manages global settings.
|
||||
# The login credential is left unmanaged, so it survives rebuilds.
|
||||
let
|
||||
cfg = config.modules.shell.pi;
|
||||
user = config.user.name;
|
||||
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
||||
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
|
||||
patches = (old.patches or [ ]) ++ [
|
||||
./patches/pi-project-resource-ancestors.patch
|
||||
./patches/pi-tool-lookup-validation.patch
|
||||
];
|
||||
});
|
||||
in
|
||||
{
|
||||
options.modules.shell.pi = {
|
||||
enable = lib.mkEnableOption "Pi, a terminal coding agent, configured through home-manager";
|
||||
enable = lib.mkEnableOption ''
|
||||
Pi, a terminal coding agent, configured through home-manager'';
|
||||
};
|
||||
|
||||
config = lib.mkIf config.modules.shell.pi.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user} = {
|
||||
programs.pi-coding-agent = {
|
||||
enable = true;
|
||||
package = pkgs.pi-coding-agent.overrideAttrs {
|
||||
src = ../../../../../third_party/pi;
|
||||
patches = [ ];
|
||||
};
|
||||
package = patchedPi;
|
||||
|
||||
settings = {
|
||||
defaultProvider = "openai-codex";
|
||||
defaultModel = "gpt-5.6-terra";
|
||||
defaultModel = "gpt-5.5";
|
||||
defaultThinkingLevel = "medium";
|
||||
theme = "dark";
|
||||
enableInstallTelemetry = false;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts
|
||||
--- a/packages/coding-agent/src/core/package-manager.ts
|
||||
+++ b/packages/coding-agent/src/core/package-manager.ts
|
||||
@@ -459,14 +459,14 @@ function findGitRepoRoot(startDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
-function collectAncestorAgentsSkillDirs(startDir: string): string[] {
|
||||
- const skillDirs: string[] = [];
|
||||
+function collectAncestorConfigDirs(startDir: string, configDirName: string): string[] {
|
||||
+ const configDirs: string[] = [];
|
||||
const resolvedStartDir = resolve(startDir);
|
||||
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
|
||||
|
||||
let dir = resolvedStartDir;
|
||||
while (true) {
|
||||
- skillDirs.push(join(dir, ".agents", "skills"));
|
||||
+ configDirs.push(join(dir, configDirName));
|
||||
if (gitRepoRoot && dir === gitRepoRoot) {
|
||||
break;
|
||||
}
|
||||
@@ -477,7 +477,7 @@ function collectAncestorAgentsSkillDirs(startDir: string): string[] {
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
- return skillDirs;
|
||||
+ return configDirs;
|
||||
}
|
||||
|
||||
function collectAutoPromptEntries(dir: string): string[] {
|
||||
@@ -2388,16 +2388,13 @@ export class DefaultPackageManager implements PackageManager {
|
||||
prompts: join(globalBaseDir, "prompts"),
|
||||
themes: join(globalBaseDir, "themes"),
|
||||
};
|
||||
- const projectDirs = {
|
||||
- extensions: join(projectBaseDir, "extensions"),
|
||||
- skills: join(projectBaseDir, "skills"),
|
||||
- prompts: join(projectBaseDir, "prompts"),
|
||||
- themes: join(projectBaseDir, "themes"),
|
||||
- };
|
||||
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
|
||||
const projectTrusted = this.settingsManager.isProjectTrusted();
|
||||
- const projectAgentsSkillDirs = projectTrusted
|
||||
- ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
|
||||
+ const projectConfigDirs = projectTrusted
|
||||
+ ? collectAncestorConfigDirs(this.cwd, CONFIG_DIR_NAME).filter((dir) => resolve(dir) !== resolve(globalBaseDir))
|
||||
+ : [];
|
||||
+ const projectAgentsDirs = projectTrusted
|
||||
+ ? collectAncestorConfigDirs(this.cwd, ".agents").filter((dir) => resolve(dir) !== resolve(dirname(userAgentsSkillsDir)))
|
||||
: [];
|
||||
|
||||
const addResources = (
|
||||
@@ -2414,56 +2411,54 @@ export class DefaultPackageManager implements PackageManager {
|
||||
}
|
||||
};
|
||||
|
||||
- if (projectTrusted) {
|
||||
- // Project extensions from .pi/
|
||||
+ // Project resources from .pi/ in cwd and ancestors.
|
||||
+ for (const configDir of projectConfigDirs) {
|
||||
+ const configMetadata: PathMetadata = {
|
||||
+ ...projectMetadata,
|
||||
+ baseDir: configDir,
|
||||
+ };
|
||||
addResources(
|
||||
"extensions",
|
||||
- collectAutoExtensionEntries(projectDirs.extensions),
|
||||
- projectMetadata,
|
||||
+ collectAutoExtensionEntries(join(configDir, "extensions")),
|
||||
+ configMetadata,
|
||||
projectOverrides.extensions,
|
||||
- projectBaseDir,
|
||||
+ configDir,
|
||||
);
|
||||
-
|
||||
- // Project skills from .pi/
|
||||
addResources(
|
||||
"skills",
|
||||
- collectAutoSkillEntries(projectDirs.skills, "pi"),
|
||||
- projectMetadata,
|
||||
+ collectAutoSkillEntries(join(configDir, "skills"), "pi"),
|
||||
+ configMetadata,
|
||||
projectOverrides.skills,
|
||||
- projectBaseDir,
|
||||
+ configDir,
|
||||
+ );
|
||||
+ addResources(
|
||||
+ "prompts",
|
||||
+ collectAutoPromptEntries(join(configDir, "prompts")),
|
||||
+ configMetadata,
|
||||
+ projectOverrides.prompts,
|
||||
+ configDir,
|
||||
+ );
|
||||
+ addResources(
|
||||
+ "themes",
|
||||
+ collectAutoThemeEntries(join(configDir, "themes")),
|
||||
+ configMetadata,
|
||||
+ projectOverrides.themes,
|
||||
+ configDir,
|
||||
);
|
||||
}
|
||||
|
||||
- // Project skills from .agents/ (each with its own baseDir)
|
||||
- for (const agentsSkillsDir of projectAgentsSkillDirs) {
|
||||
- const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
|
||||
+ // Project skills from .agents/ in cwd and ancestors.
|
||||
+ for (const agentsDir of projectAgentsDirs) {
|
||||
const agentsMetadata: PathMetadata = {
|
||||
...projectMetadata,
|
||||
- baseDir: agentsBaseDir,
|
||||
+ baseDir: agentsDir,
|
||||
};
|
||||
addResources(
|
||||
"skills",
|
||||
- collectAutoSkillEntries(agentsSkillsDir, "agents"),
|
||||
+ collectAutoSkillEntries(join(agentsDir, "skills"), "agents"),
|
||||
agentsMetadata,
|
||||
projectOverrides.skills,
|
||||
- agentsBaseDir,
|
||||
- );
|
||||
- }
|
||||
-
|
||||
- if (projectTrusted) {
|
||||
- addResources(
|
||||
- "prompts",
|
||||
- collectAutoPromptEntries(projectDirs.prompts),
|
||||
- projectMetadata,
|
||||
- projectOverrides.prompts,
|
||||
- projectBaseDir,
|
||||
- );
|
||||
- addResources(
|
||||
- "themes",
|
||||
- collectAutoThemeEntries(projectDirs.themes),
|
||||
- projectMetadata,
|
||||
- projectOverrides.themes,
|
||||
- projectBaseDir,
|
||||
+ agentsDir,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
|
||||
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
|
||||
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
|
||||
@@ -74,8 +74,8 @@
|
||||
function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||
- // Check for ENOENT error (command not found)
|
||||
- return result.error === undefined || result.error === null;
|
||||
+ // Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
|
||||
+ return (result.error === undefined || result.error === null) && result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -88,7 +87,7 @@
|
||||
|
||||
// Check our tools directory first
|
||||
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
||||
- if (existsSync(localPath)) {
|
||||
+ if (existsSync(localPath) && commandExists(localPath)) {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
}:
|
||||
# Python tools for the primary user's shell environment.
|
||||
let
|
||||
cfg = config.modules.shell.python;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.python.enable =
|
||||
lib.mkEnableOption "Python tools for the primary user's shell environment";
|
||||
options.modules.shell.python.enable = lib.mkEnableOption "Python tools for the primary user's shell environment";
|
||||
|
||||
config = lib.mkIf config.modules.shell.python.enable {
|
||||
registry.env.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
|
||||
config = lib.mkIf cfg.enable {
|
||||
registry.env-var.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
|
||||
|
||||
home-manager.users.${user}.home.packages = [ pkgs.python3 ];
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
}:
|
||||
# Rust tools for the primary user's shell environment.
|
||||
let
|
||||
cfg = config.modules.shell.rust;
|
||||
user = config.user.name;
|
||||
toolchain = pkgs.symlinkJoin {
|
||||
name = "rust-toolchain";
|
||||
paths = with pkgs; [
|
||||
@@ -16,14 +18,12 @@ let
|
||||
rustfmt
|
||||
];
|
||||
};
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.rust.enable =
|
||||
lib.mkEnableOption "Rust tools for the primary user's shell environment";
|
||||
options.modules.shell.rust.enable = lib.mkEnableOption "Rust tools for the primary user's shell environment";
|
||||
|
||||
config = lib.mkIf config.modules.shell.rust.enable {
|
||||
registry.env.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
|
||||
config = lib.mkIf cfg.enable {
|
||||
registry.env-var.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
|
||||
|
||||
home-manager.users.${user} = {
|
||||
home.packages = [
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
# tmux for the primary user, configured through home-manager.
|
||||
# Settings without a home-manager option are read from ./extra.conf.
|
||||
let
|
||||
cfg = config.modules.shell.tmux;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
|
||||
|
||||
config = lib.mkIf config.modules.shell.tmux.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
|
||||
|
||||
home-manager.users.${user}.programs.tmux = {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
}:
|
||||
# Yazi, a terminal file manager.
|
||||
let
|
||||
cfg = config.modules.shell.yazi;
|
||||
user = config.user.name;
|
||||
in
|
||||
{
|
||||
options.modules.shell.yazi.enable = lib.mkEnableOption "Yazi, a terminal file manager";
|
||||
|
||||
config = lib.mkIf config.modules.shell.yazi.enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
home-manager.users.${user}.programs.yazi = {
|
||||
enable = true;
|
||||
enableFishIntegration = true;
|
||||
|
||||
72
configs/nixos/modules/zfs.nix
Normal file
72
configs/nixos/modules/zfs.nix
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
# The host-level ZFS pool import: durable service state a host mounts, never rebuilds.
|
||||
let
|
||||
cfg = config.modules.zfs;
|
||||
in
|
||||
{
|
||||
options.modules.zfs = {
|
||||
enable = lib.mkEnableOption "importing durable ZFS pools that hold service state";
|
||||
|
||||
hostId = lib.mkOption {
|
||||
type = lib.types.strMatching "[0-9a-f]{8}";
|
||||
example = "deadbeef";
|
||||
description = ''
|
||||
This host's 8-hex-digit ZFS host id, written to `networking.hostId`. ZFS
|
||||
stamps an imported pool with the importing host's id, so a pool still
|
||||
held by another machine is refused rather than silently dual-mounted. It
|
||||
must be fixed for the machine and distinct across machines that can reach
|
||||
the same pool.
|
||||
'';
|
||||
};
|
||||
|
||||
pools = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
tank = {
|
||||
media = "/srv/media";
|
||||
downloads = "/srv/downloads";
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
The ZFS pools to import at boot, keyed by pool name, each pool mapping a
|
||||
dataset path relative to it to that dataset's mountpoint. A pool is
|
||||
durable state imported as it stands, never created or destroyed by a
|
||||
rebuild, so a service's data survives any rebuild or reimage. A pool
|
||||
with an empty map is still imported, leaving each dataset to its own ZFS
|
||||
`mountpoint` property.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# The ZFS stack in the kernel and boot, needed even where the root filesystem is another kind.
|
||||
boot.supportedFilesystems = [ "zfs" ];
|
||||
|
||||
# ZFS refuses to import a pool without a host id to stamp its ownership onto.
|
||||
networking.hostId = cfg.hostId;
|
||||
|
||||
# The declared pools are imported at boot, distinct from any pool backing the root filesystem.
|
||||
boot.zfs.extraPools = lib.attrNames cfg.pools;
|
||||
|
||||
# Each declared dataset is mounted at its host path as a native ZFS filesystem.
|
||||
fileSystems = lib.mkMerge (
|
||||
lib.mapAttrsToList (
|
||||
pool: mounts:
|
||||
lib.mapAttrs' (
|
||||
dataset: mountpoint:
|
||||
lib.nameValuePair mountpoint {
|
||||
device = "${pool}/${dataset}";
|
||||
fsType = "zfs";
|
||||
}
|
||||
) mounts
|
||||
) cfg.pools
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
{ lib, util, ... }:
|
||||
{ lib, ... }:
|
||||
{
|
||||
type = lib.types.str;
|
||||
|
||||
checks = [
|
||||
{
|
||||
assertion = util.registry.predicates.uniqueValue (entry: entry.value);
|
||||
assertion = lib.registry.predicates.uniqueValue (entry: entry.value);
|
||||
message = "Address registry values must be unique.";
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{ lib, ... }:
|
||||
{
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
tab = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "Apps";
|
||||
description = "Groups the entry in a Homepage tab.";
|
||||
};
|
||||
|
||||
section = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "Media";
|
||||
description = "Groups the entry in a Homepage section.";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "Navidrome";
|
||||
description = "Sets the display name in Homepage.";
|
||||
};
|
||||
|
||||
description = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "Music";
|
||||
description = "Sets the description in Homepage.";
|
||||
};
|
||||
|
||||
icon = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "navidrome.png";
|
||||
description = "Sets the icon in Homepage.";
|
||||
};
|
||||
|
||||
href = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://music.alexion.dev";
|
||||
description = "Overrides the generated Homepage link.";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
{ lib, util, ... }:
|
||||
{
|
||||
checks = [
|
||||
{
|
||||
assertion = util.registry.predicates.uniquePerHost (entry: entry.value.keys);
|
||||
message = "Desktop keybinds must use unique key chords per host.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
entries:
|
||||
lib.all (
|
||||
entry:
|
||||
(entry.value.action.kind == "exec" && entry.value.action.command != null)
|
||||
|| (
|
||||
builtins.elem entry.value.action.kind [
|
||||
"focus"
|
||||
"move-window"
|
||||
]
|
||||
&& lib.xor (entry.value.action.direction != null) (entry.value.action.workspace != null)
|
||||
)
|
||||
|| (
|
||||
entry.value.action.kind == "resize-window"
|
||||
&& entry.value.action.direction != null
|
||||
&& entry.value.action.workspace == null
|
||||
)
|
||||
|| (
|
||||
builtins.elem entry.value.action.kind [
|
||||
"toggle-floating"
|
||||
"toggle-fullscreen"
|
||||
"close-window"
|
||||
]
|
||||
&& entry.value.action.command == null
|
||||
&& entry.value.action.direction == null
|
||||
&& entry.value.action.workspace == null
|
||||
)
|
||||
) entries;
|
||||
message = "Desktop keybind actions must set the fields their kind requires.";
|
||||
}
|
||||
];
|
||||
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
keys = lib.mkOption {
|
||||
description = "Sets the key chord for this desktop keybind.";
|
||||
type = lib.types.str;
|
||||
example = "SUPER + Return";
|
||||
};
|
||||
|
||||
repeating = lib.mkOption {
|
||||
description = "Repeats the keybind action while the key chord is held.";
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
};
|
||||
|
||||
locked = lib.mkOption {
|
||||
description = "Allows the keybind action while the session is locked.";
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
};
|
||||
|
||||
action = lib.mkOption {
|
||||
description = "Declares the action this keybind runs.";
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
kind = lib.mkOption {
|
||||
description = "Selects the keybind action kind.";
|
||||
type = lib.types.enum [
|
||||
"exec"
|
||||
"focus"
|
||||
"move-window"
|
||||
"resize-window"
|
||||
"toggle-floating"
|
||||
"toggle-fullscreen"
|
||||
"close-window"
|
||||
];
|
||||
};
|
||||
|
||||
command = lib.mkOption {
|
||||
description = "Command executed by an exec keybind.";
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "ghostty";
|
||||
};
|
||||
|
||||
direction = lib.mkOption {
|
||||
description = "Direction targeted by a keybind action.";
|
||||
type = lib.types.nullOr (
|
||||
lib.types.enum [
|
||||
"down"
|
||||
"left"
|
||||
"right"
|
||||
"up"
|
||||
]
|
||||
);
|
||||
default = null;
|
||||
example = "left";
|
||||
};
|
||||
|
||||
workspace = lib.mkOption {
|
||||
description = "Workspace targeted by a keybind action.";
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
example = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,40 +1,10 @@
|
||||
{ lib, util, ... }:
|
||||
{ lib, ... }:
|
||||
{
|
||||
checks = [
|
||||
{
|
||||
assertion = util.registry.predicates.uniqueValue (entry: entry.value.domain);
|
||||
assertion = lib.registry.predicates.uniqueValue (entry: entry.value.domain);
|
||||
message = "Route domains must be unique.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
entries:
|
||||
util.registry.predicates.uniqueValue (entry: entry.value) (
|
||||
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.ports) entries
|
||||
);
|
||||
message = "Route UDP ports must be unique.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
entries:
|
||||
lib.all (entry: entry.value.port != null && entry.value.ports == [ ]) (
|
||||
lib.filter (entry: entry.value.protocol != "udp") entries
|
||||
);
|
||||
message = "HTTP and HTTPS routes must set port and not ports.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
entries:
|
||||
lib.all (entry: entry.value.port == null && entry.value.ports != [ ]) (
|
||||
lib.filter (entry: entry.value.protocol == "udp") entries
|
||||
);
|
||||
message = "UDP routes must set ports and not port.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
entries:
|
||||
lib.all (entry: entry.value.protocol != "udp" || entry.value.clientMaxBodySize == null) entries;
|
||||
message = "UDP routes must not set clientMaxBodySize.";
|
||||
}
|
||||
];
|
||||
|
||||
type = lib.types.submodule {
|
||||
@@ -42,40 +12,13 @@
|
||||
domain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "files.alexion.dev";
|
||||
description = "Records the public domain for this route.";
|
||||
};
|
||||
|
||||
protocol = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"http"
|
||||
"https"
|
||||
"udp"
|
||||
];
|
||||
description = "Selects the public protocol for this route.";
|
||||
description = "Public domain served by this reverse-proxy route entry.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.port;
|
||||
default = null;
|
||||
type = lib.types.ints.between 1 65535;
|
||||
example = 3923;
|
||||
description = "Sets the HTTP backend port for an HTTP or HTTPS route.";
|
||||
};
|
||||
|
||||
ports = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.port;
|
||||
default = [ ];
|
||||
example = [
|
||||
2456
|
||||
2457
|
||||
];
|
||||
description = "Sets the UDP ports for a UDP route.";
|
||||
};
|
||||
|
||||
clientMaxBodySize = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "1g";
|
||||
description = "Maximum HTTP request body the reverse proxy accepts.";
|
||||
description = "Backend TCP port served by the matching address registry entry.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
{ lib, ... }:
|
||||
{
|
||||
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "nvim";
|
||||
description = "Sets the command expanded by this shortcut.";
|
||||
};
|
||||
|
||||
abbreviation = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Uses a Fish abbreviation instead of an alias.";
|
||||
};
|
||||
let
|
||||
entryType = lib.types.submodule {
|
||||
options = {
|
||||
command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "nvim";
|
||||
description = "Command expanded by this shell shortcut.";
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
abbreviation = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether fish realizes this shell shortcut as an abbreviation instead of an alias.";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) entryType;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
{ lib, ... }:
|
||||
let
|
||||
duplicateUids =
|
||||
entries:
|
||||
lib.filterAttrs (_uid: entryNames: builtins.length entryNames > 1) (entryNamesByUid entries);
|
||||
inherit (lib)
|
||||
filterAttrs
|
||||
groupBy
|
||||
mapAttrs
|
||||
unique
|
||||
;
|
||||
|
||||
entryNamesByUid =
|
||||
entries:
|
||||
lib.mapAttrs (_uid: uidEntries: lib.unique (map (entry: entry.entry) uidEntries)) (
|
||||
lib.groupBy (entry: toString entry.value) entries
|
||||
serviceNamesByUid = entries:
|
||||
mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) (
|
||||
groupBy (entry: toString entry.value) entries
|
||||
);
|
||||
|
||||
duplicateUids = entries:
|
||||
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries);
|
||||
|
||||
in
|
||||
{
|
||||
# Next stable UID: 20005
|
||||
# Next stable UID: 20003
|
||||
replicate = true;
|
||||
|
||||
type = lib.types.ints.positive;
|
||||
|
||||
checks = [
|
||||
{
|
||||
assertion = entries: duplicateUids entries == { };
|
||||
message = "Multiple entries use the same stable UID.";
|
||||
message = "Stable UID allocations must be unique.";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{ lib, ... }:
|
||||
{
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
position = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"left"
|
||||
"center"
|
||||
"right"
|
||||
];
|
||||
default = "right";
|
||||
description = "Selects the Waybar section that shows the module.";
|
||||
};
|
||||
|
||||
order = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 100;
|
||||
example = 50;
|
||||
description = "Sorts the module within its Waybar section.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.anything;
|
||||
default = { };
|
||||
description = "Sets the Waybar module configuration.";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{ lib, ... }:
|
||||
{
|
||||
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
class = lib.mkOption {
|
||||
description = "Matches the window class reported by the compositor.";
|
||||
type = lib.types.str;
|
||||
example = ".*[Ff]irefox.*";
|
||||
};
|
||||
|
||||
icon = lib.mkOption {
|
||||
description = "Sets the icon shown for matching windows.";
|
||||
type = lib.types.str;
|
||||
example = "";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
ssh-host-ed25519-key: ENC[AES256_GCM,data:nxj7nMDzeqr0CitIjns9ugFh7Sur0pVpBr8SgWbxQMpIeNvBp+ugmnAxaUq2HRoYCo68uh/YKJZgMCBPSWrEyIhUX0rXmiZFmusrviJSXrYmy+n2XvLPjuv4vPbJEfjudRdXkHPazB3JJbeVRhGgK7y7smhNZlr8lwPGo0Ui0jVqWB4MPQRP6dLKVfV5shIusOqdhg7y2JSkZLYIjOBOJjknCxGmr5+vF2qwpDJQWEyzuJv2QJ48IlU7QyHF76//6xOchteL70/lyXI4gLiGl2fEczx3KKIXE+DNetKVAQxh4QHwzz9JK8qIJMYVry2qFdGGgF8eN7YEJYA60r1pL++1l8Uq++C1Bsc9jCO91ayQb+spXK0uM3G/ZJka7brRQbvcr54MJEyH6+0uBscXw/wMJ1EBX3Np6dumQhKuALnsAIXE89bKJg1wyPwaCy8uMZMcjQhvNkG0FPSBatHz2bB7bnNuNiXyT+scYyN0huE4Ccq7/H3bHWqbipV51YBH/XZKmjDiCwu3i0t5MHmp,iv:9P6Pa3hBe0/jbacjvzV3VGJJ4+OLRarEJEkLMOftqPw=,tag:zA6ewbj2ovP9+2WcAXa9qA==,type:str]
|
||||
ssh-host-rsa-key: ENC[AES256_GCM,data:C/xmdAeQlQou4wbP1EBqZ8G5/dRpmGYprj/z1g68WfCO7L+gzIgOryaEhBHugv3USAN8E3Ak4Ry+aUZCzDuIM2OeWdUm2AVT6r5o1RPYFtHSdsJUbKKB+9T3bR9GgFBRyRInSOjQhqR3lYuFLeA1xWhV8UG3Vv1tJwjLV1/JTUQf9j/NcYUr8tqjqD6hB8Fa5IEHPAUviX40R50o+EoiPIkB+qKbGru0Eg7p5K+/6d858nZc2lNgXIFnh72D0hXdWFs1BvcwGaY2WL3i7nJmOBUc3zM3/bYlU9EIscl04V6aUSXmF2sqI13Psa8lApV08MdAyUoFSHlYxlFxqe0IJ3nm0ofbJdwM6oDR6E8yQeU9vpirnYOPI6CWx6Y83LFWAVlhOZQKoLPBbiKImjM8oyoBGiIdqUrIXggoZ1u4F2/SwJtd9MwNr0+UDQWAQsYoAoBfI9LSUvEj52C2RiF28MDw2mEE8zmuHWM96KRUB7E8sGXHJrfBzDC5JuVdwmNCAOgck7ACJnHh8ZbKQYv8iGjbwVU/9z52BIUTLjJrdllSmIGWjVqOSVAUZVl7R3AK/xAaUuNNLaNzR4kk/+lQhPTuOLeFBfWbu3kryfeyJNyuHHtS21tgTcwc/bMYVq8LM8ttPn3b/BLQMl49qlBNmMk4TZ4hzBC5BMW6TgSHJRXZ7eajV8x+TM+aGu34xU0TiIy95XgTdi1ktQ0kAzB94iLTsMVhWpwoOguOlGLPGDKuDlym44T4cFRHySSHy0xlroPD1FPSvlSP7uY++55HsxX/88M8HNtCQ9h2P7jFRBYL+CpYbwgWNqtIWq8/daxCxScKZZ2/sAvFL5Ttqe//wYwNp6F3WbIxOk7yS/Ov+ADOt+RnxB2P11dkWNN7kHscGO7/sBNhDbzyEB8kHdfbEYKAl+OwUUsYqiGzdKF8E8aNa/0AAld3whrVgsUmIbFeoAkvJO6TkiZ3uuJAHdF9L8XfpWLEbu+geb6HG4+Bf5QOxxKVkSihMCimTI5vPGHuXj0/vwMihsMvhcaTmfSh2cZRAQhwIPQamEEeC+vqU2Igwcm57Cg81gMJYORLj+SaZSbCbrUxqElE/1OxUtF1KEh9njXjNK/grKnZbDDfRrhZwm3Nri1yxQxk/haHVoZttwXKo2vsWsYvpaHPJuUoQ0r/Wo8RffH2s04qauphLgWi2BUpfs+nNFBGRu6S92o/dktNQYgVeAGo7fYrM4xQX26fjQcOR7TD5bQIR2HcfHzyfH6A4c8MuInUeuf7kZFOoR1DdUFXYbvNcIgbmB/ScImkP3q18KaH+JRsVJbEEB7X4YOQ6lvHTtk0QVLv50V22BA9naFlpKGMWjq0oT5Gf40c6+opDlQWKrgwISwDvm9QJznvquj1EUlFJwVf0SJBHfeomFITZZMma2no2EIV0s/n9LvaU0b1Aim9EAqxUTgMrryLzE16dqSQKugbsn2jWMy7By3Xf8g0kstbyht5EOUZpThrwthEcGRN3xRHHke6ILckAVqEvl2wMuNMV8sd904pdYUL1wQAHKtg+fRbzu5cMY8itEs6YPsWob+HaD+n2tfHf4ByR1KBZ92nCyAWWjW6jrSDCzYCRsxvMSdsBJz9U4szSExC2qm9kQxGeedOl8/qECRZQQArBQZdavbwECGVUT3LRWQOu6bDyFgzoTzUlRxK05wr+Rpir/NmiGURGCZhmy+J2QnejlK0zOidORZAkzFcuGUPiUDGvJ99y43oSYAEYAI4UMKu42zimdVz50PbD2jpSJS6OmxUKfPfyy6CKC1lmYSB7PVGHzQ2aIw0RVMND9JNcntD5qgS2/g1CFhh9z9QyVtkOqiPICXTRXmVEQ5PW04tyzKBRkiSPoXxpC1zq77QPUAz0ZfeElqppdGyfejRiRl6m5JXJ9Pwl3/pgMEqGs0gpM/+TuT3rrYr6v602wPNq6/1XWpizReVPJZNzMrmxQwiIO285w1kvBlZ1VQnbAUiJR+pZuc9PiegSqEbfdkC8SQaGj+2M9tSDRzDGDE1y8pOLDi8Wczbn313JM+QwYDAXGccXxCEq0d3fVjb26u0ZNq2nn5zDy002aMWro+mrsLlWOf4MoAwvnyxgwEewrBEFIk1n14huFd1YpVomZuY3Y9XX1b1XoiTlhbmlP2HopobgQCD4Gt4zFb9RovNxBib+g4j2N94uoiuXOyBTT1B/HWHr6DnMxNvtHIyMOUPzR+6hwO9ntmdhPh+UZIB2b6FWZWLec55yBewzmvWn44dOdo7Z8Tp4d7vfSamyQDO49LA7Bd0CpDan7lbpw5rD4iejRCqZ/yJHTRyExSrZtcLQsScaorGCPlxHL4MUlRMynkyjPSUDRzqdJs+OnJDfUxJ566z8978rCppuYGRAGCqnoEHN9vcGsncMt9LuyUFlDaNKoOFZlMthvfQnUy47JnrP5u18Ph8qjn6A2Wh6MowsMvM67EBSlx8ZdHJC1iWqlHULW7FKPtcvKnDB/GToOqi2y9+EuMVWpoDJmTTyZFr9fmMlA0mL9tXWWG5h3G4LeLrT+LmRmk5tgL1TUJz5XDRQIQZRXxP+X7yt1bDh5PQK0vyupnWbs8KGDM+/pO8GwkwPnehBmJkkfkA6W2DIWzqRZHstdGmk1vtBqE6yAmwBOuXL+vm0vN86ulN+87LNR3OpCBRyorqundmw82vg/RdPNuA+Zxqg+VErsuTTdJmgZxN1rDU+8AiJPC8ZK7mH74lLYX2Ad86ZDriPcAi7wyYLc1qrSSHFCofb2FuM3/cJXcadSjZ//HxZh6qVOC5FW3b/YnFLZeqUgai6PO5rvzgyXusb2qHK60sEyS6pfw+ybOyZxWAc1TYqFXtOhUje8SW3R4cCG4zyCh5kUjZLiCNoNU2MIvVHjSfe9KAvTO2pcmuDIuVv9CKoko8tgoFqS80UDeP0pn99AmnTMXbhUR7eYjR9qAABF0IJJGUUIR1SbwiJrdEXil1qNcgciBI1epIXc1uBRXhcEnHI8VxVwYxF5wISjfuvp4cqJk03AkZVa4E2yydUATOCtk6fkaxVJIkitQaVgc0tqS4pX+9d35NF/njFHXksT/wMBfVJYmcgfIHJFK46igB/rwwC3PpOM+YNM8aJpLyZbbUB9CEWMtT9P5LqLWFoUaC2brIKG7vrfy2OSNOEWxiEDAzn7kY1+zpOb93J8FURpDNV+K4YUznPr1z2BxEGAKQXXx2PHQpAYYvdane5uWsZWMTsK5qQunX2UHPscr3T0A384pzXNdgnR6OrZfbcIpmvTAjxH9qzQLj49kJQ98czuutJWxgrp9J7abKeIdWGObTra5gA6bJ5kw9FZjW0xiyrOLAok/Vbmen8WCIVxToMJnXR91uuq58GxrcuZFke0NpCfEu3bpdPeKlb+sy0FREiFCpaDUNFFn5DqTNwOiW5KjGw/fG6dqC/NUhH5xdeod4dR1C4NEpYKzbWolUFf+rvFtGrRnQ0VQMXav+fpnBFiRA/ez/7oAzzwNv8Y7s3QpCUSUjsPRGWG/4pDlRSaz7rwkCfRxpkbOmPruYx3fcHigBnvIMLtKAPCx4jE7EsS6BoHNhgCMRyzkdl+MZFLKZ7JgFvgPdNcieFlptax2KaEwBIvhgQ4rimLRCv9cCM1qmmNIGB0UovqZ0Ye8tRG6LteFTgsfb3bNQ5sShaHQiuh2m9JGnI41q8CH2Gn6Mz/ftxn4yDWL/SlkjhtnRLoYvD5ro/Tf4q+exobXC0fbA1sLOnaX3atqHeQWK4Sk9L7zrDbwrJHzgmFD2PUPUhpfg9r1QMtJVkUckhQgyjrrIBfyZ4qqPmpYbH1w7cfE3C/rdprNZFKujYlA0ol8Ok1n0gyRFTeNZvd5EdrQ3D+hIKc0ID7/FmfzIccrI6x+wOE1ePQPzHay2mM8qRcjgTiQOTMaFRYjVy28ovqgixs/bylGvw5VNMGJTIqcu6fC54i3HOBhmnH1Ab/5a7PmwXUbAajw+mKddtrr0wQ7cXRncFnoJtld2AIOs7aD51C1Zkr1UqxliWObCsXJmaQoOpBScHdiKNp1mKQ1Oddu3mj0Su1e4gQR+YAwOyQ1zBU5WvNBbudUmRIsjXDS2Y6XaVN50neQ0DZ5V71259xPyawyVvrdiiUg7HOXgGFGcWbFU+p7MZaq0KE3aXgwCC+/mhFgaKgIVWir5pElNaXLUMDF+1jnblaKyHyAk0HPBgegAphVqWF3Hqp3b15jTSdWsmOugGcCO9hLk2OD99nZzuEVBLbDg0yGISPgzVNFg3+Pqcu8FqmGxLwAWNTRc0+ov7VL5Y4fJoD4QOPj19+nZyvXze/FOYo1Hp+U7PBZDIKVswcdauZG+Z2t2bEd0OxXj1NoazpycIVI/TmiTVyDsK+ijXztNnWXGG24WW1KZ3O5+MmDm7F7RDi1vCOfOOg3Y1yPVprOvltyn1uPo5Q/pPTD1FmAGpJkmdT3+dKkb9N5u6RNvRBEzFY8HCqcvKX1HulpJpz72,iv:vfjzz6U6ey9wGs6Ia3Vd5HaGBvpSq4akp4e3eLC/A3M=,tag:ar1+i09oOEusEuQnr44ASw==,type:str]
|
||||
ssh-user-ed25519-key: ENC[AES256_GCM,data:uHZADD2tq8vqlOAOWYniEnj1sl2IUqepf4YvrmRX/0nTksVnoethW6Yb2VNTgu5FsKCxvO65qMZ9KYupDkw/+SCZP2VH4TyhByQSr4SYxrEiNOJLdvmL2VnOWlNNZKaswgBePQR7TBG4mSIiTXCjEAcxeHAhBxLPnwiFxK/Zd6yQhq61x1N+ZtOj0q0QpADP3vioUohdkE5ODoEOUUoQJkYpjdJl2j4PhjCq7yHMEZpeMkqjZhVX5+i8blBu43BM4bKS2tBIJW2IvPV9sHOH8/tsR6p5q0pnpcJAOyOquBSo+eR0egY+liXjSv+Vo/ZDPapl7rKs2SoJ5huUGFbWjxbXY2+t2SYyiVA8lufrVUu94RCwOOJ8UyGYzAGGrChUImVDBsgk/GWwpOvWxcqydF8d6mDdxK9Iek9HtYsS+IKXLrF8GAI9OLB9jKgHVZgyQYSnC146cwCLt99TOFVocE1lfzwpFQTwKvxkep5jBEWiyqRaRUngti0JuF0ih1BWo+LKqxIqq9+V3I39xM8PfTjgHiifnxj4wuhS,iv:32zDKpNPYMiP6Rx7L9TAhxrMwcmphIGni+0jcESoLqg=,tag:YWGE62WQNm2Cer+eG+S5Xg==,type:str]
|
||||
services:
|
||||
navidrome:
|
||||
lastfm:
|
||||
api-key: ENC[AES256_GCM,data:Zb0I5TpdEqOxLAD6ed2XywFGZp7dfY69vcGiWOUJkJQ=,iv:pO53pHr09jWfzJyTsQbTHtzTmiQ9+RQkDwZJmInIiX8=,tag:io9jkSN7TF2I8+qlSymaYw==,type:str]
|
||||
secret: ENC[AES256_GCM,data:rKhd9i0/tbJlXhc9Wf81IDydjpD8khSdwJEEMK260E4=,iv:btBXWBKJp9br5a3yZsRVegN8Ytzc45Qbk2iNlPbENkQ=,tag:LUbu+6gxfqQvVpddZto7Tg==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
@@ -26,7 +21,7 @@ sops:
|
||||
dyjzy0/9Ui1IIXH36AImO5hiEZcrGePV2qPLLQc7ZLngC1jlJuEKJA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||
lastmodified: "2026-09-12T22:54:25Z"
|
||||
mac: ENC[AES256_GCM,data:4R3a0ytf4i8aM8iBQ9vRC/q+hvtjLrCTNgbpCB5DbqBMBYmhtGsBmO/0i1yT0N4tydyLaGOMkNfuuuVi3ASLEOKnViy9w3uD9IN0F79W4f2vguWUVIp/T8skvN9wbV9o9xd0LkJzmeVVgzb1H6HKw+rTGjbquleoIHEBDcDDM+k=,iv:0NlckMXlS+idY6UGBF+F0KFLt+pzD7Q/YEQOdXraAYg=,tag:JBBQduV8uhnKz9gPf6HfAQ==,type:str]
|
||||
lastmodified: "2026-08-03T01:26:32Z"
|
||||
mac: ENC[AES256_GCM,data:w4i6+dwN/t/SvFTrP/YiapxOY6ecT7+cJ0v/5lYMe4m1vL2w4mJw9ZrxWifcCcTEfaa766Wc1NE+GxGrBZtlORExbtQCa4tg2GuIQCRqTEc5WSqhYrgMb4elWuYe+r3/SlAf1ldNxz+cmLVtbDpSs96bvkGsKdw/i+q9n2URJYE=,iv:MFWgMtIdpn1v0T6FPhTgBMxi+6kzf2ZAnOGaisRT0d0=,tag:qBX32/zUHSPagXU+u8G8tA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.3
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user