Nix-Vibe public snapshot (squashed history)

Current state of main at 0240060 feat(hp-laptop): install TeleportFling from its flake. History intentionally
collapsed to a single commit; this repo mirrors only the latest state.
This commit is contained in:
2026-09-19 13:53:39 +01:00
commit eb08cd4282
118 changed files with 18716 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
---
name: flake-update
description: 'Safely update flake inputs: ensure a clean committed/pushed working tree, run nix flake update, then dry-build EVERY host before committing flake.lock. Use when the user asks to update the flake, refresh flake.lock, bump inputs, or upgrade nixpkgs/dependencies.'
---
# Flake Update
## When to Use
- When the user asks to "update the flake", "bump inputs", "refresh flake.lock", or "update nixpkgs"
- Before a planned fleet-wide input upgrade
## Procedure
### 1. Clean Working Tree (MANDATORY)
Never run `nix flake update` with uncommitted changes — mixing unrelated edits with a lockfile bump makes rollback painful.
```bash
git status
```
- If there are uncommitted changes: validate and commit them first (follow the **nix-flake-rebuild** skill: stage, `nixpkgs-fmt .`, `nix flake check`, dry-build affected hosts, commit).
- Then push everything to the remote:
```bash
git push
```
Only proceed when `git status` is clean and the branch is fully pushed.
### 2. Update Inputs
```bash
# All inputs
nix flake update
# OR a single input, if the user asked for one
nix flake update <input-name>
```
### 3. Flake Check
```bash
nix flake check
```
Fix any evaluation errors before building. Input bumps often surface deprecated/renamed NixOS and Home Manager options — check warnings and refer to release notes for replacements.
### 4. Dry-Build EVERY Host
An input update affects the whole fleet — dry-build every host, no exceptions. Generate the host list dynamically from the flake so new hosts are never missed:
```bash
for host in $(nix eval .#nixosConfigurations --apply 'attrs: builtins.concatStringsSep " " (builtins.attrNames attrs)' --raw); do
echo "=== $host ==="
nixos-rebuild dry-build --flake .#$host || break
done
```
If any host fails, do NOT commit. Either fix the breakage or roll back (see below).
### 5. Commit & Push Lockfile (only if ALL hosts pass)
```bash
git add flake.lock
git commit -m "flake: update inputs"
git push
```
## Rollback
Because step 1 guaranteed a clean, pushed tree, the previous lockfile is always recoverable:
```bash
git restore --source=HEAD~1 flake.lock
```
Or check out an older known-good lockfile from history.
## Notes
- This skill only **validates** the update. Actually applying it (`nixos-rebuild switch`) is a separate, per-host step.
- Review the `git diff flake.lock` before committing if the user wants to know which inputs moved.
+90
View File
@@ -0,0 +1,90 @@
---
name: nix-flake-rebuild
description: 'Validate Nix Flake changes end-to-end: stage, check, dry-build, and commit. Use after ANY change to .nix files, flake inputs, or module imports. Use when the user asks to apply, test, validate, rebuild, or commit NixOS config changes.'
argument-hint: '[hostname]'
---
# Nix Flake Rebuild Validation
## When to Use
- After ANY modification to `.nix` files, `flake.nix`, or module imports
- Before applying configs with `nixos-rebuild switch`
- When the user asks to "test", "validate", "rebuild", or "apply" changes
- After adding new files, modules, or hosts
## Critical Pre-Check: Git Staging
Nix flakes ONLY see Git-tracked files. If you created new files or directories, **you must stage them first**:
```bash
git add <new-file> <new-directory/>
```
Failure to do this will cause `nix flake check` to fail with confusing "file not found" errors.
## Procedure
### 1. Sync Documentation (if needed)
If software was added/removed or a host role changed, update the software inventory table and host overview in `README.md`.
### 2. Stage ALL Changes
```bash
git add -A
```
This ensures flake evaluation can see every file.
### 3. Format Code
```bash
nixpkgs-fmt .
```
### 4. Run Flake Check
```bash
nix flake check
```
Fix any errors before proceeding. Common issues:
- Missing `git add` on new files
- Deprecated NixOS/Home Manager options
- Syntax errors in `.nix` files
### 5. Dry-Build for Affected Hosts
```bash
nixos-rebuild dry-build --flake .#<hostname>
```
Run for EACH host affected by the changes. If unsure which hosts are affected, run for all. Generate the host list dynamically from the flake so new hosts are never missed:
```bash
for host in $(nix eval .#nixosConfigurations --apply 'attrs: builtins.concatStringsSep " " (builtins.attrNames attrs)' --raw); do
echo "=== $host ==="
nixos-rebuild dry-build --flake .#$host || break
done
```
### 6. Commit (only if ALL checks pass)
```bash
git commit -m "descriptive message"
```
Do NOT commit if `nix flake check` or any dry-build failed.
## Host Reference
> Informational only — the dry-build loop above generates the host list dynamically and is authoritative. Keep this table roughly in sync when adding hosts (used to check which overlay a host gets).
| Host | Type | Overlay |
|------|------|---------|
| `x1carbon` | Desktop/laptop | Full (`overlays`: CUDA/NDI/stable) |
| `caitlin-x1` | Desktop/laptop | `desktopOverlays` |
| `x470` | Desktop/laptop | `desktopOverlays` |
| `mary-x270` | Desktop | `desktopOverlays` |
| `richmond-server` | Server | `serverOverlays` |
| `homeserver-1` | Server | `serverOverlays` |
| `mcf-server` | Server | `serverOverlays` |
| `mcf-stream` | Desktop/laptop | `desktopOverlays` |
| `hp-laptop` | Desktop/laptop | `desktopOverlays` |
## Post-Validation
After a successful commit, to actually apply:
```bash
# Local
sudo nixos-rebuild switch --flake .#<hostname>
# Remote
nixos-rebuild switch --target-host <user@host> --flake .#<hostname> --use-remote-sudo
```
+132
View File
@@ -0,0 +1,132 @@
---
name: nix-module
description: 'Create or update a NixOS or Home Manager module following Nix-Vibe conventions. Use when adding a new service, desktop app, hardware support, or shared configuration module. Use when the user asks to add a package, service, or application module.'
argument-hint: '<module-name> [service|desktop|hardware|core]'
---
# Nix Module Creation
## When to Use
- Adding a new service (e.g., a new containerized app, daemon, or web service)
- Adding a new desktop application module
- Adding hardware support (e.g., new device driver, firmware)
- Creating a new shared core module
- The user asks to "add support for X" or "create a module for X"
## Module Pattern
All service/feature modules MUST follow this pattern:
```nix
{ config, pkgs, lib, ... }:
{
options.services.<name> = {
enable = lib.mkEnableOption "description of the service";
# Additional options as needed
};
config = lib.mkIf config.services.<name>.enable {
# Configuration here
};
}
```
Reference implementation: [modules/services/immich.nix](../../modules/services/immich.nix)
## Procedure
### 1. Determine Module Type
| Type | Directory | Example |
|------|-----------|---------|
| Service (daemon, container, webapp) | `modules/services/` | immich, jellyfin, ntfy |
| Desktop app | `modules/desktop/apps/` | soundux, opencode, freeshow |
| Desktop environment | `modules/desktop/` | gnome.nix, gui.nix |
| Hardware support | `modules/hardware/` | fingerprint.nix |
| Core system config | `modules/core/` | common.nix, settings.nix, fonts.nix |
### 2. Create the Module File
#### Service Module Template
```nix
{ config, pkgs, lib, ... }:
{
options.services.<service-name> = {
enable = lib.mkEnableOption "<Service Description>";
port = lib.mkOption {
type = lib.types.port;
default = <default-port>;
description = "Port for <service>";
};
# Add data directories, user config, etc. as needed
};
config = lib.mkIf config.services.<service-name>.enable {
# Service-specific configuration
# For stateful services, ensure data directories exist
systemd.tmpfiles.rules = [
"d /var/lib/<service> 0700 <user> <group> -"
];
# Open firewall if needed
networking.firewall.allowedTCPPorts = [ config.services.<service-name>.port ];
};
}
```
#### Desktop App Module Template
```nix
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [
<package-name>
];
}
```
### 3. Register in Host Configuration
Import the module in the target host's `configuration.nix`:
```nix
imports = [
# ...existing imports...
../../modules/services/<module-name>.nix
];
```
Then enable it:
```nix
services.<service-name>.enable = true;
```
### 4. Update README.md
Add the new software to the Software Inventory table with the appropriate host columns.
### 5. Validate
Run the full validation workflow. See [nix-flake-rebuild](../nix-flake-rebuild/SKILL.md).
## Key Conventions
### Secrets
Never hardcode secrets. Use SOPS:
```nix
sops.secrets."<host>/<secret-name>" = {
owner = "<user>";
group = "users";
mode = "0440";
};
```
See [docs/sops-secrets.md](../../docs/sops-secrets.md) for details.
### Overlay Awareness
- If a package needs NVIDIA/CUDA or NDI, it should only be added to `x1carbon` (the only host using the full `overlays` set)
- Desktops/laptops use `desktopOverlays` (`stable` without CUDA); servers use `serverOverlays`
### Module Composition
- Core modules (`modules/core/common.nix`) apply to ALL hosts
- Desktop modules only apply to GUI hosts
- Service modules are imported per-host as needed
+246
View File
@@ -0,0 +1,246 @@
---
name: nix-new-host
description: 'Scaffold a new NixOS host in this flake: create host directory, config files, wire into flake.nix, and set up Home Manager users. Use when adding a new machine (desktop, laptop, or server) to the Nix-Vibe configuration.'
argument-hint: '<hostname> [desktop|server]'
---
# New Host Scaffolding
## When to Use
- Adding a brand new machine to the Nix-Vibe configuration
- User asks to "add a host", "create a new machine config", or "scaffold a server/laptop"
- Migrating a new device into this flake-based setup
## Procedure
### 1. Gather Information
Before creating files, confirm with the user:
- **Hostname** (e.g., `new-laptop`)
- **Type**: Desktop/laptop (needs GUI) or Server (headless, CLI only)
- **Users** who will have Home Manager configs on this host
- **Any special hardware** (NVIDIA GPU, fingerprint sensor, etc.)
- **Any services** this host should run
### 2. Create Host Directory
```bash
mkdir -p hosts/<hostname>
```
### 3. Create Required Host Files
#### `hosts/<hostname>/hardware-configuration.nix`
Generate on the target machine after NixOS install:
```bash
nixos-generate-config --show-hardware-config > hosts/<hostname>/hardware-configuration.nix
```
If the target machine isn't available yet, create a minimal placeholder:
```nix
# hardware-configuration.nix for <hostname>
# Generated placeholder — run nixos-generate-config on the target machine
{
config,
lib,
pkgs,
...
}:
{
imports = [ ];
boot.initrd.availableKernelModules = [ ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ ];
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
swapDevices = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
```
#### `hosts/<hostname>/disko-config.nix`
Copy from an existing host of the same type (desktop vs server) and adjust disk layout:
```bash
cp hosts/x1carbon/disko-config.nix hosts/<hostname>/disko-config.nix
# Then edit to match the target disk layout
```
#### `hosts/<hostname>/configuration.nix`
Use the appropriate template below.
**Desktop/laptop template:**
```nix
{
config,
pkgs,
inputs,
lib,
...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/gnome.nix
../../modules/core/management.nix
../../modules/core/podman.nix
../../modules/core/dev.nix
];
networking.hostName = "<hostname>";
hardware.graphics.enable = true;
hardware.graphics.enable32Bit = true;
}
```
**Server template:**
```nix
{
config,
pkgs,
lib,
inputs,
...
}:
{
imports = [
../../modules/core/common.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
./hardware-configuration.nix
../../modules/core/podman.nix
];
networking.hostName = "<hostname>";
}
```
### 4. Wire into `flake.nix`
#### Add users in `hostUsers`:
```nix
hostUsers = {
# ...existing hosts...
<hostname> = [ "user1" "user2" ];
};
```
#### Add nixosConfiguration:
If **desktop/laptop** (uses full overlays):
```nix
nixosConfigurations = {
# ...existing configs...
<hostname> = mkNixosSystem "<hostname>" { };
};
```
If **server** (uses serverOverlays):
```nix
nixosConfigurations = {
# ...existing configs...
<hostname> = mkNixosSystem "<hostname>" {
hostOverlays = serverOverlays;
};
};
```
### 5. Create Home Manager User Configs (if needed)
If the host has users without existing Home Manager configs, create:
```bash
mkdir -p home-manager/users
```
Minimal user template (`home-manager/users/<username>.nix`):
```nix
{
config,
pkgs,
lib,
inputs,
...
}:
{
home-manager.users.<username> = {
home.username = "<username>";
home.homeDirectory = "/home/<username>";
home.stateVersion = "24.11";
programs.home-manager.enable = true;
programs.git.enable = true;
};
}
```
### 6. Update README.md
Add the new host to:
- The **Host Roles** section with role description and key features
- The **Software Inventory** table (add a column for the new host)
### 7. Register Host in nix-flake-rebuild Skill
The dry-build loop in [nix-flake-rebuild](../nix-flake-rebuild/SKILL.md) is dynamic (hosts are read from `nixosConfigurations`), so nothing breaks if you skip this — but add `<hostname>` to its **Host Reference** table (with the correct Type and Overlay — `serverOverlays` for servers, Full for desktops/laptops) to keep the overlay reference accurate.
### 8. Validate
Follow the [nix-flake-rebuild](../nix-flake-rebuild/SKILL.md) skill to validate all changes.
## Common Patterns
### Adding Desktop Apps
Import app modules in the host config:
```nix
imports = [
# ...existing imports...
../../modules/desktop/apps/soundux.nix
../../modules/desktop/apps/opencode.nix
];
```
### Adding Services
Import service modules and enable:
```nix
imports = [
# ...existing imports...
../../modules/services/immich.nix
];
services.immich-server.enable = true;
```
### Hardware-Specific Modules
```nix
imports = [
# ...existing imports...
../../modules/hardware/fingerprint.nix # if fingerprint reader
../../modules/hardware/nvidia.nix # if NVIDIA GPU
];
```
For an NVIDIA GPU, enable via the shared option (instead of hand-writing
`hardware.nvidia`):
```nix
my.hardware.nvidia = {
enable = true;
nvidiaSettings = true;
package = config.boot.kernelPackages.nvidiaPackages.legacy_580; # optional
};
```
### Shared Users (petere)
The admin user `petere` is defined once in `modules/core/users.nix` (imported via
`common.nix`). New hosts do **not** re-declare the full `users.users.petere`
block — override only what differs:
```nix
my.users.petere = {
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
subUidStart = 165536; # optional rootless-podman range
subGidStart = 165536;
};
```
`petere` also needs a Home Manager config in `hostUsers` (step 4).
+22
View File
@@ -0,0 +1,22 @@
# Nix build results
result*
# Tailscale authkey (unencrypted, should NOT be committed)
hosts/richmond-server/tailscale_authkey.txt
hosts/richmond-server/mcf-notices.env
hosts/richmond-server/castopod-container.env
hosts/richmond-server/castopod-api.env
hosts/richmond-server/pihole.env
# Local sops age keys (generated per-machine at deploy time)
# The public key goes in .sops.yaml, encrypted secrets go in secrets.yaml
/root/.config/sops/age/
/var/lib/sops-nix/
# Decrypted temporary sops files
.*decrypted*
extra-files/
extra-files/**
extra-files/
extra-files/**
+12
View File
@@ -0,0 +1,12 @@
creation_rules:
- path_regex: secrets\.yaml$
age: age145xh9ecu2hye2r9s9lqgap49vydttwyhhfc4x93juy9d78f92pnsucj8wg,
age17z3fuzlfmerpnsrum9g4sfmkmgghtlltfq07lp79uzugm5are3cs64dnqy,
age1wzt34k82v2shr443zqmkfu2la8ewdfszwg8s93mqh8m6n7mfd3ssfegyd0,
age1ge9zg2kz80xyq9xk94kc70g3d44j3lurpclgg8cel8u3skmsx4kss6ny8a,
age10p0dv642mdlwzt0xgrrz6udndu0yg939hletl2zxk558ycufxypqp6qcve,
age1kra9xjk5709jluhdwvn2x6jczx26z5z2ffygsf0q3p2tvqvnff4shn345g,
age1k23adf45f8ay5g65axc4v8ahfeuq2gg7fmjw3vhaf4zl30a0c52sw4fhza,
age16d5anx997as6syyzzj0cs70kvsknl2pjs4afx7l77fjpawp7vy2qnvm458,
age14kdra7c48a9262vxtsrmu09lhk6xt05hre560s89td46aadwpulq5esjqy,
age17sc2wm2zr8q84knuzr0jdhxasmt6wvvncgxuduqxz8h0juequfmqmw363q
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"signage.vscode-sops"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"servers": {
"nixos": {
"command": "mcp-nixos",
"args": []
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"sops.ageKeyFile": "${env:HOME}/.config/sops/age/keys.txt",
"sops.encryptOnSave": true,
"sops.yaml.keyPaths": [
".sops.yaml"
],
"chat.tools.terminal.autoApprove": {
"nix": true
}
}
+92
View File
@@ -0,0 +1,92 @@
# AGENTS.md — Nix-Vibe AI Agent Instructions
A Nix Flakes-based multi-host NixOS + Home Manager configuration repository.
## Quick Reference
- **Build/Test**: `nix flake check` (must `git add` new files first)
- **Task runner**: `just` (see `justfile`) — `just` deploys a host + restarts quickshell, `just check`, `just dry-build`, `just test`, `just restart-qs`
- **Format**: `nixpkgs-fmt .` (or `nix fmt` — the flake defines a `nixpkgs-fmt` formatter)
- **Dev shell**: `nix develop` (provides `sops`, `age`, `nixpkgs-fmt`)
- **Apply**: `sudo nixos-rebuild switch --flake .#<hostname>`
- **Test (no activation)**: `sudo nixos-rebuild test --flake .#<hostname>`
- **Remote apply**: `nixos-rebuild switch --target-host <host> --flake .#<hostname> --use-remote-sudo`
- **Dry-build**: `nixos-rebuild dry-build --flake .#<hostname>`
## Architecture
| Directory | Purpose |
|-----------|---------|
| `hosts/<name>/` | Per-machine configs (`configuration.nix`, `disko-config.nix`, `hardware-configuration.nix`) |
| `modules/core/` | Shared NixOS modules (common, fonts, settings, sops, podman, dev, users, management, known-hosts) |
| `modules/desktop/` | GUI desktop modules and app-specific modules |
| `modules/hardware/` | Hardware-specific modules (fingerprint, nvidia) |
| `modules/services/` | Service modules (immich, jellyfin, ntfy, paperless, immich-proxy) |
| `modules/storage/` | Disk configuration via Disko |
| `home-manager/modules/` | Shared Home Manager modules (zsh, gnome, firefox, kitty, etc.) |
| `home-manager/users/` | Per-user Home Manager configs |
| `flake.nix` | Entry point — defines `nixosConfigurations` via `mkNixosSystem` helper |
Host overview and software inventory: see [README.md](./README.md).
## Critical Rules
### Style
- **Language**: Nix — follow conventions used in the `nixpkgs` repository.
- **Formatter**: `nixpkgs-fmt .` (run before every commit).
### Git & Flakes
- **NEW FILES MUST BE STAGED** (`git add`) before `nix flake check` or `nixos-rebuild`. Flake evaluation only sees Git-tracked files.
- Do NOT commit until `nix flake check` AND a dry-build pass.
### Module Pattern
- New service modules follow the pattern: `lib.mkEnableOption` for `enable` + `lib.mkIf config.services.<name>.enable { ... }`.
- Example: [modules/services/immich.nix](./modules/services/immich.nix)
### Secrets
- Secrets are managed via [SOPS + age](./docs/sops-secrets.md) in `secrets.yaml`.
- Reference in host configs as `sops.secrets."<path>" = { ... };`.
- Rendered config files (e.g. container env files) use `sops.templates` — see [docs/sops-secrets.md](./docs/sops-secrets.md).
### Sudo
- Passwordless sudo is granted to **`petere` only** via `security.sudo.extraRules` (NOPASSWD) in `modules/core/common.nix`. All other wheel users must enter a password for `sudo`.
### Zsh
- `programs.zsh.enable = true` must be set in NixOS config for Zsh users.
- Shared Zsh config uses `home-manager.sharedModules` in `flake.nix` — do NOT use `home.file.".zshrc"`.
### Servers vs Desktops
- Servers (`richmond-server`, `homeserver-1`, `mcf-server`) use `serverOverlays` (lightweight).
- Desktops/laptops use `desktopOverlays` (`stable` without CUDA) by default.
- Only `x1carbon` uses the full `overlays` set with CUDA, NDI, stable packages (OBS Studio).
### Deprecated Options
- Pay attention to warnings about deprecated options during `nix flake check` or `nixos-rebuild`. Refer to NixOS/Home Manager release notes for updated options. Keeping configurations current prevents surprises on channel updates.
### Homepage Dashboard (gethomepage.dev)
- Dashboard definition for homeserver-1 lives in `hosts/homeserver-1/homepage.nix` (imported by the host config), NOT in `configuration.nix`.
- Full guide for adding machines/tabs/services: [docs/homepage-dashboard.md](./docs/homepage-dashboard.md).
- Widget group names MUST be unique — the credentialed proxy resolves config by leaf group name; duplicate names cause one machine's stats to render on another's tiles.
- Every Glances widget tile needs a `metric` field (`info`, `cpu`, `memory`, `fs:/`, `process`, ...) and `version = 4`; omitting `metric` throws `t.metric is undefined`.
- Tailscale widget `deviceid` must be the **numeric** device ID (from the Tailscale API), not the `...CNTRL` value.
- API keys are injected as `HOMEPAGE_VAR_*` env vars from the SOPS secret `homeserver-1/homepage-env` — never hardcode keys in `homepage.nix`.
- After editing `homepage.nix`, if changes don't appear after rebuild, restart the service: `sudo systemctl restart homepage-dashboard` (config files are only read at service start).
- Homepage is exposed on Tailscale only (port 8082).
## Post-Modification Workflow
1. Update `README.md` software inventory and host overview if relevant
2. When adding a new host, add it to the Host Reference table in `.github/skills/nix-flake-rebuild/SKILL.md` (informational; the dry-build loop is dynamic)
3. `git add` all modified/new files
4. `nix flake check`
5. `nixos-rebuild dry-build --flake .#<hostname>` for affected hosts
6. Commit only after both checks pass
## Documentation
- [README.md](./README.md) — Full architecture, host roles, and entry point for all docs
- [docs/software-inventory.md](./docs/software-inventory.md) — Cross-reference matrix of software per host
- [docs/installation.md](./docs/installation.md) — nixos-anywhere deployment guide
- [docs/sops-secrets.md](./docs/sops-secrets.md) — SOPS/age secrets management
- [docs/borg-backup-setup.md](./docs/borg-backup-setup.md) — BorgBackup server setup (deprecated - migrated to Backrest)
- [docs/homepage-dashboard.md](./docs/homepage-dashboard.md) — Homepage dashboard: adding machines, tabs & services
+69
View File
@@ -0,0 +1,69 @@
# Nix-Vibe: Multi-Host NixOS Configuration
This repository contains a modular NixOS and Home Manager configuration managed via Nix Flakes. The architecture is designed for reproducibility and consistency across diverse hardware, from powerful desktops to headless servers.
## Architecture Overview
The system is built on a modular foundation, separating core configurations, hardware-specific settings, and user environments.
### Host Roles
* **`x1carbon`**: Advanced mobile workstation.
* **Role**: Portable audio engineering and personal document management.
* **Key Features**: Specialized audio control (X32-Edit, Mixing Station), Paperless-ngx, image editing (GIMP), desktop publishing (Scribus), 2-in-1 tablet mode (auto-rotation via accelerometer + on-screen keyboard via wvkbd).
* **`caitlin-x1`**: User-focused laptop.
* **Role**: Daily productivity and gaming.
* **Key Features**: Steam integration, Prism Launcher for Minecraft, and Soundux for audio management.
* **`x470`**: Lightweight guest/general use laptop.
* **Role**: Basic productivity and remote network management.
* **Key Features**: Winbox for networking and FreeShow for presentations.
* **`mary-x270`**: User-focused desktop workstation.
* **Role**: Daily productivity workstation for Mary.
* **Key Features**: Shared GNOME desktop environment, Home Manager configuration, and multi-user access (`mary` & `petere`).
* **`richmond-server`**: Headless infrastructure core.
* **Role**: Network services, container hosting, and central backup storage.
* **Key Features**: Podman-hosted services (Pi-hole, Castopod, MCFNotices), Backrest backup server, and complex VLAN networking.
* **`homeserver-1`**: Headless general server.
* **Role**: Auxiliary network server and container environment.
* **Key Features**: Tailscale, Podman, Zsh, Disko, SOPS secret management, Immich, Jellyfin, Backrest, Pocket ID, and a Homepage dashboard.
* **`mcf-server`**: Headless server.
* **Role**: Remote backup target for `homeserver-1` (restic).
* **Key Features**: SSH and Glances monitoring on the tailnet.
* **`mcf-stream`**: Desktop.
* **Role**: Newly scaffolded desktop host.
* **Key Features**: GNOME desktop, Home Manager for `petere`.
* **`hp-laptop`**: End-user laptop.
* **Role**: Daily productivity laptop for `petere`.
* **Key Features**: Hyprland + QuickShell desktop (no GNOME), greetd/tuigreet login, Home Manager for `petere`.
---
### Software Inventory
See the full cross-reference table in **[docs/software-inventory.md](docs/software-inventory.md)**.
---
## Homepage Dashboard
The Homepage dashboard (gethomepage.dev) runs on `homeserver-1` at
`http://homeserver-1:8082` and displays services from across the tailnet.
- Dashboard definition: `hosts/homeserver-1/homepage.nix`
- Adding machines, tabs, and services: **[docs/homepage-dashboard.md](docs/homepage-dashboard.md)**
- Widget API keys are managed via SOPS (`secrets.yaml``HOMEPAGE_VAR_*` env vars)
---
## Initial Installation
This repository uses [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) for deployment to new hardware. See **[docs/installation.md](docs/installation.md)** for prerequisites and step-by-step instructions.
---
## Managing Secrets
This project uses [sops-nix](https://github.com/Mic92/sops-nix) and `age` to manage secrets.
For instructions on adding new secrets, managing keys, and using secrets in your configuration, please see the **[SOPS Secrets Management Guide](docs/sops-secrets.md)**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

+105
View File
@@ -0,0 +1,105 @@
# Borg Backup Server Setup for Richmond-Server
This document outlines the research and recommended approach for setting up a secure Borg backup server on the NixOS host `richmond-server`.
## 1. Research Findings
The core security principle for a Borg server is to use SSH with a dedicated, unprivileged user account whose command execution is strictly limited to `borg serve`. I investigated two main ways to achieve this on your server.
### Method 1: Manual NixOS Configuration (Recommended)
This method involves declaratively configuring the necessary components directly within your `configuration.nix`. It leverages standard NixOS options for user management and OpenSSH, allowing for precise control and integration with the rest of your system.
* **How it works**: You define a new system user (e.g., `borg-x1carbon`) and configure its SSH `authorized_keys` entry. For security, the SSH access for this user is restricted by prepending `command="borg serve --restrict-to-path /path/to/repo"` to the public key entry. This ensures that when a client connects as this user via SSH, it can *only* execute the `borg serve` command and *only* within the specified repository path.
#### Pros
- **Highly Secure**: Directly implements Borg's recommended security model (restricted SSH command).
- **Idiomatic & Declarative**: Managed entirely within your NixOS configuration using standard options.
- **Flexible**: Allows fine-grained control over user permissions and repository paths.
- **Integrated**: Works seamlessly with other NixOS components like `users.users` and `services.openssh`.
#### Cons
- Requires manual definition of each user and their SSH keys.
---
### Method 2: Podman Container
This approach involves running a community-provided Docker image (like `borgmatic/borgserver` or `nold360/borgserver`) as a Podman container on `richmond-server`.
* **How it works**: You would define a `virtualisation.oci-containers.containers.<name>` block. This would involve:
1. Pulling a suitable Borg server image from Docker Hub.
2. Mapping a host directory (e.g., `/var/lib/borg-backups`) into the container as a volume to persist the backup data.
3. Mapping a host directory containing the `authorized_keys` file into the container's SSH directory.
4. Publishing the container's SSH port (e.g., 2222) to a port on the host.
#### Pros
- **Encapsulated**: The Borg environment and its dependencies are isolated from the host system.
- **Consistent Workflow**: Aligns with the existing use of Podman containers on `richmond-server`.
#### Cons
- **Increased Complexity**: Managing persistent storage and SSH keys via volumes is more complex and prone to misconfiguration.
- **Manual Security**: You are responsible for ensuring the container image is secure and that the SSH key restrictions are correctly implemented inside the container.
- **Less Integrated**: Does not tie into the host's user or firewall management as cleanly as the native NixOS configuration.
---
## 2. Recommendation
**The Manual NixOS Configuration is the best method.**
It is more secure, simpler to manage, and more robust than a container-based solution for this specific use case on a NixOS system. It perfectly embodies the declarative and security-focused principles of both NixOS and Borg.
## 3. Example Configuration for `richmond-server`
Here is a proposed configuration snippet that you would add to `hosts/richmond-server/configuration.nix`. This example sets up a repository for a hypothetical client named `x1carbon-laptop`.
```nix
{
config, pkgs, lib, ...
}:
{
# ... existing configuration ...
# Borg Backup Server Configuration
# Create a dedicated system user for Borg backups
users.users.borg-x1carbon = {
isSystemUser = true;
group = "borg-x1carbon";
home = "/var/lib/borgbackup/x1carbon-main-backup"; # Home directory for this repo
createHome = true;
};
users.groups.borg-x1carbon = {};
# Configure OpenSSH to allow access for the borg user with restricted commands
services.openssh.enable = true; # Ensure OpenSSH is enabled
services.openssh.authorizedKeys.keys = {
"borg-x1carbon" = [
# IMPORTANT: Replace this with the actual public SSH key from your x1carbon laptop.
# The 'command' option restricts this key to only execute Borg serve commands.
"command=\"/run/current-system/sw/bin/borg serve --restrict-to-path /var/lib/borgbackup/x1carbon-main-backup\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyour_clients_public_key_here user@x1carbon"
];
};
# Ensure the base directory for backups exists and has correct permissions
systemd.tmpfiles.rules = [
"d /var/lib/borgbackup 0700 root root -"
];
# Borg uses SSH, so ensure the SSH port is open in your firewall.
# This is likely already enabled on your server.
networking.firewall.allowedTCPPorts = [ 22 ];
# ... rest of your configuration ...
}
```
## 4. Helpful Links
- [**BorgBackup Official Documentation - Usage with SSH**](https://borgbackup.readthedocs.io/en/stable/usage/ssh.html)
- [**NixOS Wiki on BorgBackup**](https://nixos.wiki/wiki/BorgBackup)
- [**NixOS `users.users` Options**](https://search.nixos.org/options?channel=unstable&show=users.users&from=0&size=50&sort=relevance&type=packages&query=users.users)
- [**NixOS `services.openssh` Options**](https://search.nixos.org/options?channel=unstable&show=services.openssh&from=0&size=50&sort=relevance&type=packages&query=services.openssh)
+275
View File
@@ -0,0 +1,275 @@
# Homepage Dashboard — Adding Machines, Tabs & Services
This guide covers how to add new machines, tabs, and services to the **Homepage**
dashboards on **homeserver-1** (and, by extension, any host that imports the
Homepage module).
## Overview
Homepage (https://gethomepage.dev) is a self-hosted dashboard. All dashboard
configuration lives in Nix and is rendered to YAML files that Homepage reads
at `/etc/homepage-dashboard/`.
| Item | Location |
|------|----------|
| Homepage NixOS module (service wrapper) | `modules/services/homepage.nix` |
| Dashboard definition for homeserver-1 | `hosts/homeserver-1/homepage.nix` |
| Deployed config files | `/etc/homepage-dashboard/{settings,services,widgets}.yaml` |
| API keys / secrets | `secrets.yaml` (SOPS) → `HOMEPAGE_VAR_*` env vars |
The dashboard definition is split from the host config so it's easy to edit:
- `hosts/homeserver-1/configuration.nix` enables Glances + firewall and imports:
```nix
imports = [
# ...
./homepage.nix
];
```
## Applying Changes
After editing `homepage.nix`:
```bash
nixos-rebuild switch --target-host petere@homeserver-1 --flake .#homeserver-1 --use-remote-sudo
```
> **Important:** Homepage reads its config files when the service starts. After
> a `nixos-rebuild`, the unit's definition usually changes, but if only the
> *contents* of the config files changed (e.g. just editing `homepage.nix`), the
> service may keep the old config in memory. If your changes don't appear, restart it:
>
> ```bash
> ssh petere@homeserver-1 "sudo systemctl restart homepage-dashboard"
> ```
## Structure of the Dashboard Definition
`hosts/homeserver-1/homepage.nix` has three main parts:
```nix
services.homepage = {
enable = true;
port = 8082;
allowedHosts = [ ... ]; # Host header values Homepage responds to
environmentFiles = [ ... ]; # SOPS-secret env file with HOMEPAGE_VAR_* keys
settings = {
title = "HomeServer";
# ...
layout = { ... }; # Controls grouping, tabs, and column widths
};
services = [ ... ]; # The service groups and tiles (services.yaml)
widgets = [ ... ]; # Header info widgets (resources, search, clock)
};
```
## Tabs
Tabs are enabled by adding a `tab` field to a group's **layout** entry.
- Groups with the **same** `tab` value appear on that tab.
- Groups with **no** `tab` appear on **every** tab.
- Tabs are sorted by their order in the `layout` block.
- Each tab is deep-linkable: `#monitoring`, `#homeserver-1`, etc.
Current tabs:
```
Monitoring (default tab)
├── Homeserver-1 Monitoring → 5 Glances tiles (localhost)
└── MCF Server Monitoring → 5 Glances tiles (mcf-server via tailnet)
Homeserver-1 (#homeserver-1)
├── Media → Jellyfin, Immich
└── System → Backrest, Pocket ID, Tailscale
MCF Server (reserved; uncomment the placeholder to enable)
```
## Adding a Service
1. **Add the service tile** to the relevant group in the `services` list:
```nix
services = [
# ...
{
System = [
# ...existing tiles...
{
MyService = {
icon = "sh-myservice";
href = "http://homeserver-1.gerbil-opah.ts.net:<port>";
description = "What it does";
siteMonitor = "http://127.0.0.1:<port>"; # green/red status
widget = { # optional live stats
type = "myservice";
url = "http://127.0.0.1:<port>";
key = "{{HOMEPAGE_VAR_MYSERVICE_API_KEY}}"; # only if it needs a key
};
};
}
];
}
];
```
2. **Set the layout** so the group renders where you want (columns = how many
tiles per row; groups span the full width with `style = "row"` when they're
a single top-level group):
```nix
layout = {
# ...
MyService = { # or add to an existing group's entry
tab = "Homeserver-1";
style = "row";
columns = 4;
};
};
```
3. **API keys**: never hardcode secrets. Add the value to `secrets.yaml` under
the machine section and reference it via the environment file. The secret
`homeserver-1/homepage-env` already provides `HOMEPAGE_VAR_JELLYFIN_API_KEY`,
`HOMEPAGE_VAR_IMMICH_API_KEY`, `HOMEPAGE_VAR_TAILSCALE_API_KEY` and
`HOMEPAGE_VAR_TAILSCALE_DEVICEID`. To add another:
```bash
sops --set '["homeserver-1"]["homepage-env"] "HOMEPAGE_VAR_MYSERVICE_API_KEY=<value>"' secrets.yaml
```
(This replaces the whole env file — include every existing `HOMEPAGE_VAR_*`
line when setting it.)
## Adding a Tab
Add a new group to `services` and give it a `tab` in the layout:
```nix
# services
{
"My New Group" = [
{ "MyService" = { href = "..."; }; }
];
}
# layout
"My New Group" = {
tab = "My Tab";
style = "row";
columns = 4;
};
```
## Adding a New Machine to the Dashboard
To monitor another machine (e.g. `richmond-server`):
### 1. Enable Glances on the target machine
Add to that host's `configuration.nix` (Glances exposes system stats to the
dashboard):
```nix
services.glances = {
enable = true;
port = 61208;
extraArgs = [ "--webserver" ];
};
# Expose on Tailscale only
networking.firewall.interfaces.tailscale.allowedTCPPorts = [ 61208 ];
```
### 2. Add the monitoring group
In `hosts/homeserver-1/homepage.nix`, add a group with Glances tiles. **The
group name must be unique** — Homepage's widget proxy resolves widget config by
leaf group name, so duplicate group names cause one machine's stats to display
on another's tiles.
```nix
# services
{
"Richmond Server Monitoring" = [
{ "System" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "info"; }; }; }
{ "CPU" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "cpu"; }; }; }
{ "Memory" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "memory"; }; }; }
{ "Disk" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "fs:/"; }; }; }
{ "Processes" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "process"; }; }; }
];
}
# layout
"Richmond Server Monitoring" = {
tab = "Monitoring";
style = "row";
columns = 5;
};
```
### 3. Add non-monitoring services for that machine
Add a group and give it its own tab (or reuse an existing one):
```nix
# services
{
"Richmond Server Apps" = [
{ "Ntfy" = { icon = "sh-ntfy"; href = "http://richmond-server.gerbil-opah.ts.net:8080"; }; }
];
}
# layout
"Richmond Server Apps" = {
tab = "Richmond Server";
style = "row";
columns = 4;
};
```
### 4. (Optional) Tailscale widget for the machine
Each machine's Tailscale node can be shown with its own widget, using the
machine's numeric device ID (find it via the Tailscale API/admin console):
```nix
{
"Richmond Server" = [
{
Tailscale = {
icon = "sh-tailscale";
href = "https://login.tailscale.com/admin/machines";
widget = {
type = "tailscale";
deviceid = "<numeric-device-id>"; # NOT the ...CNTRL value
key = "{{HOMEPAGE_VAR_TAILSCALE_API_KEY}}";
};
};
}
];
}
```
## Troubleshooting
| Symptom | Cause / Fix |
|---------|-------------|
| Changes not appearing | Service not restarted — run `sudo systemctl restart homepage-dashboard` |
| `t.metric is undefined` | Glances widget missing the `metric` field — every glances tile needs `metric` (e.g. `cpu`, `memory`, `fs:/`, `process`, `info`) |
| `no manageable device matching this ID found` | Tailscale widget `deviceid` is wrong — must be the **numeric** device ID, not the `...CNTRL` value |
| Two machines show the same stats | Duplicate group names — every group (especially monitoring) needs a unique name |
| `Host validation failed` | The `Host` header isn't in `allowedHosts` — add the hostname (with port) to `allowedHosts` |
| API keys showing in config | Keys must come from the SOPS env file (`HOMEPAGE_VAR_*`), never hardcoded |
## Glances Metrics
The Glances widget (`type = "glances"`) requires a `version` and `metric`:
- `version = 4` for Glances v4.x (installed)
- `metric`: `info` (system summary), `cpu`, `memory`, `process`, `containers`,
`fs:/` (disk usage), `network:<iface>`, `sensor:<id>`, `disk:<id>`, `gpu:<id>`
+69
View File
@@ -0,0 +1,69 @@
# Initial Installation
This repository uses [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) for seamless deployment to new hardware.
## Prerequisites
1. **SSH Access**: The target machine must be booted into a Linux environment (e.g., NixOS Installer ISO) with SSH enabled and your public key authorized.
2. **Secrets Management**: If the target host requires secrets, ensure its Age key is generated and added to `.sops.yaml` as described in the [SOPS Guide](sops-secrets.md).
3. **Disko**: Ensure the `disko-config.nix` for the host matches the target hardware's drive names (e.g., `/dev/nvme0n1` vs `/dev/sda`).
## Deployment Command
Run this command from the root of the repository:
```bash
nix run github:nix-community/nixos-anywhere -- --flake .#<hostname> <target-ip>
```
*Example:* `nix run github:nix-community/nixos-anywhere -- --flake .#x1carbon 192.168.1.50`
The process will automatically partition the drive via `disko`, install the system, and reboot into the new NixOS environment.
## Post-Installation
After the first boot, apply the configuration locally:
```bash
sudo nixos-rebuild switch --flake /etc/nixos/#<hostname>
```
For ongoing management, clone this repository and use the commands in the main [README.md](../README.md).
---
## Secrets: Using Age Keys with `--extra-files`
Some hosts require SOPS secrets at build time (e.g., user passwords with `neededForUsers = true`). The private age key must be available during `nixos-rebuild`, but it should **never** be committed to Git. Use the `extra-files/` directory (in `.gitignore`) and the `--extra-files` flag to supply the key securely.
### 1. Create the Age Key
Generate a new age key and store it in the untracked `extra-files/` tree:
```bash
mkdir -p extra-files/root/.config/sops/age
age-keygen -o extra-files/root/.config/sops/age/keys.txt
```
Extract the **public key** and add it to `.sops.yaml`, then re-encrypt the secrets file so this key can decrypt them:
```bash
cat extra-files/root/.config/sops/age/keys.txt | age-keygen -y
# Copy the output public key into .sops.yaml under the `age` key list
sops updatekeys secrets.yaml
```
### 2. Deploy with `--extra-files`
The `--extra-files` flag copies the local `extra-files/` directory into the Nix store so the age key is available at build time:
```bash
sudo nixos-rebuild switch --flake .#<hostname> --extra-files extra-files
```
This makes `extra-files/root/.config/sops/age/keys.txt` available at `/root/.config/sops/age/keys.txt` during evaluation, allowing SOPS to decrypt `secrets.yaml` without the key ever touching the target machine's filesystem.
> **Note**: For `nixos-anywhere` initial deployments, supply the age key via `--extra-files` as well:
> ```bash
> nix run github:nix-community/nixos-anywhere -- --extra-files extra-files --flake .#<hostname> <target-ip>
> ```
+94
View File
@@ -0,0 +1,94 @@
# Software Inventory
This table tracks which software is installed on each host and how it's installed.
| Software | Installation Method | X1C | CX1 | X470 | MX270 | RS | HS1 | HPL |
| :------------------- | :------------------------------- | :-- | :-- | :--- | :---- | :- | :-- | :-: |
| **Zsh** | NixOS System / HM Module | x | x | x | x | x | x | x |
| **Tailscale** | NixOS System | x | x | x | x | x | x | x |
| **GNOME** | NixOS System | x | x | x | x | | | |
| **Hyprland** | NixOS System | | | | | | | x |
| **QuickShell** | NixOS System | | | | | | | x |
| **Wofi** | NixOS System | | | | | | | x |
| **Thunar** | NixOS System | | | | | | | x |
| **Power Mgmt** | NixOS System (upower, power-profiles-daemon, hypridle) | | | | | | | x |
| **vdirsyncer** | HM (cal sync) | | | | | | | x |
| **khal** | HM (cal sync) | | | | | | | x |
| **Firefox** | NixOS System / HM Module | x | x | x | x | | | x |
| **Chromium** | NixOS System | x | x | x | x | | | x |
| **Thunderbird** | NixOS System | x | x | x | x | | | x |
| **Bitwarden** | NixOS System | x | x | x | x | | | x |
| **VLC** | NixOS System | x | x | x | x | | | x |
| **Element Desktop** | NixOS System | x | x | x | x | | | x |
| **Kdenlive** | NixOS System | x | x | x | x | | | x |
| **Nextcloud Client** | NixOS System | x | x | x | x | | | x |
| **OnlyOffice** | NixOS System | x | x | x | x | | | x |
| **Gear Lever** | Flatpak | x | x | x | x | | | x |
| **Flatseal** | Flatpak | x | x | x | x | | | x |
| **Soundux** | Flatpak | x | x | | | | | |
| **OpenLP** | Flatpak Bundle | | | x | | | | |
| **FreeShow** | AppImage | x | x | x | | | | |
| **GIMP** | NixOS System | x | | | | | | |
| **Scribus** | NixOS System | x | | | | | | |
| **OpenShot** | NixOS System | x | | | | | | |
| **OBS Studio** | NixOS System (NDI / CUDA) | x | | | | | | |
| **OpenCode** | NixOS System | x | | | | | | |
| **MCP-NixOS** | HM User Package (x1carbon) | x | | | | | | |
| **LM Studio** | NixOS System | x | | | | | | |
| **Mixing Station** | NixOS System (Custom Derivation) | x | | | | | | |
| **X32-Edit** | NixOS System | x | | | | | | |
| **Vorta** | NixOS System | x | | | | | | |
| **Paperless-ngx** | NixOS System (Module) | x | | | | | | |
| **Winbox** | NixOS System | x | | x | | | | |
| **wvkbd** | HM User Package (x1carbon) | x | | | | | | |
| **matugen** | HM User Package (x1carbon) | x | | | | | | |
| **Xournal++** | NixOS System | x | x | | | | | |
| **Steam** | NixOS System | x | x | | | | | |
| **Prism Launcher** | NixOS System | | x | | | | | |
| **Rclone** | NixOS System | | x | | | | | |
| **Pi-hole** | Container (Podman) | | | | | x | | |
| **Castopod** | Container (Podman) | | | | | x | | |
| **MCFNotices** | Container (Podman) | | | | | x | | |
| **Ntfy** | NixOS System (Module) | | | | | x | | |
| **Immich** | NixOS System (Module) | | | | | | x | |
| **Pocket ID** | NixOS System (Module) | | | | | | x | |
| **Jellyfin** | NixOS System (Module) | | | | | | x | |
| **Backrest** | NixOS System (Module) | | | | | | x | |
| **Homepage** | NixOS System (Module) | | | | | | x | |
| **MariaDB** | NixOS System | | | | | x | | |
| **BorgBackup** | _Removed - migrated to Backrest_ | | | | | | | |
| **NVIDIA Drivers** | NixOS System | x | | | | | x | |
| **VS Code** | HM Module | x | x | x | x | | | x |
| **Kitty** | HM Module | x | x | x | x | | | x |
| **Direnv** | HM User | x | x | x | x | x | | x |
| **Git** | HM User / System | x | x | x | x | x | x | x |
| **Fastfetch** | HM User | x | x | x | x | x | | x |
| **Python / UV** | NixOS System | x | x | | x | | | x |
| **Flutter** | NixOS System | x | x | | x | | | x |
| **Disko** | NixOS Module | x | x | x | x | x | x | x |
| **SOPS** | NixOS Module / System | x | x | x | x | x | x | x |
| **Microsoft Core Fonts** | NixOS System (Fonts) | x | x | x | x | x | x | x |
| **Admin tools** | NixOS System (`management.nix`) | x | x | x | x | x | x | x |
## Legend
| Code | Host |
|------|------|
| **X1C** | `x1carbon` |
| **CX1** | `caitlin-x1` |
| **X470** | `x470` |
| **MX270** | `mary-x270` |
| **RS** | `richmond-server` |
| **HS1** | `homeserver-1` |
| **HPL** | `hp-laptop` |
## Installation Methods
| Method | Description |
|--------|-------------|
| **NixOS System** | Package installed via `environment.systemPackages` or `services.<name>.enable` |
| **HM Module** | Home Manager module (`programs.<name>.enable`) |
| **HM User** | Home Manager user-level config (`home.packages`) |
| **Flatpak** | Installed via `services.flatpak.packages` |
| **Container (Podman)** | Runs as an OCI container via Podman |
| **AppImage** | Standalone AppImage bundle |
+128
View File
@@ -0,0 +1,128 @@
# SOPS Secrets Management Guide
This guide covers how to manage encrypted secrets in Nix-Vibe using `sops-nix` with `age` encryption.
## Overview
- **Secrets File**: `secrets.yaml` (encrypted in Git)
- **Configuration**: `.sops.yaml` (public keys and creation rules)
- **Private Key**: Locally stored at `~/.config/sops/age/keys.txt` (Never commit this!)
## Prerequisites
The project includes `sops` in the default development environment. You can also run it temporarily:
```bash
nix shell nixpkgs#sops nixpkgs#age
```
## Adding or Updating Secrets
The easiest way to add a new secret (like a password or API key) is to use the `sops --set` command from the root of the repository.
### Adding a Key-Value Pair
```bash
sops --set '["<host-or-category>"]["<secret-name>"] "<value>"' secrets.yaml
```
*Example (Adding a server password):*
```bash
sops --set '["richmond-server"]["new-password"] "supersecret123"' secrets.yaml
```
*Example (Adding a global user password):*
```bash
sops --set '["users"]["petere-password"] "mypassword"' secrets.yaml
```
### Editing the Secrets File Directly
To open the entire decrypted file in your editor:
```bash
sops secrets.yaml
```
## Using Secrets in Configuration
### 1. NixOS System Secrets
In `hosts/<hostname>/configuration.nix`:
```nix
sops.secrets."machine-name/new-secret" = {
owner = "root";
group = "root";
mode = "0400";
};
```
**Note:** For user passwords, add `neededForUsers = true;` to ensure the secret is decrypted early enough for the account to be created.
### 2. Home Manager Secrets
Home Manager secrets are defined in the user's profile (e.g., `home-manager/users/petere.nix`):
```nix
sops.secrets."gemini-api-key" = { };
```
Access the decrypted path in your configuration:
`config.sops.secrets."gemini-api-key".path`
### 3. Rendered Templates (`sops.templates`)
For config files that embed a secret (e.g. an env file consumed by a container),
use `sops.templates` so sops-nix renders the file with correct permissions and
re-renders it at boot/switch — no shell `preStart` needed:
```nix
sops.templates."pihole-env" = {
content = ''
FTLCONF_webserver_api_password=${config.sops.placeholder."richmond-server/pihole-password"}
'';
path = "/run/pihole-env";
mode = "0600";
};
```
The `${config.sops.placeholder."<secret>"}` reference is substituted with the
decrypted value at runtime. Live examples: `hosts/richmond-server/configuration.nix`
(pihole + castopod env files).
### Multi-line Secrets
Store SSH keys and other multi-line values as YAML **block scalars** (using `|`)
so they decrypt with real newlines. `hosts/homeserver-1/configuration.nix`
installs the `restic-ssh-key` verbatim with `install`, so that secret must be
multi-line (not a single line with escaped spaces).
## Rotating / Adding New Machine Keys
When deploying to a new machine, you must generate an age key and add its public key to `.sops.yaml`.
1. **Generate the key on the target machine**:
```bash
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
```
2. **Get the public key**:
```bash
cat ~/.config/sops/age/keys.txt | age-keygen -y
```
3. **Update `.sops.yaml`**:
Add the new public key to the `age` list.
4. **Re-encrypt the secrets file**:
```bash
sops updatekeys secrets.yaml
```
## Troubleshooting
- **"No sops config found"**: Ensure you are in the repository root.
- **Decryption Failure**: Ensure your private key is at `~/.config/sops/age/keys.txt` or set `export SOPS_AGE_KEY_FILE=...`.
- **Pure Evaluation Mode**: Nix Flakes in pure mode cannot read absolute paths (like `/run/secrets/...`). Use `sops.templates` or runtime injection instead of `preStart` (see `hosts/richmond-server/configuration.nix`).
Generated
+345
View File
@@ -0,0 +1,345 @@
{
"nodes": {
"disko": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781152676,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
"owner": "nix-community",
"repo": "disko",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"disko_2": {
"inputs": {
"nixpkgs": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1781152676,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
"owner": "nix-community",
"repo": "disko",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "master",
"repo": "disko",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1788651960,
"narHash": "sha256-v9wJd32eZ2bvhBzVOd7TIjLQd011P7nwOhjKtWlci5I=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "2c0350c759688177331b8f5242311fae8877bdb3",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"home-manager-stable": {
"inputs": {
"nixpkgs": [
"nixpkgs-stable"
]
},
"locked": {
"lastModified": 1747688870,
"narHash": "sha256-ypL9WAZfmJr5V70jEVzqGjjQzF0uCkz+AFQF7n9NmNc=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "d5f1f641b289553927b3801580598d200a501863",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-24.11",
"repo": "home-manager",
"type": "github"
}
},
"nix-flatpak": {
"locked": {
"lastModified": 1783368811,
"narHash": "sha256-0H8jDwR4Kegb3heaTrH1ftbgKfZVDT8JE+46uXxDy/Q=",
"owner": "gmodena",
"repo": "nix-flatpak",
"rev": "20d42f0ee98c9fe9f85e8d1de474f1409ed10d05",
"type": "github"
},
"original": {
"owner": "gmodena",
"repo": "nix-flatpak",
"type": "github"
}
},
"nix-vm-test": {
"inputs": {
"nixpkgs": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1786747096,
"narHash": "sha256-9QqhmaLVsPhKdMSBaWKjDqeGRn8G4ov4cVuZ6JFwXbo=",
"owner": "numtide",
"repo": "nix-vm-test",
"rev": "c8781a0ea2d8417506fff7722eae5a6316461212",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "nix-vm-test",
"type": "github"
}
},
"nixos-anywhere": {
"inputs": {
"disko": "disko_2",
"nix-vm-test": "nix-vm-test",
"nixos-images": "nixos-images",
"nixos-stable": "nixos-stable",
"nixpkgs": [
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1787728766,
"narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=",
"owner": "nix-community",
"repo": "nixos-anywhere",
"rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixos-anywhere",
"type": "github"
}
},
"nixos-images": {
"inputs": {
"nixos-stable": [
"nixos-anywhere",
"nixos-stable"
],
"nixos-unstable": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1787222173,
"narHash": "sha256-acp6QJnWVnLvnanC59CMkiDC/i0ZhdFKiB7prru9SHw=",
"owner": "nix-community",
"repo": "nixos-images",
"rev": "e17386d9193d6d5a90f1b4b6a8a5cd2620d34b56",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixos-images",
"type": "github"
}
},
"nixos-stable": {
"locked": {
"lastModified": 1787204541,
"narHash": "sha256-OURZPknrTjQrlNyxPdqzyqmU/81Wes1CUP/Ft1Rv/YI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5880666fd9eb563038431edb35c2d0aa595884e6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1788752844,
"narHash": "sha256-VaWGJ6+cIYN2erfSecbRV+4ljI185Ty2wUrXyvQbgOw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "dc5d91f840324650bac8c379428c7037a416959a",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1789684949,
"narHash": "sha256-ZKhUe/2IJUq1JhKxKMu8rbkgSGmPP2ZCqlIPn40aGCM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e554fab72f81915600f3f449b786fd9af40439a5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"disko": "disko",
"home-manager": "home-manager",
"home-manager-stable": "home-manager-stable",
"nix-flatpak": "nix-flatpak",
"nixos-anywhere": "nixos-anywhere",
"nixpkgs": "nixpkgs",
"nixpkgs-stable": "nixpkgs-stable",
"sops-nix": "sops-nix",
"teleportfling": "teleportfling"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1788337237,
"narHash": "sha256-gkSH8VUtCo6hnysNmb9DbTuDepH2t5pv+QWjP75xKAk=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "fbf759290e0cb0a98dfc813a4eb7d53ad1dacb57",
"type": "github"
},
"original": {
"owner": "Mic92",
"repo": "sops-nix",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"teleportfling": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1789810501,
"narHash": "sha256-OAdYGZP2lcN5eRmy/liflctV7gSWObGJBD2w9QQgN8E=",
"ref": "refs/heads/main",
"rev": "921e6d65dfc6267f339a30474a5b37072a99c474",
"revCount": 20,
"type": "git",
"url": "http://homeserver:3050/pedley/TeleportFling.git"
},
"original": {
"type": "git",
"url": "http://homeserver:3050/pedley/TeleportFling.git"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixos-anywhere",
"nixpkgs"
]
},
"locked": {
"lastModified": 1786901030,
"narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+216
View File
@@ -0,0 +1,216 @@
{
description = "A vibrant NixOS configuration for multiple machines";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager-stable = {
url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs-stable";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-anywhere = {
url = "github:nix-community/nixos-anywhere";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-flatpak.url = "github:gmodena/nix-flatpak";
# TeleportFling: standalone screen + audio sender for OBS Teleport.
teleportfling.url = "git+http://homeserver:3050/pedley/TeleportFling.git";
};
outputs =
{ self
, nixpkgs
, nixpkgs-stable
, home-manager
, home-manager-stable
, disko
, nixos-anywhere
, sops-nix
, nix-flatpak
, teleportfling
, ...
}@inputs:
let
system = "x86_64-linux";
serverOverlays = [
# Empty - antigravity removed
];
# Overlays shared by all desktops/laptops (no CUDA/NDI)
desktopOverlays = [
(final: prev: {
stable = import nixpkgs-stable {
system = final.stdenv.hostPlatform.system;
config = {
allowUnfree = true;
allowBroken = true;
};
};
})
];
# Full overlay set used by x1carbon only (CUDA + NDI for OBS Studio etc.)
overlays = [
(final: prev: {
stable = import nixpkgs-stable {
system = final.stdenv.hostPlatform.system;
config = {
allowUnfree = true;
allowBroken = true;
cudaSupport = true;
cudaCapabilities = [ "6.1" ];
};
};
})
(final: prev: {
ndi-6 = prev.ndi-6.overrideAttrs (old: {
src = prev.fetchurl {
url = "https://downloads.ndi.tv/SDK/NDI_SDK_Linux/Install_NDI_SDK_v6_Linux.tar.gz";
hash = "sha256-8DFPJFRG3vxIi2POtGiazxqWWu79ray3BXG7IWqMwYM=";
};
});
})
];
# Packages with known vulnerabilities that are explicitly permitted.
# Single-sourced here; referenced by the mkNixosSystem module below.
permittedInsecurePackages = [
"electron-39.8.10"
"qtwebengine-5.15.19"
"ventoy-1.1.17"
];
# Specific pkgs for Desktop with CUDA support (using stable to avoid broken CUDA builds)
# No longer needed as a separate input, we use overlays now.
# pkgsDesktop = ... (Removed)
# Function to generate a NixOS system configuration
hostUsers = {
x1carbon = [ "petere" ];
caitlin-x1 = [
"caitlin"
"petere"
];
x470 = [
"petere"
"guest"
];
mary-x270 = [
"mary"
"petere"
];
richmond-server = [ "petere" ];
homeserver-1 = [ "petere" ];
mcf-server = [ "petere" ];
mcf-stream = [
"guest"
"petere"
];
hp-laptop = [ "petere" ];
};
# Function to generate a NixOS system configuration
mkNixosSystem =
hostname:
{ specialArgs ? { }
, modules ? [ ]
, hostOverlays ? desktopOverlays
, nixpkgsInput ? nixpkgs
, homeManagerInput ? home-manager
,
}:
nixpkgsInput.lib.nixosSystem {
# Use the original nixpkgs.lib here
specialArgs = {
inherit inputs;
}
// specialArgs; # Removed pkgs from specialArgs
modules = [
{
nixpkgs.config.allowUnfree = true;
nixpkgs.config.permittedInsecurePackages = permittedInsecurePackages;
nixpkgs.overlays = hostOverlays;
}
sops-nix.nixosModules.sops
(./hosts + "/${hostname}/configuration.nix")
homeManagerInput.nixosModules.home-manager
nix-flatpak.nixosModules.nix-flatpak
{
home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true;
home-manager.backupFileExtension = "backup";
home-manager.extraSpecialArgs = { inherit inputs; };
home-manager.sharedModules = [
./home-manager/modules/zsh.nix
];
}
]
++ (nixpkgs.lib.map # Use original nixpkgs.lib here
(user: import (./home-manager/users + "/${user}.nix"))
(hostUsers.${hostname} or [ ])
)
++ modules;
};
in
{
nixosConfigurations = {
x1carbon = mkNixosSystem "x1carbon" {
hostOverlays = overlays;
};
caitlin-x1 = mkNixosSystem "caitlin-x1" { };
x470 = mkNixosSystem "x470" { };
mary-x270 = mkNixosSystem "mary-x270" { };
richmond-server = mkNixosSystem "richmond-server" {
hostOverlays = serverOverlays;
};
homeserver-1 = mkNixosSystem "homeserver-1" {
hostOverlays = serverOverlays;
};
mcf-server = mkNixosSystem "mcf-server" {
hostOverlays = serverOverlays;
};
mcf-stream = mkNixosSystem "mcf-stream" { };
hp-laptop = mkNixosSystem "hp-laptop" { };
};
packages.${system} = { };
formatter.${system} = nixpkgs.legacyPackages.${system}.nixfmt;
devShells.${system}.default =
let
devPkgs = nixpkgs.legacyPackages.${system};
in
devPkgs.mkShell {
packages = with devPkgs; [
sops
age
nixfmt
];
};
};
}
+51
View File
@@ -0,0 +1,51 @@
{ pkgs, lib, ... }:
{
imports = [
./gnome.nix
./kitty.nix
./firefox.nix
./tools/just.nix
];
home.activation.linkOnlyOfficeFonts = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
FONT_DIR="/run/current-system/sw/share/X11/fonts"
USER_FONTS="$HOME/.local/share/fonts"
NATIVE_TARGET="$HOME/.local/share/onlyoffice/desktopeditors/data/fonts"
FLATPAK_TARGET="$HOME/.var/app/org.onlyoffice.desktopeditors/data/onlyoffice/desktopeditors/data/fonts"
if [ -d "$FONT_DIR" ]; then
for TARGET_DIR in "$USER_FONTS" "$NATIVE_TARGET" "$FLATPAK_TARGET"; do
$DRY_RUN_CMD mkdir -p "$TARGET_DIR"
$DRY_RUN_CMD rm -f "$TARGET_DIR/AllFonts.js"* "$TARGET_DIR/fonts_thumbnail"* "$TARGET_DIR/font_selection.bin" "$TARGET_DIR/fonts.log"
$DRY_RUN_CMD cp -Lf "$FONT_DIR"/*.ttf "$FONT_DIR"/*.otf "$FONT_DIR"/*.ttc "$TARGET_DIR/" 2>/dev/null || true
done
fi
'';
programs.vscode = {
enable = true;
package = pkgs.vscode;
profiles.default = {
extensions =
with pkgs.vscode-extensions;
[
bbenoist.nix
ms-python.python
dart-code.flutter
]
++ [
# OpenCode Go: Copilot Provider - pinned to specific version for reproducibility.
# This extension is not in nixpkgs, only available from VSCode Marketplace.
(pkgs.vscode-utils.extensionFromVscodeMarketplace {
name = "opencode-go-for-copilot";
publisher = "DenizhanDaklr";
version = "0.1.18";
sha256 = "0igdkgv6dra3zpd80lhwymdh216dqn8m8kqwsxh258wyk9fzgfk0";
})
];
};
};
fonts.fontconfig.enable = true;
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
"modules": [
"title",
"separator",
"os",
"host",
"kernel",
"uptime",
"packages",
"shell",
"display",
"de",
"wm",
"wmtheme",
"theme",
"icons",
"font",
"cursor",
"terminal",
"terminalfont",
"cpu",
"gpu",
"memory",
"swap",
"disk",
"localip",
"battery",
"poweradapter",
"locale",
"break",
{
"type": "command",
"key": "Age Public Key",
"text": "cat /tmp/age-public-key.txt 2>/dev/null || echo 'Not generated yet'"
},
"colors"
]
}
+19
View File
@@ -0,0 +1,19 @@
{ pkgs, ... }:
{
programs.firefox = {
enable = true;
configPath = ".mozilla/firefox";
profiles.petere = {
isDefault = true;
settings = {
"ui.systemUsesDarkTheme" = 1;
"browser.in-content.dark-mode" = true;
"extensions.activeThemeID" = "firefox-compact-dark@mozilla.org";
"browser.newtabpage.enabled" = false;
"browser.startup.homepage" = "about:blank";
"browser.aboutConfig.showWarning" = false;
};
};
};
}
+20
View File
@@ -0,0 +1,20 @@
{ pkgs, ... }:
# Shared GNOME extensions: AppIndicator, User Themes, Caffeine
{
dconf.settings = {
"org/gnome/shell" = {
enabled-extensions = [
"user-theme@gnome-shell-extensions.gcampax.github.com"
"appindicatorsupport@rgcjonas.gmail.com"
"caffeine@patapon.info"
];
};
};
home.packages = with pkgs; [
gnomeExtensions.appindicator
gnomeExtensions.user-themes
gnomeExtensions.caffeine
];
}
+169
View File
@@ -0,0 +1,169 @@
{ config
, pkgs
, lib
, ...
}:
{
dconf.settings = {
# Desktop interface settings
"org/gnome/desktop/interface" = {
color-scheme = "prefer-dark";
enable-hot-corners = false;
clock-show-weekday = true;
show-battery-percentage = true;
icon-theme = "Papirus-Dark";
cursor-theme = "Bibata-Modern-Classic";
};
"org/gnome/desktop/input-sources" = {
sources = [
(lib.gvariant.mkTuple [
"xkb"
"gb"
])
];
xkb-options = [ "terminate:ctrl_alt_bksp" ];
};
# Window manager preferences
"org/gnome/desktop/wm/preferences" = {
button-layout = "appmenu:minimize,maximize,close";
num-workspaces = 4;
};
# Keybindings
"org/gnome/desktop/wm/keybindings" = {
close = [ "<Super>q" ];
switch-to-workspace-1 = [ "<Super>1" ];
switch-to-workspace-2 = [ "<Super>2" ];
switch-to-workspace-3 = [ "<Super>3" ];
switch-to-workspace-4 = [ "<Super>4" ];
move-to-workspace-1 = [ "<Super><Shift>1" ];
move-to-workspace-2 = [ "<Super><Shift>2" ];
move-to-workspace-3 = [ "<Super><Shift>3" ];
move-to-workspace-4 = [ "<Super><Shift>4" ];
};
# Shell settings
"org/gnome/shell" = {
favorite-apps = [
"firefox.desktop"
"kitty.desktop"
"org.gnome.Nautilus.desktop"
"thunderbird.desktop"
"chromium-browser.desktop"
];
enabled-extensions = [
"user-theme@gnome-shell-extensions.gcampax.github.com"
"appindicatorsupport@rgcjonas.gmail.com"
"caffeine@patapon.info"
"screen-rotate@shyzus.github.io"
"gsconnect@andyholmes.github.io"
];
};
# Mutter (window manager) settings
"org/gnome/mutter" = {
dynamic-workspaces = false;
edge-tiling = true;
workspaces-only-on-primary = true;
};
# File manager (Nautilus) settings
"org/gnome/nautilus/preferences" = {
default-folder-viewer = "list-view";
search-filter-time-type = "last_modified";
show-hidden-files = true;
};
# Power settings
"org/gnome/settings-daemon/plugins/power" = {
sleep-inactive-ac-type = "nothing";
sleep-inactive-battery-timeout = 1800;
};
# Privacy settings
"org/gnome/desktop/privacy" = {
remember-recent-files = true;
remove-old-temp-files = true;
remove-old-trash-files = true;
};
# Session settings
"org/gnome/desktop/session" = {
idle-delay = lib.gvariant.mkUint32 900; # 15 minutes
};
# Screensaver settings
"org/gnome/desktop/screensaver" = {
lock-enabled = true;
lock-delay = lib.gvariant.mkUint32 0;
};
# Caffeine extension settings
"org/gnome/shell/extensions/caffeine" = {
enable-fullscreen = true;
restore-state = true;
show-indicator = "always";
show-notifications = false;
};
};
# GNOME-specific packages
home.packages = with pkgs; [
gnome-tweaks
gnomeExtensions.appindicator
gnomeExtensions.user-themes
gnomeExtensions.caffeine
gnomeExtensions.gsconnect
dconf-editor
catppuccin-gtk
papirus-icon-theme
bibata-cursors
];
# GTK theme configuration
gtk = {
enable = true;
gtk4.theme = config.gtk.theme;
theme = {
name = "catppuccin-frappe-blue-standard";
package = pkgs.catppuccin-gtk;
};
iconTheme = {
name = "Papirus-Dark";
package = pkgs.papirus-icon-theme;
};
cursorTheme = {
name = "Bibata-Modern-Classic";
package = pkgs.bibata-cursors;
};
};
# Home Manager cursor configuration
home.pointerCursor = {
enable = true;
name = "Bibata-Modern-Classic";
package = pkgs.bibata-cursors;
gtk.enable = true;
x11.enable = true;
};
home.file."Pictures/wallpapers/tokyo-night.png".source = ../../assets/wallpapers/tokyo-night.png;
# Set initial default wallpaper on first install if user hasn't configured a custom wallpaper yet.
# If the user changes their wallpaper via GNOME Settings, dconf read detects it and leaves it untouched across reboots/updates.
home.activation.setInitialWallpaper = lib.hm.dag.entryAfter [ "dconf" ] ''
WALLPAPER="${config.home.homeDirectory}/Pictures/wallpapers/tokyo-night.png"
VAL="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/background/picture-uri 2>/dev/null || true)"
if [ -z "$VAL" ]; then
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri "'file://$WALLPAPER'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$WALLPAPER'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-options "'zoom'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/screensaver/picture-uri "'file://$WALLPAPER'"
$DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/screensaver/picture-options "'zoom'"
fi
'';
}
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
# hyprland-tablet-daemon.py — Hyprland tablet-mode rotation + on-screen keyboard.
#
# Wired to the ThinkPad X1 Yoga Gen 6 (Intel HID switches, /dev/input/event17):
# * SW_TABLET_MODE tells us the LCD is folded into tablet mode.
# * "Accelerometer orientation changed: <orient>" lines are parsed from
# `monitor-sensor` (a thin client of iio-sensor-proxy, which runs as a
# system service on this host). iio-sensor-proxy merges the accel axes +
# mount matrix into a single compass-style orientation string.
#
# While in tablet mode the eDP-1 monitor transform (and matching per-device
# transforms for touch inputs) follow the accelerometer. The on-screen
# keyboard (wvkbd) follows text focus instead of Hyprland's input-method v2
# protocol (which Hyprland advertises but never fires):
# * We watch `.socket2.sock` "activewindow>>class,title" events. If the
# focused window class is in the text-capable allowlist the keyboard is
# shown (SIGUSR2), otherwise hidden (SIGUSR1). This gives reliable
# auto-hide when there is nothing to type into.
# * Manual control (super+crtl+k / the bar chip) flips wvkbd directly via
# SIGRTMIN. The daemon re-applies focus state on the next focus change,
# so a manual dismissal lasts until the user switches windows.
# Leaving tablet mode snaps transform back to 0 and kills wvkbd.
#
# State is mirrored to a small JSON file ($XDG_RUNTIME_DIR/hyprland-tablet)
# that the QuickShell bar chip reads via `hyprland-tablet status`:
# {"tablet": bool, "osk": visible-or-not}
#
# Control commands arrive over a Unix datagram socket
# ($XDG_RUNTIME_DIR/hyprland-tablet.ctl): "toggle" | "show" | "hide".
import json
import os
import re
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
# Orientation string (from iio-sensor-proxy) -> Hyprland monitor transform.
# Hyprland transform: 0 normal, 1 90° CCW, 2 180°, 3 270° CCW.
# If the screen rotates the wrong way on hardware, swap the 1 and 3 below.
ORIENTATION_TRANSFORM = {
"normal": 0,
"left-up": 1,
"bottom-up": 2,
"right-up": 3,
}
SW_TABLET_MODE = 0x01 # input event code for SW_TABLET_MODE
# Paths resolved at build time by the Nix module (wrapped with pkgs.python3).
# The placeholder strings below are in at-sign-delimited form because
# pkgs.replaceVarsWith substitutes exactly that syntax.
EVDEV_DEVICE = "@EVDEV_DEVICE@"
WVKBD_PATH = "@WVKBD_PATH@"
WVKBD_ARGS = @WVKBD_ARGS@ # replaced with a JSON array (valid Python list)
MONITOR_SENSOR = "@MONITOR_SENSOR@"
HYPRCTL = "@HYPRCTL@"
TEXT_APPS = @TEXT_APPS_JSON@ # replaced with a JSON array (valid Python list)
# State file lives in $XDG_RUNTIME_DIR (set by the user systemd service); the
# QuickShell bar chip + CLI read the same path.
STATE_FILE = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet")
CONTROL_SOCKET = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet.ctl")
DEBUG = os.environ.get("HYPRLAND_TABLET_DEBUG") == "1"
def log(msg: str) -> None:
if DEBUG:
print(f"[hyprland-tablet] {msg}", file=sys.stderr, flush=True)
_state_lock = threading.Lock()
def write_state(tablet: bool, osk: bool) -> None:
try:
with open(STATE_FILE, "w") as f:
json.dump({"tablet": tablet, "osk": osk}, f)
except OSError:
pass
def hyprctl_transform(monitor: str, transform: int) -> None:
"""Apply a transform to the monitor and every touch/tablet device."""
# hyprland.nix uses the Lua config parser (0.55+), so the legacy
# `monitor NAME,transform,N` / input keyword lines don't apply at runtime
# ("keyword can't work with non-legacy parsers"). The equivalent Lua API:
# hl.monitor({ output = "NAME", mode = ..., scale = ..., transform = N })
# hl.config({ input = { touchdevice/tablet = { transform = N } } })
# scale MUST be passed explicitly: omitting it makes Hyprland re-derive
# HiDPI zoom (1.5 here), blowing up the bar on rotation.
tx = [HYPRCTL, "eval", f'hl.monitor({{ output = "{monitor}", mode = "preferred", position = "auto", scale = 1, transform = {transform} }})']
subprocess.run(tx, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
# touchdevice transform follows the display so touch coordinates track the
# rotated framebuffer. input:touchdevice:transform is a global input option
# (not per-device device:touchdevice:transform which doesn't exist).
subprocess.run(
[HYPRCTL, "eval", f'hl.config({{ input = {{ touchdevice = {{ transform = {transform} }} }} }})'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
# same for the tablet (pen) — this Yoga presents the Wacom digitizer as a
# tablet device, and without this the pen coordinates stay in screen space.
subprocess.run(
[HYPRCTL, "eval", f'hl.config({{ input = {{ tablet = {{ transform = {transform} }} }} }})'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
def transform_for(orientation: str) -> int:
return ORIENTATION_TRANSFORM.get(orientation, 0)
def _hypr_socket(name: str) -> str:
"""Locate a Hyprland IPC socket (".socket.sock" / ".socket2.sock")."""
runtime = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
inst = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
if inst:
candidate = os.path.join(runtime, "hypr", inst, name)
if os.path.exists(candidate):
return candidate
base = os.path.join(runtime, "hypr")
try:
for entry in os.listdir(base):
candidate = os.path.join(base, entry, name)
if os.path.exists(candidate):
return candidate
except OSError:
pass
return ""
class Daemon:
def __init__(self, monitor: str) -> None:
self.monitor = monitor
self.tablet = False
self.orientation = "normal"
self.current_transform = 0
self.osk_proc = None
self.osk_visible = False
self.dev = None
self.sensor = None
self.last_focus_class = ""
# -------------------------------------------------------------
# evdev switch watcher (thread)
# -------------------------------------------------------------
def open_evdev(self) -> bool:
try:
import evdev
import evdev.ecodes as ecodes
self.dev = evdev.InputDevice(EVDEV_DEVICE)
except Exception as e:
log(f"cannot open {EVDEV_DEVICE}: {e}")
return False
# Seed the current switch state via EVIOCGSW so the daemon is correct
# if it starts mid-tablet.
EVIOCGSW = (2 << 30) | (ord("E") << 8) | 0x1B | (64 << 16)
buf = bytearray(64)
try:
import fcntl
fcntl.ioctl(self.dev.fd, EVIOCGSW, buf)
vals = struct.unpack("16i", buf)
self.tablet = bool(vals[0] >> SW_TABLET_MODE & 1)
except OSError:
self.tablet = False
log(f"opened {self.dev.name}, starting tablet={self.tablet}")
return True
def evdev_thread(self) -> None:
import evdev.ecodes as ecodes
for event in self.dev.read_loop():
if event.type != ecodes.EV_SW or event.code != SW_TABLET_MODE:
continue
new = event.value == 1
if new != self.tablet:
self.tablet = new
log(f"SW_TABLET_MODE -> {new}")
self.on_tablet_changed(new)
# -------------------------------------------------------------
# orientation watcher (thread)
# -------------------------------------------------------------
def _start_sensor(self) -> bool:
try:
self.sensor = subprocess.Popen(
[MONITOR_SENSOR],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
except FileNotFoundError:
log("monitor-sensor not found")
return False
return self.sensor.stdout is not None
def sensor_thread(self) -> None:
# monitor-sensor (a thin client of iio-sensor-proxy) only emits
# "Accelerometer orientation changed:" on *changes*; the initial
# orientation arrives as "Has accelerometer (orientation: X, ...)".
# Match both, and restart the client if it ever exits so orientation
# tracking survives.
first = True
while True:
if not self._start_sensor():
return
if self.sensor is None or self.sensor.stdout is None:
return
if first:
log("monitor-sensor started")
first = False
for line in self.sensor.stdout:
m = re.search(r"Accelerometer orientation changed:\s*(\S+)", line.strip())
if m:
self.on_orientation(m.group(1))
continue
m = re.search(r"Has accelerometer \(orientation:\s*(\S+)", line.strip())
if m:
# value is "normal," / "left-up," — strip the separator
self.on_orientation(m.group(1).rstrip(","))
# monitor-sensor exited: reopen it (iio-sensor-proxy may have
# dropped our client if a second one connected).
log("monitor-sensor exited, restarting")
time.sleep(1)
# -------------------------------------------------------------
# Hyprland focus watcher (thread) — drives auto show/hide
# -------------------------------------------------------------
def focus_thread(self) -> None:
path = _hypr_socket(".socket2.sock")
if not path:
log("hyprland .socket2.sock not found; focus auto-hide disabled")
return
log(f"watching focus events on {path}")
while True:
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(path)
f = s.makefile("r")
for line in f:
line = line.strip()
if not line.startswith("activewindow>>"):
continue
payload = line.split(">>", 1)[1].split(",", 1)
self.on_focus_changed(payload[0].strip())
except OSError as e:
log(f"focus socket error: {e}")
time.sleep(2)
def _is_text_app(self, window_class: str) -> bool:
if not window_class:
return False
lowered = window_class.lower()
for spec in TEXT_APPS:
if lowered == spec or lowered.startswith(spec):
return True
return False
def on_focus_changed(self, window_class: str) -> None:
self.last_focus_class = window_class
if not self.tablet:
return # in laptop mode the OSK duty is entirely manual
want = self._is_text_app(window_class)
log(f"focus={window_class!r} text_app={want}")
self.set_osk_visible(want)
# -------------------------------------------------------------
# control socket (thread) — manual show/hide/toggle
# -------------------------------------------------------------
def control_thread(self) -> None:
try:
os.unlink(CONTROL_SOCKET)
except OSError:
pass
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
s.bind(CONTROL_SOCKET)
s.settimeout(1.0)
except OSError as e:
log(f"cannot bind control socket: {e}")
return
log(f"control socket ready at {CONTROL_SOCKET}")
while True:
try:
data, _ = s.recvfrom(128)
except socket.timeout:
continue
except OSError:
break
cmd = data.decode("ascii", "replace").strip()
log(f"control command: {cmd}")
if cmd == "toggle":
self.toggle_osk()
elif cmd == "show":
self.set_osk_visible(True)
elif cmd == "hide":
self.set_osk_visible(False)
# -------------------------------------------------------------
# actions
# -------------------------------------------------------------
def ensure_osk(self) -> None:
with _state_lock:
if self.osk_proc is not None and self.osk_proc.poll() is None:
return
try:
# Start hidden: focus state decides whether to SIGUSR2 it.
self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"])
self.osk_visible = False
log(f"wvkbd started (hidden, args={WVKBD_ARGS!r})")
except FileNotFoundError:
log("wvkbd not found")
write_state(self.tablet, self.osk_visible)
def kill_osk(self) -> None:
with _state_lock:
if self.osk_proc is not None and self.osk_proc.poll() is None:
self.osk_proc.terminate()
try:
self.osk_proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self.osk_proc.kill()
self.osk_proc.wait()
log("wvkbd stopped")
self.osk_proc = None
self.osk_visible = False
write_state(self.tablet, False)
def _signal_osk(self, sig: int) -> None:
if self.osk_proc is not None and self.osk_proc.poll() is None:
try:
os.kill(self.osk_proc.pid, sig)
except OSError:
pass
def set_osk_visible(self, visible: bool) -> None:
if visible == self.osk_visible:
return
if visible:
if self.osk_proc is None or self.osk_proc.poll() is not None:
self.ensure_osk()
if self.osk_proc is None:
return
self._signal_osk(signal.SIGUSR2)
self.osk_visible = True
log("wvkbd show")
else:
self._signal_osk(signal.SIGUSR1)
self.osk_visible = False
log("wvkbd hide")
write_state(self.tablet, self.osk_visible)
def toggle_osk(self) -> None:
with _state_lock:
if self.osk_proc is None or self.osk_proc.poll() is not None:
self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"])
self.osk_visible = False
log("wvkbd started (hidden, manual toggle)")
write_state(self.tablet, self.osk_visible)
return
# wvkbd SIGRTMIN toggles visibility; we shadow its state here.
self.osk_visible = not self.osk_visible
self._signal_osk(signal.SIGRTMIN)
log(f"wvkbd toggled -> visible={self.osk_visible}")
write_state(self.tablet, self.osk_visible)
def apply_transform(self, t: int) -> None:
if t != self.current_transform:
hyprctl_transform(self.monitor, t)
self.current_transform = t
log(f"transform -> {t}")
# -------------------------------------------------------------
# event handlers
# -------------------------------------------------------------
def on_tablet_changed(self, new: bool) -> None:
if new:
self.apply_transform(transform_for(self.orientation))
self.ensure_osk()
else:
self.apply_transform(0)
self.kill_osk()
write_state(self.tablet, self.osk_visible)
def on_orientation(self, orient: str) -> None:
self.orientation = orient
if self.tablet:
self.apply_transform(transform_for(orient))
# laptop mode: never rotate (gated by the switch above)
# -------------------------------------------------------------
def run(self) -> int:
have_evdev = self.open_evdev()
# If we started mid-tablet, bring Hyprland to the current state right
# away instead of waiting for the next SW_TABLET_MODE edge. The OSK
# duty for the currently-focused window is applied below.
if self.tablet:
self.apply_transform(transform_for(self.orientation))
self.ensure_osk()
cls = self.cur_focus_class()
if cls:
self.on_focus_changed(cls)
write_state(self.tablet, self.osk_visible)
threads = []
if have_evdev:
t = threading.Thread(target=self.evdev_thread, daemon=True)
t.start()
threads.append(t)
t = threading.Thread(target=self.sensor_thread, daemon=True)
t.start()
threads.append(t)
t = threading.Thread(target=self.focus_thread, daemon=True)
t.start()
threads.append(t)
t = threading.Thread(target=self.control_thread, daemon=True)
t.start()
threads.append(t)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
return 0
def cur_focus_class(self) -> str:
try:
out = subprocess.run(
[HYPRCTL, "activewindow", "-j"],
capture_output=True,
text=True,
timeout=3,
)
info = json.loads(out.stdout)
return info.get("class") or ""
except Exception:
return ""
def main() -> int:
monitor = os.environ.get("HYPRLAND_TABLET_MONITOR", "eDP-1")
d = Daemon(monitor)
return d.run()
if __name__ == "__main__":
raise SystemExit(main())
+219
View File
@@ -0,0 +1,219 @@
{
config,
pkgs,
...
}:
let
palette = import ./palette.nix;
evdevPython = pkgs.python3.withPackages (ps: [ ps.evdev ]);
# Text-capable window classes. wvkbd's own --auto relies on Hyprland's
# input-method-v2 protocol, which Hyprland advertises but never actually
# fires — so auto show/hide is driven here by watching activewindow events.
# Add classes (lowercase prefixes are allowed) for anything you type into.
textApps = [
"kitty"
"alacritty"
"wezterm"
"konsole"
"com.mitchellh.ghostty"
"ghostty"
"xterm"
"firefox"
"librewolf"
"chromium"
"chromium-browser"
"google-chrome"
"microsoft-edge"
"brave-browser"
"thunderbird"
"com.github.xournalpp.xournalpp"
"code"
"code-url-handler"
"zen"
];
# Shell-escaped arg list for the CLI fallback (same content as wvkbdFlags
# but space-joined for shell expansion). The daemon receives the JSON form.
# The font spec is double-quoted so it survives shell word-splitting.
wvkbdFlagsShell = pkgs.lib.concatStringsSep " " [
"-l"
"simple"
"-H"
"300"
"-L"
"200"
"--fn"
"\"DejaVu Sans 24\""
"--alpha"
"235"
"--bg"
"${palette.wvkbdBg}"
"--fg"
"${palette.surface}"
"--fg-sp"
"${palette.wvkbdFgSp}"
"--press"
"${palette.neon}"
"--press-sp"
"${palette.violet}"
"--text"
"${palette.text}"
"--text-sp"
"${palette.muted}"
"--text-press"
"${palette.ink}"
"--text-press-sp"
"${palette.ink}"
"--swipe"
"${palette.cyan}"
"--swipe-sp"
"${palette.violet}"
"--text-swipe"
"${palette.ink}"
"--text-swipe-sp"
"${palette.ink}"
];
# wvkbd argv as a JSON array. The daemon splices this into place as a bare
# Python expression — a JSON array of strings IS valid Python (list of
# double-quoted strings), so no quote-escaping headaches at build time.
wvkbdArgs = [
"-l"
"simple"
"-H"
"300"
"-L"
"200"
"--fn"
"DejaVu Sans 24"
"--alpha"
"235"
"--bg"
"${palette.wvkbdBg}"
"--fg"
"${palette.surface}"
"--fg-sp"
"${palette.wvkbdFgSp}"
"--press"
"${palette.neon}"
"--press-sp"
"${palette.violet}"
"--text"
"${palette.text}"
"--text-sp"
"${palette.muted}"
"--text-press"
"${palette.ink}"
"--text-press-sp"
"${palette.ink}"
"--swipe"
"${palette.cyan}"
"--swipe-sp"
"${palette.violet}"
"--text-swipe"
"${palette.ink}"
"--text-swipe-sp"
"${palette.ink}"
];
wvkbdArgsJson = builtins.toJSON wvkbdArgs;
# Text-app allowlist as a JSON array, spliced the same way (valid Python
# list literal after substitution).
textAppsJson = builtins.toJSON textApps;
# Place build-time paths into the daemon (replaceVars honours the
# @VAR@ placeholders) and run it with the evdev-enabled python.
daemon = pkgs.replaceVarsWith {
name = "hyprland-tablet-daemon";
src = pkgs.writeScript "hyprland-tablet-daemon-src" ''
#!${evdevPython}/bin/python3
${builtins.readFile ./hyprland-tablet-daemon.py}
'';
isExecutable = true;
replacements = {
EVDEV_DEVICE = "/dev/input/event17";
WVKBD_PATH = "${pkgs.wvkbd}/bin/wvkbd-mobintl";
WVKBD_ARGS = wvkbdArgsJson;
TEXT_APPS_JSON = textAppsJson;
MONITOR_SENSOR = "${pkgs.iio-sensor-proxy}/bin/monitor-sensor";
HYPRCTL = "${pkgs.hyprland}/bin/hyprctl";
};
};
# Toggle/show/hide are forwarded to the daemon over a Unix datagram socket
# so manual control and focus-driven auto-hide share one visibility state.
cli = pkgs.writeShellScriptBin "hyprland-tablet" ''
set -eu
state_file="$XDG_RUNTIME_DIR/hyprland-tablet"
ctl_sock="$XDG_RUNTIME_DIR/hyprland-tablet.ctl"
ctl_send() {
if [ -S "$ctl_sock" ]; then
${pkgs.python3}/bin/python3 -c '
import os, socket, sys
path = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/hyprland-tablet.ctl"
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
s.sendto(sys.argv[1].encode(), path)
' "$1"
return 0
fi
return 1
}
osk_pid() {
pgrep -f 'wvkbd-mobintl' | head -n1 || true
}
case "''${1:-}" in
status)
if [ -f "$state_file" ]; then
cat "$state_file"
else
echo '{"tablet":false,"osk":false}'
fi
;;
# Toggle on-screen keyboard visibility. Manual control wins over the
# daemon's focus-derived state until the next activewindow change.
keyboard-toggle|osk-toggle)
ctl_send toggle || {
pid="$(osk_pid)"
if [ -z "$pid" ]; then
nohup ${pkgs.wvkbd}/bin/wvkbd-mobintl ${wvkbdFlagsShell} >/dev/null 2>&1 &
else
kill -SIGRTMIN "$pid"
fi
}
;;
osk-show)
ctl_send show || { pid="$(osk_pid)"; [ -n "$pid" ] && kill -SIGUSR2 "$pid"; }
;;
osk-hide)
ctl_send hide || { pid="$(osk_pid)"; [ -n "$pid" ] && kill -SIGUSR1 "$pid"; }
;;
*)
echo "usage: hyprland-tablet {status|keyboard-toggle|osk-show|osk-hide}" >&2
exit 2
;;
esac
'';
in
{
home.packages = [ cli ];
systemd.user.services.hyprland-tablet = {
Unit = {
Description = "Hyprland tablet-mode auto-rotation + on-screen keyboard";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
Type = "simple";
ExecStart = daemon;
Restart = "on-failure";
};
Install = {
WantedBy = [ "graphical-session.target" ];
};
};
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{ pkgs, ... }:
let
palette = import ./palette.nix;
in
{
programs.kitty = {
enable = true;
font = {
name = "FiraCode Nerd Font";
size = 12;
};
settings = {
enabled_layouts = "tall:bias=50;full_size=1;mirrored=false,fat,grid,stack";
scrollback_lines = 10000;
enable_audio_bell = false;
update_check_interval = 0;
shell_integration = "enabled";
# Performance
repaint_delay = 10;
input_delay = 3;
sync_to_monitor = "yes";
# Window layout
window_padding_width = 10;
confirm_os_window_close = 0;
background_opacity = "0.85";
dynamic_background_opacity = "yes";
# Tabs (colors live in colors.conf so `qs-theme` can override them)
tab_bar_style = "powerline";
};
keybindings = {
"ctrl+shift+c" = "copy_to_clipboard";
"ctrl+shift+v" = "paste_from_clipboard";
"ctrl+shift+enter" = "new_window";
"ctrl+shift+]" = "next_window";
"ctrl+shift+[" = "previous_window";
"ctrl+shift+l" = "next_layout";
};
# Colors live in an include so a wallpaper-driven palette (`qs-theme`,
# via matugen) can overwrite them without touching the rest of kitty.conf.
extraConfig = "include colors.conf";
};
xdg.configFile."kitty/colors.conf".text = ''
# Color palette (Tokyo Night inspired) declared in home-manager.
# Regenerated by `~/.local/bin/qs-theme <wallpaper>` (matugen) at runtime.
background ${palette.hash palette.bgDark}
foreground ${palette.hash palette.muted}
selection_background ${palette.hash palette.selection}
selection_foreground ${palette.hash palette.text}
url_color ${palette.hash palette.teal}
cursor ${palette.hash palette.text}
cursor_text_color ${palette.hash palette.bgDark}
active_tab_background ${palette.hash palette.blue}
active_tab_foreground ${palette.hash palette.tabFgBright}
inactive_tab_background ${palette.hash palette.tabBg}
inactive_tab_foreground ${palette.hash palette.tabFg}
# ANSI 16
color0 ${palette.hash palette.color0}
color1 ${palette.hash palette.danger}
color2 ${palette.hash palette.green}
color3 ${palette.hash palette.yellow}
color4 ${palette.hash palette.blue}
color5 ${palette.hash palette.purple}
color6 ${palette.hash palette.cyan}
color7 ${palette.hash palette.muted}
color8 ${palette.hash palette.color8}
color9 ${palette.hash palette.danger}
color10 ${palette.hash palette.green}
color11 ${palette.hash palette.yellow}
color12 ${palette.hash palette.blue}
color13 ${palette.hash palette.purple}
color14 ${palette.hash palette.cyan}
color15 ${palette.hash palette.text}
'';
}
@@ -0,0 +1,33 @@
background {{colors.surface.dark.hex}}
foreground {{colors.on_surface.dark.hex}}
selection_background {{colors.primary.dark.hex}}
selection_foreground {{colors.on_primary.dark.hex}}
url_color {{colors.tertiary.dark.hex}}
cursor {{colors.on_surface.dark.hex}}
cursor_text_color {{colors.surface.dark.hex}}
active_tab_background {{colors.primary.dark.hex}}
active_tab_foreground {{colors.on_primary.dark.hex}}
inactive_tab_background {{colors.surface_container_low.dark.hex}}
inactive_tab_foreground {{colors.on_surface_variant.dark.hex}}
# ANSI 16 — mapped to Material-You roles (NOT base16: matugen's base16 dark
# accents collapse to near-black, making colored terminal text unreadable).
# Material roles are the dark-mode light-tinted variants, all >10:1 contrast
# on the surface, so both text and UI stay legible.
color0 {{colors.surface.dark.hex}}
color1 {{colors.error.dark.hex}}
color2 {{colors.tertiary.dark.hex}}
color3 {{colors.secondary.dark.hex}}
color4 {{colors.primary.dark.hex}}
color5 {{colors.tertiary.dark.hex}}
color6 {{colors.secondary.dark.hex}}
color7 {{colors.on_surface.dark.hex}}
color8 {{colors.outline.dark.hex}}
color9 {{colors.error.dark.hex}}
color10 {{colors.tertiary.dark.hex}}
color11 {{colors.secondary.dark.hex}}
color12 {{colors.primary.dark.hex}}
color13 {{colors.tertiary.dark.hex}}
color14 {{colors.secondary.dark.hex}}
color15 {{colors.on_surface.dark.hex}}
@@ -0,0 +1,11 @@
{
"surface": "{{colors.surface.dark.hex}}",
"ink": "{{colors.background.dark.hex}}",
"text": "{{colors.on_surface.dark.hex}}",
"muted": "{{colors.on_surface_variant.dark.hex}}",
"line": "{{colors.outline.dark.hex}}",
"neon": "{{colors.primary.dark.hex}}",
"violet": "{{colors.tertiary.dark.hex}}",
"magenta": "{{colors.secondary.dark.hex}}",
"danger": "{{colors.error.dark.hex}}"
}
+55
View File
@@ -0,0 +1,55 @@
# Wallpaper-driven Material-You theming (matugen) for the QuickShell shell.
# `qs-theme <wallpaper>` regenerates:
# - ~/.cache/quickshell/theme.json → live-repaints the bar (FileView watcher)
# - ~/.config/kitty/colors.conf → kitty palette + ANSI 16
# The Nix-declared colors.conf symlink is restored on the next home-manager
# rebuild; until then the generated palette wins (see the rm in qs-theme).
{
config,
pkgs,
...
}:
{
home.packages = [ pkgs.matugen ];
# Default matugen config — renders our two templates on every run.
xdg.configFile."matugen/config.toml".text = ''
[config]
[templates.quick-shell]
input_path = "${config.home.homeDirectory}/.config/matugen/themes/theme.json.template"
output_path = "${config.home.homeDirectory}/.cache/quickshell/theme.json"
[templates.kitty]
input_path = "${config.home.homeDirectory}/.config/matugen/themes/kitty-colors.conf.template"
output_path = "${config.home.homeDirectory}/.config/kitty/colors.conf"
'';
# Template sources live in the repo so matugen can read them at runtime.
xdg.configFile."matugen/themes/theme.json.template".text =
builtins.readFile ./matugen-themes/theme.json.template;
xdg.configFile."matugen/themes/kitty-colors.conf.template".text =
builtins.readFile ./matugen-themes/kitty-colors.conf.template;
# qs-theme — regenerate the wallpaper palette, then repaint the live bar.
home.file.".local/bin/qs-theme" = {
executable = true;
text = ''
#!/bin/sh
# Regenerate the Material-You palette (QuickShell bar + kitty) from a
# wallpaper. The bar picks it up live via its theme.json watcher.
set -e
if [ "$#" -lt 1 ]; then
echo "usage: qs-theme <wallpaper>" >&2
exit 1
fi
# matugen refuses to overwrite the Nix-declared symlink; unlink it first
# (home-manager restores it on the next rebuild).
rm -f "$HOME/.config/kitty/colors.conf"
exec ${pkgs.matugen}/bin/matugen image "$1" -m dark --prefer darkness
'';
};
}
+31
View File
@@ -0,0 +1,31 @@
{ pkgs, ... }:
{
# nil (Nix language server) for x1carbon only — wired into VS Code via the
# nix-community Nix IDE extension and into opencode (see opencode.nix).
home.packages = [ pkgs.nil ];
programs.vscode = {
enable = true;
profiles.default = {
extensions = [ pkgs.vscode-extensions.jnoortheen.nix-ide ];
userSettings = {
"nix.enableLanguageServer" = true;
"nix.serverPath" = "${pkgs.nil}/bin/nil";
# All nil LSP settings nest under a "nil" key. Formatting shells out
# to nixfmt (the flake formatter), reading stdin / writing stdout.
"nix.serverSettings" = {
nil = {
formatting = {
command = [
"nixfmt"
"-"
];
};
};
};
};
};
};
}
+60
View File
@@ -0,0 +1,60 @@
{ pkgs, ... }:
{
# mcp-nixos gives opencode + VS Code real NixOS package/option data instead of
# hallucinated package names. Only installed on x1carbon (this module is only
# imported from hosts/x1carbon), so it does not leak to other machines.
home.packages = [ pkgs.mcp-nixos ];
xdg.configFile."opencode/opencode.json".text = builtins.toJSON {
"$schema" = "https://opencode.ai/config.json";
provider = {
# lmstudio = {
# npm = "@ai-sdk/openai-compatible";
# name = "LM Studio (local)";
# options = {
# baseURL = "http://127.0.0.1:1234/v1";
# };
# models = {
# "google/gemma-4-e4b" = {
# name = "Gemma 4 E4B (LM Studio)";
# limit = {
# context = 65536;
# output = 32768;
# };
# };
# };
# };
opencode-go = { };
};
model = "opencode-go/kimi-k2.6";
lsp = {
# nil (Nix language server, installed by nix-lsp.nix) as a custom LSP
# server so the agent gets Nix diagnostics. Formatter: nixfmt (stdin).
nil = {
command = [ "${pkgs.nil}/bin/nil" ];
extensions = [ ".nix" ];
initialization = {
nil = {
formatting = {
command = [
"nixfmt"
"-"
];
};
};
};
};
};
mcp = {
# NixOS packages/options MCP server (stdlib CLI; binary provided by
# pkgs.mcp-nixos). Use "the nix tool" to look up real package names,
# options and versions while editing flake/host configs.
nixos = {
type = "local";
command = [ "mcp-nixos" ];
enabled = true;
};
};
};
}
+89
View File
@@ -0,0 +1,89 @@
# Shared color palette for the "sci-fi dark glass + vivid neon" desktop.
# Single source of truth: every consumer (QuickShell QML theme, kitty,
# wvkbd, wofi, hyprlock, Hyprland borders/shadows, matugen fallback) reads
# its colors from here instead of hardcoding hex literals.
#
# Colors are stored as raw hex WITHOUT the leading '#'. Use the helpers below
# to format for each consumer:
# palette.hash "#rrggbb" (QML, kitty, wofi borders)
# palette.cssRgba "rgba(r, g, b, a)" (wofi)
# palette.lockRgba "rgba(r, g, b, a)" (hyprlock)
# palette.hyprRgba "rgba(rrggbbaa)" (Hyprland, compact hex+alpha)
# palette.rgb [ r g b ] decimals
rec {
# ---- Surfaces (translucent over the Hyprland layer blur) ----
# glass/glassPanel are 8-digit AARRGGBB for QML only.
glass = "8c0f111c";
glassPanel = "c2141627";
surface = "24283b";
panel = "141627"; # wofi / hyprlock solid panel
line = "394b70";
# ---- Text ----
text = "c0caf5";
muted = "a9b1d6";
ink = "0b0e14";
# ---- Neon accents ----
neon = "00e5ff";
magenta = "ff2e97";
violet = "7c4dff";
cyan = "7dcfff";
blue = "7aa2f7";
green = "9ece6a";
yellow = "e0af68";
purple = "bb9af7";
teal = "73daca"; # kitty url_color
danger = "f7768e";
# ---- kitty extras (Tokyo Night inspired) ----
bgDark = "1a1b26";
color0 = "15161e";
color8 = "414868";
selection = "33467c";
tabBg = "292e42";
tabFg = "545c7e";
tabFgBright = "16161e";
# ---- wvkbd ----
wvkbdBg = "1b1e2d";
wvkbdFgSp = "1f2740";
# ---- helpers ----
hexToInt =
h:
let
m = {
"0" = 0;
"1" = 1;
"2" = 2;
"3" = 3;
"4" = 4;
"5" = 5;
"6" = 6;
"7" = 7;
"8" = 8;
"9" = 9;
a = 10;
b = 11;
c = 12;
d = 13;
e = 14;
f = 15;
};
len = builtins.stringLength h;
chars = builtins.genList (i: builtins.substring i 1 h) len;
in
builtins.foldl' (acc: c: acc * 16 + m.${c}) 0 chars;
rgb = h: [
(hexToInt (builtins.substring 0 2 h))
(hexToInt (builtins.substring 2 2 h))
(hexToInt (builtins.substring 4 2 h))
];
hash = c: "#${c}";
cssRgba = c: a: "rgba(${builtins.concatStringsSep ", " (map toString (rgb c))}, ${a})";
lockRgba = c: a: "rgba(${builtins.concatStringsSep ", " (map toString (rgb c))}, ${a})";
hyprRgba = c: a: "rgba(${c}${a})";
}
+73
View File
@@ -0,0 +1,73 @@
{
config,
pkgs,
lib,
...
}:
# QuickShell autostart app manager: lets the user pick from the gear
# quick-settings panel which installed apps start at login. The candidate
# *pool* is declared here (services.quickshell-apps.apps) and seeded into
# ~/.config/quickshell/autostartseed.json; runtime on/off state and any apps
# added from the UI live in ~/.cache/quickshell/autostart.json. The Hyprland
# login autostart runs `qs-apps run` (see home-manager/modules/hyprland.nix)
# to launch whatever is enabled.
{
options.services.quickshell-apps = {
enable = lib.mkEnableOption "QuickShell autostart app manager (runtime toggle from the gear panel)";
apps = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name shown in the gear panel.";
};
cmd = lib.mkOption {
type = lib.types.str;
description = "Command to launch the app (shell-style, argv 0 must name the binary).";
};
};
}
);
default = [ ];
description = "Candidate pool the user can toggle/start from the shell.";
};
};
config = lib.mkIf config.services.quickshell-apps.enable {
# Store the manager in the Nix store with the procps tools baked in (the
# Hyprland login autostart PATH is not guaranteed to have pgrep/pkill).
xdg.configFile."quickshell/qs-apps.py".text =
builtins.replaceStrings
[
"@pgrep@"
"@pkill@"
]
[
"${pkgs.procps}/bin/pgrep"
"${pkgs.procps}/bin/pkill"
]
(builtins.readFile ./quickshell-apps.py);
# Nix-declared pool. The runtime state file is authoritative for on/off
# and for UI-added apps; the manager merges this in on every load, adding
# new entries as disabled.
xdg.configFile."quickshell/autostartseed.json".text = builtins.toJSON (
map (app: {
name = app.name;
cmd = app.cmd;
}) config.services.quickshell-apps.apps
);
home.file.".local/bin/qs-apps" = {
executable = true;
text = ''
#!/bin/sh
exec ${pkgs.python3}/bin/python3 \
${config.home.homeDirectory}/.config/quickshell/qs-apps.py "$@"
'';
};
};
}
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""QuickShell autostart app manager.
Lets the user pick which installed apps start at login, toggled live from the
gear quick-settings panel (no rebuild needed for preference changes).
Model
-----
The *pool* of candidates is declared in Nix (host config) and written to
~/.config/quickshell/autostartseed.json. The runtime *state* lives in
~/.cache/quickshell/autostart.json and is authoritative for on/off and for
apps the user adds from the UI (source = "user"). On every `list`/`run` the
seed is merged in: new seed entries are added disabled and, for existing seed
entries, the Nix command is re-applied while keeping the enabled flag. A
user-added app named like a seed entry wins (the seed never re-enables it).
Commands
--------
list -> JSON {apps: [{name, cmd, enabled, source}]} (after merge)
toggle <name> -> flip enabled; start/stop the app now, then persist
add <name> <cmd> -> create a user entry (enabled, starts now); if the name
already exists, just enable it
remove <name> -> stop (best-effort) and forget the entry
run -> login autostart: launch every enabled app (no dupes)
"""
import json
import os
import shlex
import subprocess
import sys
HOME = os.path.expanduser("~")
CACHE_DIR = os.path.join(HOME, ".cache", "quickshell")
STATE_FILE = os.path.join(CACHE_DIR, "autostart.json")
SEED_FILE = os.path.join(HOME, ".config", "quickshell", "autostartseed.json")
PGREP = "/run/current-system/sw/bin/pgrep"
PKILL = "/run/current-system/sw/bin/pkill"
def ensure_cache_dir():
os.makedirs(CACHE_DIR, exist_ok=True)
def load_state():
try:
with open(STATE_FILE) as fh:
data = json.load(fh)
except (OSError, ValueError):
data = {"apps": []}
if not isinstance(data, dict) or not isinstance(data.get("apps"), list):
data = {"apps": []}
return data
def save_state(data):
ensure_cache_dir()
with open(STATE_FILE, "w") as fh:
json.dump(data, fh, indent=2)
def load_seed():
try:
with open(SEED_FILE) as fh:
seed = json.load(fh)
except (OSError, ValueError):
return []
if not isinstance(seed, list):
return []
out = []
for entry in seed:
if isinstance(entry, dict) and entry.get("name") and entry.get("cmd"):
out.append({"name": str(entry["name"]), "cmd": str(entry["cmd"])})
return out
def merge_seed(data):
"""Apply the Nix-declared pool to the runtime state (add-missing only)."""
seed = load_seed()
for s in seed:
found = next(
(a for a in data["apps"] if a.get("name", "").lower() == s["name"].lower()),
None,
)
if found is None:
data["apps"].append(
{"name": s["name"], "cmd": s["cmd"], "enabled": False, "source": "seed"}
)
elif found.get("source") == "seed":
found["cmd"] = s["cmd"]
return data
def find_app(data, name):
return next(
(a for a in data["apps"] if a.get("name", "").lower() == name.lower()), None
)
def binary_of(cmd):
try:
toks = shlex.split(cmd)
except ValueError:
return None
return os.path.basename(toks[0]) if toks else None
def pattern_of(cmd):
"""pgrep/pkill -f pattern for the app's executable.
Electron apps (element-desktop, bitwarden-desktop) run with comm=electron,
so comm (-x) matching misses them and we match the full command line
instead. The bracket trick 'e[e]lement-desktop' still matches
element-desktop but never the pgrep/pkill command itself (its own cmdline
contains the literal bracketed form) — avoiding the classic -f self-match.
"""
binary = binary_of(cmd)
if not binary:
return None
if len(binary) < 2:
return binary
return binary[:1] + "[" + binary[1:2] + "]" + binary[2:]
def _self_and_ancestors():
"""PIDs of this process and its parent chain.
The -f guard can be fooled by the *invoking* process: `qs-apps add X "cmd"`
passes the command as an argv element, so our own cmdline (and ancestors
like a `sh -c 'qs-apps add X cmd'` wrapper) contains the app's binary name
and pgrep -f would match it instead of the real app. Excluding the whole
ancestor chain makes the guard see only genuinely running apps.
"""
pids = {os.getpid()}
ppid = os.getppid()
seen = set()
while ppid > 1 and ppid not in seen:
seen.add(ppid)
pids.add(ppid)
try:
with open("/proc/%d/stat" % ppid) as fh:
parts = fh.read().split()
ppid = int(parts[3])
except (OSError, IndexError, ValueError):
break
return pids
def is_running(pattern):
if not pattern:
return False
try:
r = subprocess.run([PGREP, "-f", pattern], capture_output=True, text=True)
except OSError:
return False
if r.returncode != 0:
return False
self_pids = _self_and_ancestors()
for line in r.stdout.splitlines():
try:
pid = int(line.strip())
except ValueError:
continue
if pid not in self_pids:
return True
return False
def start_app(cmd):
"""Launch cmd detached. Returns True if it is (or already is) running."""
try:
toks = shlex.split(cmd)
except ValueError:
return False
if not toks:
return False
pattern = pattern_of(cmd)
if pattern and is_running(pattern):
return True
try:
subprocess.Popen(
toks,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return True
except OSError:
return False
def stop_app(cmd):
"""Best-effort kill by -f pattern (comm is 'electron' for Electron apps)."""
pattern = pattern_of(cmd)
if not pattern:
return
try:
subprocess.run([PKILL, "-f", pattern], capture_output=True)
except OSError:
pass
def cmd_toggle(name):
data = merge_seed(load_state())
app = find_app(data, name)
if app is None:
return {"ok": False, "error": "no app named " + name}
app["enabled"] = not app["enabled"]
if app["enabled"]:
started = start_app(app["cmd"])
else:
stop_app(app["cmd"])
started = True
save_state(data)
return {"ok": started, "enabled": app["enabled"]}
def cmd_add(name, cmd):
data = merge_seed(load_state())
app = find_app(data, name)
if app is None:
data["apps"].append(
{"name": name, "cmd": cmd, "enabled": True, "source": "user"}
)
app = data["apps"][-1]
else:
app["enabled"] = True
if app.get("source") == "user":
app["cmd"] = cmd
started = start_app(app["cmd"])
save_state(data)
return {"ok": started}
def cmd_remove(name):
data = merge_seed(load_state())
app = find_app(data, name)
if app is None:
return {"ok": True}
stop_app(app["cmd"])
data["apps"] = [a for a in data["apps"] if a is not app]
save_state(data)
return {"ok": True}
def cmd_run():
data = merge_seed(load_state())
for app in data["apps"]:
if app.get("enabled"):
start_app(app["cmd"])
save_state(data)
return {"ok": True}
def cmd_list():
data = merge_seed(load_state())
save_state(data)
return {"apps": data["apps"]}
def main():
args = sys.argv[1:]
cmd = args[0] if args else "list"
if cmd == "list":
out = cmd_list()
print(json.dumps(out))
elif cmd == "run":
print(json.dumps(cmd_run()))
elif cmd == "toggle":
name = args[1] if len(args) > 1 else ""
print(json.dumps(cmd_toggle(name)))
elif cmd == "add":
name = args[1] if len(args) > 1 else ""
command = args[2] if len(args) > 2 else ""
print(json.dumps(cmd_add(name, command)))
elif cmd == "remove":
name = args[1] if len(args) > 1 else ""
print(json.dumps(cmd_remove(name)))
else:
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
if __name__ == "__main__":
main()
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""QuickShell bluetooth manager backend.
Talks to bluez via `bluetoothctl` (text/shell parsing — the tool has no JSON
mode). State is read live from the adapter (`bluetoothctl show`) and per device
(`bluetoothctl info <mac>`), so it reflects reality even after pairing from
another app. The `qs-bt` wrapper (home-manager hyprland module) passes the
absolute bluetoothctl path in $BLUECTL.
Commands
--------
status -> JSON adapter + devices
{powered,name,alias,pairable,discoverable,
discovering,devices:[{mac,name,icon,paired,
trusted,connected,battery}]}
power on|off -> toggle adapter power -> JSON ok
scan -> discovery for SCAN_SECONDS, then stop; JSON
list of {mac,name} found during the scan
connect <mac> / disconnect <mac>
pair <mac> -> pair (bounded; pairing prompts end it)
trust <mac> / untrust <mac>
remove <mac> -> unpair / forget
All non-status commands return {"ok": bool, "error": "..."}.
"""
import json
import os
import re
import subprocess
import sys
BLUECTL = os.environ.get("BLUECTL", "bluetoothctl")
SCAN_SECONDS = 8
PAIR_TIMEOUT = 30
CONNECT_TIMEOUT = 12
WAIT_TIMEOUT = 10
def run(args, timeout=WAIT_TIMEOUT):
try:
p = subprocess.run(
[BLUECTL] + args,
capture_output=True,
text=True,
timeout=timeout,
)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired as exc:
tail = (exc.stdout or exc.stderr or b"")
if isinstance(tail, bytes):
tail = tail.decode(errors="replace")
return -1, tail, "command timed out"
except OSError as exc:
return -2, "", str(exc)
def field(line, key):
low = line.lstrip()
return low.split(":", 1)[1].strip() if low.startswith(key + ":") else None
def parse_adapter(out):
"""Adapter sections from `bluetoothctl show`; prefer the [default] one."""
sections = []
cur = None
for line in out.splitlines():
if line.startswith("Controller "):
bits = line.split()
cur = {"mac": bits[1], "default": "[default]" in line}
sections.append(cur)
elif cur is not None:
v = field(line, "Powered")
if v:
cur["powered"] = v == "yes"
v = field(line, "Discovering")
if v:
cur["discovering"] = v == "yes"
v = field(line, "Pairable")
if v:
cur["pairable"] = v == "yes"
v = field(line, "Discoverable")
if v:
cur["discoverable"] = v == "yes"
v = field(line, "Name")
if v:
cur["name"] = v
v = field(line, "Alias")
if v:
cur["alias"] = v
for s in sections:
if s.get("default"):
return s
return sections[0] if sections else None
def parse_device(mac):
rc, out, _ = run(["info", mac], timeout=WAIT_TIMEOUT)
dev = {
"mac": mac,
"name": "",
"icon": "",
"paired": False,
"trusted": False,
"connected": False,
"battery": None,
}
if rc != 0:
return dev
for line in out.splitlines():
v = field(line, "Name")
if v:
dev["name"] = v
v = field(line, "Icon")
if v:
dev["icon"] = v
v = field(line, "Paired")
if v:
dev["paired"] = v == "yes"
v = field(line, "Trusted")
if v:
dev["trusted"] = v == "yes"
v = field(line, "Connected")
if v:
dev["connected"] = v == "yes"
m = re.search(r"Battery Percentage:\s*0x[0-9A-Fa-f]+\s*\((\d+)\)", line)
if m:
dev["battery"] = int(m.group(1))
return dev
def list_devices():
rc, out, _ = run(["devices"], timeout=WAIT_TIMEOUT)
macs = []
if rc == 0:
for line in out.splitlines():
bits = line.split()
if len(bits) >= 2 and bits[0] == "Device":
mac = bits[1]
if mac not in macs:
macs.append(mac)
return [parse_device(m) for m in macs]
def status():
rc, out, _ = run(["show"], timeout=WAIT_TIMEOUT)
adapter = parse_adapter(out) if rc == 0 else None
devs = list_devices()
# connected first, then alphabetical — the stable order the popup renders.
devs.sort(key=lambda d: (not d["connected"], (d["name"] or d["mac"]).lower()))
return {
"powered": bool(adapter and adapter.get("powered")),
"name": adapter.get("name") if adapter else "",
"alias": adapter.get("alias") if adapter else "",
"pairable": bool(adapter and adapter.get("pairable")),
"discoverable": bool(adapter and adapter.get("discoverable")),
"discovering": bool(adapter and adapter.get("discovering")),
"devices": devs,
}
def scan():
"""Discovery for SCAN_SECONDS; bluetoothctl parses NEW/CHG device lines."""
rc, out, _ = run(
["--timeout", str(SCAN_SECONDS), "scan", "on"],
timeout=SCAN_SECONDS + 8,
)
found = {}
for line in out.splitlines():
if line.startswith("[NEW] Device"):
mac, _, name = line.split()[2:5]
found[mac] = name
elif line.startswith("[CHG] Device"):
bits = line.split()
if len(bits) >= 5 and bits[3] == "Name:":
found[bits[2]] = line.split("Name:", 1)[1].strip()
return [{"mac": m, "name": n} for m, n in sorted(found.items())]
def action(args):
rc, out, err = run(args, timeout=PAIR_TIMEOUT if args and args[0] == "pair" else CONNECT_TIMEOUT)
if rc == 0:
return {"ok": True, "error": ""}
# bluetoothctl prints failures like "Failed to connect: ..." to stdout.
detail = ""
for line in (out or "").splitlines():
if "Failed" in line or "not" in line.lower() or "error" in line.lower():
detail = line.strip()
break
return {"ok": False, "error": detail or (err or "command failed")}
def main():
args = sys.argv[1:]
cmd = args[0] if args else "status"
if cmd == "status":
print(json.dumps(status()))
elif cmd == "power":
val = args[1] if len(args) > 1 else ""
print(json.dumps(action(["power", val]) if val in ("on", "off") else {"ok": False, "error": "power on|off"}))
elif cmd == "scan":
print(json.dumps(scan()))
elif cmd in ("pair", "connect", "disconnect", "trust", "untrust", "remove"):
mac = args[1] if len(args) > 1 else ""
if not mac:
print(json.dumps({"ok": False, "error": "missing device address"}))
else:
print(json.dumps(action([cmd, mac])))
else:
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
{
config,
pkgs,
lib,
...
}:
let
# Python with the ICS parsing stack baked in: icalendar + recurring
# recurrence expansion for the reminder scanner.
python = pkgs.python3.withPackages (ps: [
ps.icalendar
ps.recurring-ical-events
]);
in
{
options.services.quickshell-cal = {
enable = lib.mkEnableOption "QuickShell calendar + weather sync (Nextcloud CalDAV via vdirsyncer/khal, Open-Meteo)";
eventDays = lib.mkOption {
type = lib.types.int;
default = 31;
description = "How many days ahead of today khal should expand/return events for the shell popup.";
};
};
config = lib.mkIf config.services.quickshell-cal.enable {
# The one Nextcloud fact the sync pipeline needs beyond the secret: the
# base user-principal CalDAV URL (vdirsyncer discovers every calendar
# under it). The username + password live in the SOPS secret
# "hp-laptop/nextcloud-cal-env".
home.packages = [
python
pkgs.vdirsyncer
pkgs.pipewire
pkgs.libnotify
];
# ---------- sync script ----------
# Store the python in the Nix store with tool paths baked in (user units
# have an unpredictable PATH), then expose it on the QuickShell PATH.
# Substitutions keep the source tree readable.
xdg.configFile."quickshell-cal/qs-cal-sync.py".text =
builtins.replaceStrings
[
"@vdirsyncer@"
"@libnotify@"
"@pipewire@"
]
[
"${pkgs.vdirsyncer}"
"${pkgs.libnotify}"
"${pkgs.pipewire}"
]
(builtins.readFile ./quickshell-cal.py);
home.file.".local/bin/qs-cal-sync" = {
executable = true;
text = ''
#!/bin/sh
exec ${python}/bin/python3 \
${config.home.homeDirectory}/.config/quickshell-cal/qs-cal-sync.py "$@"
'';
};
# ---------- khal config ----------
# Generated at runtime by the sync script from the vdirsyncer discovery:
# one [[calendar]] section per subdir of ~/.local/share/vdirsyncer (khal
# cannot glob, and the calendar set only changes when Nextcloud does).
# Removing this file from HM keeps ~/.config/khal/config a plain user
# file the script may rewrite on every sync.
# ---------- periodic sync ----------
# Runs as the user so caches are written to ~/.cache without privilege
# games, and the SOPS env file is readable because the host config sets
# owner petere / group users / mode 0440 on it.
systemd.user.services.qs-cal-sync = {
Unit = {
Description = "Sync Nextcloud calendar + weather for the QuickShell popup";
After = [ "network-online.target" ];
};
Service = {
Type = "oneshot";
ExecStart = "${config.home.homeDirectory}/.local/bin/qs-cal-sync sync";
};
};
systemd.user.timers.qs-cal-sync = {
Unit = {
Description = "Periodic Nextcloud calendar + weather sync";
After = [ "network-online.target" ];
};
Install = {
WantedBy = [ "timers.target" ];
};
Timer = {
OnBootSec = "1min";
OnUnitActiveSec = "20min";
Persistent = true;
};
};
# ---------- reminders ----------
# Fires VALARM due-tones once per minute: plays the cached chime and
# sends a desktop notification that lands in the QuickShell notification
# center. Reads only the local .ics stores (no network), so it is cheap
# enough to run every 60s.
systemd.user.services.qs-cal-remind = {
Unit = {
Description = "Fire QuickShell calendar reminders";
};
Service = {
Type = "oneshot";
ExecStart = "${config.home.homeDirectory}/.local/bin/qs-cal-sync remind";
};
};
systemd.user.timers.qs-cal-remind = {
Unit = {
Description = "Per-minute QuickShell calendar reminder check";
};
Install = {
WantedBy = [ "timers.target" ];
};
Timer = {
OnBootSec = "1min";
OnUnitActiveSec = "30s";
# default AccuracySec is a whole minute, which (plus polling phase)
# lets alerts land up to ~2min late; 1s keeps current-skew tiny.
AccuracySec = "1s";
Persistent = true;
};
};
};
}
File diff suppressed because it is too large Load Diff
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""qs-launch — wofi-based launcher modes: emoji pick, calculator, DuckDuckGo search.
Subcommands:
emoji feed a curated emoji list into wofi --dmenu, copy the picked emoji
calc type an expression in wofi, evaluate with qalc, copy + toast result
search type a query in wofi, open DuckDuckGo in the default browser
All binary paths are injected via $QS_* env vars by the .local/bin/qs-launch
wrapper so execution works regardless of PATH (same pattern as qs-bt's
$BLUECTL). When no DISPLAY/WAYLAND_DISPLAY is present, wofi can't open, so each
mode exits silently with code 1.
"""
import os
import subprocess
import sys
import urllib.parse
# Curated emoji list: "<char> <shortcode>". Searchable by name, output splits
# on the first whitespace so only the emoji character gets copied.
EMOJI = """😀 grin
😃 smiley
😁 beam
😂 joy
🤣 rofl
😊 blush
🙂 slight smile
😉 wink
😍 heart eyes
😘 kiss
🤩 star struck
🥳 party
😎 cool
🤓 nerd
😇 halo
😏 smirk
😜 winky tongue
🥺 plead
😬 grimace
🤔 think
🤯 mind blown
😴 sleepy
😭 cry
😢 sad
😞 disappointed
😠 angry
😤 triumph
🤬 swear
🙄 roll eyes
😐 neutral
😑 expressionless
🙂🙃 upside down
😱 scream
🤒 sick
🤕 hurt
💀 skull
👻 ghost
👽 alien
🤖 robot
🎃 pumpkin
💩 poo
🙌 yes
👏 clap
🙏 pray
🤝 handshake
👍 thumbs up
👎 thumbs down
👊 fist
✊ raised fist
🤞 crossed fingers
✌️ peace
👋 hello wave
🖐️ hand
✍️ writing
💪 muscle
🫶 heart hands
🦾 bionic arm
🧠 brain
👀 eyes
👁️ eye
👄 lips
👅 taste tongue
🧛 vampire
🧟 zombie
❤️ heart
🧡 orange heart
💛 yellow heart
💚 green heart
💙 blue heart
💜 purple heart
🖤 black heart
🤍 white heart
🤎 brown heart
💔 broken heart
❤️‍🔥 heart fire
💯 hundred
💥 boom
💫 dizzy
✨ sparkles
🔥 fire
🌟 star
⭐ star white
☀️ sun
🌙 moon
🌑 new moon
🌈 rainbow
☁️ cloud
⛈️ storm
❄️ snow
🌊 ocean wave
🌍 earth
🌺 hibiscus
🌸 cherry blossom
🌹 rose
🌷 tulip
🌵 cactus
🌴 palm
🍀 clover
🍄 mushroom
🐶 dog
🐱 cat
🦊 fox
🐻 bear
🐼 panda
🐨 koala
🦁 lion
🐯 tiger
🐸 frog
🐵 monkey
🐧 penguin
🦄 unicorn
🐝 bee
🦋 butterfly
🐢 turtle
🐙 octopus
🦈 shark
🐬 dolphin
🍍 pineapple
🥭 mango
🍒 cherries
🍓 strawberry
🍑 peach
🥑 avocado
🍕 pizza
🍔 burger
🍟 fries
🌭 hotdog
🍿 popcorn
🌮 taco
🍰 cake
🍩 donut
🥐 croissant
☕ coffee
🍵 tea
🍺 beer
🍻 cheers
⚽ football
🏀 basketball
🎾 tennis
⚾ baseball
🏈 american football
🎮 game
🎲 dice
🎯 target
🎵 music
🎶 notes
🎤 mic
🎧 headphones
📻 radio
🎬 movie
📺 tv
💻 laptop
📱 phone
🖥️ computer
🖱️ mouse
💾 floppy
💿 disc
🔋 battery
🔌 plug
💡 bulb
🔦 flashlight
🗑️ trash
🔒 lock
🔓 unlocked
🔑 key
✂️ scissors
📎 paperclip
📌 pin
📍 location
🧲 magnet
🛠️ tools
⚙️ gear
🔧 wrench
✏️ pencil
🖊️ pen
📖 book
📚 books
📄 doc
📁 folder
📦 package
✉️ mail
📝 memo
📊 chart
💰 money
💳 card
🧾 receipt
🏆 trophy
🥇 gold
🥈 silver
🎁 gift
🎈 balloon
🎉 party popper
🥂 toast
🎊 confetti
🧹 clean
🧽 sponge
⏰ alarm
⏱️ stopwatch
🕰️ clock
📅 calendar
📆 date
🔔 bell
🔕 bell off
❌ cross
✅ check
⚠️ warning
🚫 no entry
plus
minus
➗ divide
✖️ multiply
🔄 refresh
⬆️ up
⬇️ down
⬅️ left
➡️ right
↩️ return
🔙 back
🔜 soon
🌐 globe
🚀 rocket
🛸 saucer
✈️ plane
🚗 car
🚕 taxi
🚌 bus
🚂 train
🚲 bike
🏍️ motorcycle
👻 spooky
☠️ skull crossbones
💣 bomb
🚁 chopper
🚢 ship
🚄 bullet train
🚦 traffic light
⛽ fuel
🅿️ parking
🚧 roadwork
🌉 bridge
🏰 castle
⛺ camp
🌋 volcano
🗼 tower
🏯 pagoda
🌆 city dusk
🌃 city night
🏙️ skyline
🖼️ frame
🎨 palette
🖌️ brush
🧶 yarn
🧵 thread
💍 ring
👟 sneaker
👠 heel
🧢 cap
🎓 graduate
👒 hat
🧣 scarf
🧤 gloves
🧥 coat
👔 tie
👕 shirt
👖 jeans
💭 thought
💬 speech
🗯️ anger
⌨️ keyboard
🖨️ printer
🧮 abacus
🕹️ joystick
🎳 bowling
🏓 ping pong
🏸 badminton
🥊 boxing
🥋 martial arts
🎽 running shirt
🚴 cyclist
🧗 climber
🏊 swimmer
🎪 circus
🎠 carousel
🗿 moai
🏛️ classical
🏟️ stadium
🏫 school
🏥 hospital
🏦 bank
🏪 store
🏭 factory
🏝️ island
🏖️ beach
⛱️ umbrella
🧭 compass"""
def run(command, **kwargs):
return subprocess.run(command, capture_output=True, text=True, **kwargs)
def wofi(entries, prompt, exec_search=False):
cmd = [os.environ.get("QS_WOFI", "wofi"), "--dmenu", "--prompt", prompt]
if exec_search:
cmd.append("--exec-search")
result = run(cmd, input=entries)
if result.returncode != 0:
return None
return result.stdout.rstrip("\n")
def cmd_mode():
if "WAYLAND_DISPLAY" not in os.environ and "DISPLAY" not in os.environ:
return 1
sub = sys.argv[1] if len(sys.argv) > 1 else ""
if sub == "emoji":
return emoji_mode()
if sub == "calc":
return calc_mode()
if sub == "search":
return search_mode()
print(__doc__, file=sys.stderr)
return 2
def emoji_mode():
picked = wofi(EMOJI, "\U0001f600 Emoji")
if not picked:
return 0
char = picked.split(" ", 1)[0]
if not char:
return 0
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
run([wl_copy, char])
return 0
def calc_mode():
expr = wofi("", "=? \u2014 1+2 then Enter", exec_search=True)
if not expr:
return 0
qalc = os.environ.get("QS_QALC", "qalc")
result = run([qalc, "-t", expr])
if result.returncode != 0:
return 0
answer = result.stdout.strip()
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
run([wl_copy, answer])
notify(os.environ.get("QS_NOTIFY", "notify-send"),
"\U0001f4d0 " + expr, "= " + answer + " (copied)")
return 0
def search_mode():
query = wofi("", "Search the web", exec_search=True)
if not query:
return 0
url = "https://duckduckgo.com/?q=" + urllib.parse.quote_plus(query)
xdg_open = os.environ.get("QS_XDGO", "xdg-open")
subprocess.Popen([xdg_open, url])
return 0
def notify(sender, title, body):
try:
run([sender, "-a", "qs-launch", title, body])
except OSError:
pass
if __name__ == "__main__":
raise SystemExit(cmd_mode())
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""qs-shot — screenshot capture with a QuickShell toast preview.
Subcommands:
pick show a wofi chooser (select area / full screen) then capture
area interactively select a region (grimblast)
screen capture the current output (grimblast)
open <path> open a saved screenshot in the default viewer (toast action)
copy <path> re-push a saved screenshot to the clipboard (toast action)
The capture is copied to the clipboard AND saved to a temp file. A
notification toast is posted with the image preview and Open / Copy
actions; the shell intercepts those in calNotifAction (identifier
payload carries the saved file path).
All binary paths are injected via $QS_* env vars by the .local/bin/qs-shot
wrapper so execution works regardless of PATH. When no DISPLAY/WAYLAND_DISPLAY
is present, wofi can't open, so the picker exits silently with code 1.
"""
import os
import subprocess
import sys
import time
TARGETS = {
"area": "Select area",
"screen": "Full screen",
}
def run(command, **kwargs):
return subprocess.run(command, capture_output=True, text=True, **kwargs)
def wofi(entries, prompt):
cmd = [os.environ.get("QS_WOFI", "wofi"), "--dmenu", "--prompt", prompt]
result = run(cmd, input=entries)
if result.returncode != 0:
return None
return result.stdout.rstrip("\n")
def _output_path():
return os.path.join(
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
"qs-shot",
"shot-%s.png" % time.strftime("%Y%m%d-%H%M%S"),
)
def _grab(target, out):
"""grimblast save <target> <out> — file only, no clipboard."""
grimblast = os.environ.get("QS_GRIMBLAST", "grimblast")
return subprocess.run(
[grimblast, "save", target, out],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def _copy_to_clip(out):
"""wl-copy < out — fire-and-forget, mirrors the `copy` subcommand."""
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
subprocess.Popen(["sh", "-c",
'exec "$1" --type image/png < "$2"', "--",
wl_copy, out],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _notify(out, target):
summary = "Screenshot" if target == "screen" else "Screenshot (area)"
body = "\u2702 + \U0001f4be " + out
notify = os.environ.get("QS_NOTIFY", "notify-send")
# fire-and-forget: notify-send with --action stays alive as the D-Bus action
# sender waiting for ActionInvoked; quickshell handles the shot-* actions by
# identifier in QML instead, so the sender can exit immediately.
subprocess.Popen([
notify,
"-a", "Screenshot",
"-t", "8000",
"--hint=string:image-path:" + out,
"--action=shot-open:%s=OPEN" % out,
"--action=shot-copy:%s=COPY" % out,
summary,
body,
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def pick_mode():
if "WAYLAND_DISPLAY" not in os.environ and "DISPLAY" not in os.environ:
return 1
out = _output_path()
os.makedirs(os.path.dirname(out), exist_ok=True)
_prune_old_shots(os.path.dirname(out), keep=40)
# Capture BEFORE the chooser opens. wofi's layer surface lingers as a
# composited zombie after it exits (Hyprland keeps it in hyprctl layers even
# with a dead pid), so any post-exit wait races teardown and can bake the
# menu into the shot. A pre-capture makes that impossible for full screen.
if not _grab("screen", out):
return 1
label = wofi("\n".join(TARGETS.values()), "\U0001f4f7 Screenshot")
if not label:
try:
os.unlink(out)
except OSError:
pass
return 0
target = None
for t, entry in TARGETS.items():
if label == entry:
target = t
break
if target is None:
try:
os.unlink(out)
except OSError:
pass
return 0
if target == "area":
if not _grab("area", out):
return 1
_copy_to_clip(out)
_notify(out, target)
return 0
def _prune_old_shots(directory, keep=40):
try:
files = sorted(
os.path.join(directory, f) for f in os.listdir(directory)
if f.startswith("shot-") and f.endswith(".png")
)
for old in files[:-keep]:
os.unlink(old)
except OSError:
pass
def capture(target):
out = _output_path()
os.makedirs(os.path.dirname(out), exist_ok=True)
_prune_old_shots(os.path.dirname(out), keep=40)
if not _grab(target, out):
return 1
_copy_to_clip(out)
_notify(out, target)
return 0
def main():
sub = sys.argv[1] if len(sys.argv) > 1 else "pick"
if sub == "area" or sub == "screen":
return capture(sub)
if sub == "pick":
return pick_mode()
if sub == "open" and len(sys.argv) > 2:
xdg_open = os.environ.get("QS_XDGO", "xdg-open")
subprocess.Popen([xdg_open, sys.argv[2]])
return 0
if sub == "copy" and len(sys.argv) > 2:
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
subprocess.Popen(["sh", "-c",
'exec "$1" --type image/png < "$2"', "--",
wl_copy, sys.argv[2]])
return 0
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
// SciSlider.qml — themed slider for the quick-settings panel.
// Imperative two-way use: set `.value` from the outside, read `.moved(v)`.
// (Avoids QML binding loops with live PipeWire/brightness state.)
import QtQuick
Item {
id: slider
implicitHeight: 24
property real value: 0.5 // 0..1
property color accent: "#00e5ff"
property color trackColor: "#24283b"
readonly property bool pressed: mouseArea.pressed
signal moved(real v)
function setFromMouse(mx) {
if (width <= 0)
return;
let v = mx / width;
if (v < 0)
v = 0;
if (v > 1)
v = 1;
if (v !== slider.value) {
slider.value = v;
slider.moved(v);
}
}
// track
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 6
radius: 3
color: slider.trackColor
border.color: "#394b70"
border.width: 1
}
// filled portion
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: slider.value * parent.width
height: 6
radius: 3
color: slider.accent
}
// knob
Rectangle {
anchors.verticalCenter: parent.verticalCenter
x: slider.value * (parent.width - width)
width: 14
height: 14
radius: 7
color: slider.pressed ? "#ffffff" : slider.accent
border.color: "#0b0e14"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
}
MouseArea {
id: mouseArea
anchors {
fill: parent
topMargin: -6
bottomMargin: -6
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onPressed: mouse => slider.setFromMouse(mouse.x)
onPositionChanged: mouse => {
if (pressed)
slider.setFromMouse(mouse.x);
}
}
}
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""qs-stats: one-shot system stats probe for the QuickShell stats popup.
Emits a single JSON object on stdout:
{
"ts": 1730000000,
"cpu": 12.3,
"ram": {"used": 8.2, "total": 31.9, "pct": 25.7},
"swap": {"used": 0.0, "total": 8.0, "pct": 0.0},
"disk": {"used": 156.4, "total": 934.1, "pct": 16.7},
"load": [2.01, 2.29, 2.10],
"uptime": 7861.0,
"temps": [{"name": "TCPU", "temp": 58.0}, ...],
"freq": 3.8
}
CPU% is computed from two /proc/stat samples ~250ms apart (mostly an idle
sleep: ~free on battery). Memory/swap/disk come from /proc/meminfo +
os.statvfs. Temperatures come from /sys/class/thermal (kernel hwmon zones).
The bar runs this on a slow 15s timer; the stats popup refreshes every 3s
while open.
"""
import json
import os
import sys
import time
CPU_SAMPLE_MS = 250
def cpu_usage():
def sample():
with open("/proc/stat") as f:
parts = f.readline().split()
# cpu user nice system idle iowait irq softirq steal guest guest_nice
vals = list(map(int, parts[1:]))
idle = vals[3] + vals[4]
total = sum(vals)
return idle, total
idle0, total0 = sample()
time.sleep(CPU_SAMPLE_MS / 1000.0)
idle1, total1 = sample()
d_idle = idle1 - idle0
d_total = total1 - total0
if d_total <= 0:
return 0.0
return round(100.0 * (1.0 - d_idle / d_total), 1)
def meminfo_gi():
mem = {}
try:
with open("/proc/meminfo") as f:
for line in f:
key, _, rest = line.partition(":")
val = rest.strip().split()[0]
mem[key] = int(val) # kB
except OSError:
return {}
def gi(kb):
return round(kb / 1024.0 / 1024.0, 1)
ram = {
"total": gi(mem.get("MemTotal", 0)),
# MemAvailable is the honest "swappable minus thrash" number
"available": gi(mem.get("MemAvailable", mem.get("MemFree", 0))),
}
ram["used"] = round(ram["total"] - ram["available"], 1)
ram["pct"] = (
round(100.0 * ram["used"] / ram["total"], 1) if ram["total"] > 0 else 0.0
)
swap = {
"total": gi(mem.get("SwapTotal", 0)),
"free": gi(mem.get("SwapFree", 0)),
}
swap["used"] = round(swap["total"] - swap["free"], 1)
swap["pct"] = (
round(100.0 * swap["used"] / swap["total"], 1) if swap["total"] > 0 else 0.0
)
return ram, swap
def disk_pct(path="/"):
try:
st = os.statvfs(path)
except OSError:
return {"used": 0.0, "total": 0.0, "pct": 0.0}
total = st.f_blocks * st.f_frsize
free = st.f_bavail * st.f_frsize
used = total - free
return {
"used": round(used / 1024.0**3, 1),
"total": round(total / 1024.0**3, 1),
"pct": round(100.0 * used / total, 1) if total > 0 else 0.0,
}
def load_avg():
try:
return [round(float(x), 2) for x in os.getloadavg()]
except OSError:
return []
def uptime():
try:
with open("/proc/uptime") as f:
return round(float(f.read().split()[0]), 1)
except OSError:
return 0.0
def temps():
out = []
base = "/sys/class/thermal"
try:
zones = sorted(os.listdir(base))
except OSError:
return out
for z in zones:
if not z.startswith("thermal_zone"):
continue
tpath = os.path.join(base, z)
typefile = os.path.join(tpath, "type")
tempfile = os.path.join(tpath, "temp")
try:
with open(typefile) as f:
name = f.read().strip()
with open(tempfile) as f:
millideg = int(f.read().strip())
except (OSError, ValueError):
continue
# Skip the ACPI "INT3400 Thermal" umbrella zone (always ~20C, noise)
if "INT3400" in name:
continue
out.append({"name": name, "temp": round(millideg / 1000.0, 0)})
# Keep a sane display order: CPU forward, wifi last
def rank(n):
n = n.lower()
if "cpu" in n or "tctl" in n or "pkg" in n:
return 0
if "sen" in n:
return 1
return 2
out.sort(key=lambda t: (rank(t["name"]), t["name"]))
return out
def scaled_vout():
# Current CPU P-state / GHz (x86-package-temp zone present ⇒ has cpufreq).
try:
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") as f:
khz = int(f.read().strip())
return round(khz / 1_000_000.0, 2)
except OSError:
return 0.0
def main():
ram, swap = meminfo_gi()
payload = {
"ts": int(time.time()),
"cpu": cpu_usage(),
"ram": ram,
"swap": swap,
"disk": disk_pct(),
"load": load_avg(),
"uptime": uptime(),
"temps": temps(),
"freq": scaled_vout(),
}
sys.stdout.write(json.dumps(payload))
sys.stdout.write("\n")
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""QuickShell wallpaper picker backend.
Wallpapers are the image files in ~/Pictures/wallpapers (hyprpaper reads that
dir as the initial set; the picker surfaces whatever is there at runtime).
Commands
--------
list -> JSON array of {name, path, active} (active = matches the state file)
set <p> -> persist <p> to ~/.cache/quickshell/wallpaper, then apply it now
apply -> re-apply the persisted choice (login autostart; retries while
hyprpaper's control socket comes up)
path -> print the persisted path (empty if never picked)
Apply mechanism
---------------
hyprpaper >= 0.8 ships a new control protocol: the legacy `preload`/`reload`
IPC commands are gone, and `hyprctl hyprpaper wallpaper <monitor>,<path>`
takes a real output name (no `*`). We query `hyprctl monitors -j` for the
current output names and apply to each. Persisting to a cache file (not
hyprpaper.conf) keeps the Nix-generated config authoritative; the login
autostart line in hyprland.nix re-applies the choice after hyprpaper starts,
defaulting to the config wallpaper until the user picks something.
"""
import json
import os
import subprocess
import sys
import time
HOME = os.path.expanduser("~")
WALL_DIR = os.path.join(HOME, "Pictures", "wallpapers")
CACHE_DIR = os.path.join(HOME, ".cache", "quickshell")
STATE_FILE = os.path.join(CACHE_DIR, "wallpaper")
IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif")
HYPRCTL = "hyprctl"
def ensure_cache_dir():
os.makedirs(CACHE_DIR, exist_ok=True)
def scan_names():
if not os.path.isdir(WALL_DIR):
return []
try:
names = os.listdir(WALL_DIR)
except OSError:
return []
return sorted(n for n in names if n.lower().endswith(IMAGE_EXT))
def read_state():
try:
with open(STATE_FILE) as fh:
path = fh.read().strip()
return path if os.path.isfile(path) else ""
except OSError:
return ""
def list_walls():
current = read_state()
entries = []
for name in scan_names():
path = os.path.join(WALL_DIR, name)
entries.append({"name": name, "path": path, "active": path == current})
return entries
def monitors():
"""Current Hyprland output names (e.g. eDP-1), empty if unavailable."""
try:
out = subprocess.run(
[HYPRCTL, "monitors", "-j"],
capture_output=True,
text=True,
)
except OSError:
return []
if out.returncode != 0:
return []
try:
data = json.loads(out.stdout)
return [m["name"] for m in data if "name" in m]
except (ValueError, KeyError, TypeError):
return []
def apply_path(path, retries=10, delay=0.5):
"""Apply via hyprctl hyprpaper, absorbing the login socket race."""
if not os.path.isfile(path):
return False, "no such file: %s" % path
last = ""
for _ in range(retries):
mons = monitors()
if not mons:
last = "no monitors available"
time.sleep(delay)
continue
try:
errs = []
ok = True
for mon in mons:
setw = subprocess.run(
[HYPRCTL, "hyprpaper", "wallpaper", mon + "," + path],
capture_output=True,
text=True,
)
if setw.returncode != 0:
ok = False
errs.append(setw.stderr.strip())
except OSError as exc:
return False, "hyprctl unavailable: %s" % exc
if ok:
return True, ""
last = "; ".join(errs) or "hyprctl hyprpaper wallpaper failed"
time.sleep(delay)
return False, last
def set_wall(path):
if not os.path.isfile(path):
return {"ok": False, "error": "no such file: %s" % path}
ensure_cache_dir()
with open(STATE_FILE, "w") as fh:
fh.write(path + "\n")
ok, err = apply_path(path)
return {"ok": ok, "stderr": err}
def main():
args = sys.argv[1:]
cmd = args[0] if args else "list"
if cmd == "list":
print(json.dumps(list_walls()))
elif cmd == "set":
print(json.dumps(set_wall(args[1] if len(args) > 1 else "")))
elif cmd == "apply":
current = read_state()
print(json.dumps(set_wall(current) if current else {"ok": True, "stderr": ""}))
elif cmd == "path":
print(read_state())
else:
print(json.dumps({"ok": False, "error": "unknown command: " + cmd}))
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
{ lib, pkgs, ... }:
let
justfileContent = builtins.readFile ./justfile;
in
{
home.packages = [ pkgs.just ];
home.file.".config/just/justfile".text = justfileContent;
}
+53
View File
@@ -0,0 +1,53 @@
nix_flake := "/home/petere/Nix-Vibe"
hostname := `hostname`
# list available recipes
default:
@just --list --justfile {{justfile()}} 2>/dev/null || echo "Run: just -g --list"
# rebuild current system
[group('NixOS')]
update:
cd {{nix_flake}} && sudo nixos-rebuild switch --flake .#{{hostname}}
# remote deploy: just deploy x470
[group('NixOS')]
deploy target:
cd {{nix_flake}} && nixos-rebuild switch --target-host petere@{{target}} --flake .#{{target}} --sudo
# update flake.lock
[group('NixOS')]
flake-lock:
cd {{nix_flake}} && nix flake update
# update flake + rebuild
[group('NixOS')]
upgrade:
cd {{nix_flake}} && nix flake update && sudo nixos-rebuild switch --flake .#{{hostname}}
# build without switching (test config)
[group('NixOS')]
build:
cd {{nix_flake}} && nixos-rebuild build --flake .#{{hostname}}
# dry activation (check if config is valid)
[group('NixOS')]
check:
cd {{nix_flake}} && nixos-rebuild dry-activate --flake .#{{hostname}}
# switch home-manager config only
[group('NixOS')]
hm-switch:
cd {{nix_flake}} && home-manager switch --flake .#{{hostname}}
# garbage collect nix store
[group('System')]
gc:
nix-collect-garbage -d
sudo nix-collect-garbage -d
# remove old generations
[group('System')]
clean:
sudo nix-env --delete-generations old -p /nix/var/nix/profiles/system
sudo nix-collect-garbage -d
+54
View File
@@ -0,0 +1,54 @@
{ pkgs, ... }:
{
# Configure Zsh
programs.zsh = {
enable = true;
enableCompletion = true;
autosuggestion.enable = true;
syntaxHighlighting.enable = true;
autocd = true;
oh-my-zsh = {
enable = true;
plugins = [
"git"
"eza"
"vscode"
"sudo"
];
theme = "lukerandall";
};
# Use initContent to add custom Zsh configuration after Home Manager's setup
initContent = ''
# Load Gemini API Key from SOPS
if [ -f "/run/secrets/gemini-api-key" ]; then
export GOOGLE_GENERATIVE_AI_API_KEY=$(cat "/run/secrets/gemini-api-key")
export GEMINI_API_KEY="$GOOGLE_GENERATIVE_AI_API_KEY"
fi
# Load OpenCode API Key from SOPS
if [ -f "/run/secrets/opencode-api-key" ]; then
export OPENCODE_API_KEY=$(cat "/run/secrets/opencode-api-key")
fi
# Fastfetch check
if command -v fastfetch >/dev/null 2>&1; then
fastfetch --config ${./fastfetch.json}
fi
'';
};
programs.zoxide.enable = true;
programs.eza = {
enable = true;
extraOptions = [
"-l"
"--icons"
"--git"
"-a"
];
};
}
+42
View File
@@ -0,0 +1,42 @@
{ config
, pkgs
, lib
, inputs
, ...
}:
{
# Home Manager configuration for caitlin
home-manager.users.caitlin = {
home.username = "caitlin";
home.homeDirectory = "/home/caitlin";
home.stateVersion = "23.11"; # Set to your NixOS release version
home.file.".config/containers/containers.conf".text = ''
[service_destinations]
[service_destinations.local]
uri = "unix:///run/user/1000/podman/podman.sock"
[engine]
active_service = "local"
'';
programs.home-manager.enable = true;
programs.git = {
enable = true;
settings.user = {
name = "Caitlin";
email = "caitlin@example.com"; # Placeholder email
};
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
systemd.user.startServices = "sd-switch";
home.packages = with pkgs; [
fastfetch
];
};
}
+22
View File
@@ -0,0 +1,22 @@
{ config
, pkgs
, lib
, ...
}:
{
# User definition moved to host NixOS config (x470)
# Home Manager configuration for guest
home-manager.users.guest = {
home.username = "guest";
home.homeDirectory = "/home/guest";
home.stateVersion = "23.11"; # Set to your NixOS release version
programs.home-manager.enable = true;
home.packages = with pkgs; [
fastfetch
];
};
}
+42
View File
@@ -0,0 +1,42 @@
{ config
, pkgs
, lib
, inputs
, ...
}:
{
# Home Manager configuration for mary
home-manager.users.mary = {
home.username = "mary";
home.homeDirectory = "/home/mary";
home.stateVersion = "24.11";
home.file.".config/containers/containers.conf".text = ''
[service_destinations]
[service_destinations.local]
uri = "unix:///run/user/1000/podman/podman.sock"
[engine]
active_service = "local"
'';
programs.home-manager.enable = true;
programs.git = {
enable = true;
settings.user = {
name = "Mary";
email = "mary@example.com";
};
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
systemd.user.startServices = "sd-switch";
home.packages = with pkgs; [
fastfetch
];
};
}
+49
View File
@@ -0,0 +1,49 @@
{ config
, pkgs
, lib
, inputs
, ...
}:
{
# User password is set in NixOS host configuration
users.users.petere.extraGroups = [
"optical"
"cdrom"
];
# Home Manager configuration for petere
home-manager.users.petere = {
home.username = "petere";
home.homeDirectory = "/home/petere";
home.stateVersion = "23.11"; # Set to your NixOS release version
home.file.".config/containers/containers.conf".text = ''
[service_destinations]
[service_destinations.local]
uri = "unix:///run/user/1000/podman/podman.sock"
[engine]
active_service = "local"
'';
programs.home-manager.enable = true;
programs.git = {
enable = true;
settings.user = {
name = "Peter Edley";
email = "peter@edleyit.com";
};
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
systemd.user.startServices = "sd-switch";
home.packages = with pkgs; [
fastfetch
];
};
}
+96
View File
@@ -0,0 +1,96 @@
# /hosts/nixos/configuration.nix
{ config
, pkgs
, inputs
, lib
, ...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/gnome.nix
../../modules/desktop/apps/soundux.nix
../../modules/desktop/apps/freeshow.nix
../../modules/core/management.nix
../../modules/hardware/laptop.nix
../../modules/core/dev.nix
];
sops.secrets = {
"users/petere-password" = {
neededForUsers = true;
};
"users/caitlin-password" = {
neededForUsers = true;
};
};
services.displayManager.gdm.settings = {
"greeter" = {
"Exclude" = "petere";
};
};
home-manager.users.caitlin.imports = [
../../home-manager/modules/desktop-user.nix
];
networking.hostName = "caitlin-x1";
networking.modemmanager.enable = true;
# Laptop-specific hardware (fingerprint reader, fwupd)
my.hardware.laptop.enable = true;
hardware.sensor.iio.enable = true;
users.users.caitlin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
hashedPasswordFile = config.sops.secrets."users/caitlin-password".path;
subUidRanges = [
{
startUid = 100000;
count = 65536;
}
];
subGidRanges = [
{
startGid = 100000;
count = 65536;
}
];
};
services.openssh.enable = true;
my.users.petere = {
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
subUidStart = 165536;
subGidStart = 165536;
};
environment.systemPackages = with pkgs; [
xournalpp
prismlauncher
jdk21
rclone
steam
];
hardware.graphics.enable = true;
programs.steam.enable = true;
# Allow Steam to use 32-bit libraries
programs.steam.package = pkgs.steam.override {
extraLibraries = ps: [ ];
};
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}
+43
View File
@@ -0,0 +1,43 @@
# /hosts/x1carbon/disko-config.nix
# This file will contain your disko configuration for x1carbon.
{
disk = {
nixos = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
swap = {
size = "20G";
type = "8200";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
}
@@ -0,0 +1,55 @@
# /hosts/x1carbon/hardware-configuration.nix
# This file will be generated by NixOS during installation or by 'nixos-generate-config'.
# It contains hardware-specific settings for x1carbon.
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
#hardware.ipu6.enable = true;
#hardware.ipu6.platform = "ipu6";
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"usb_storage"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
# fileSystems."/" =
# { device = "/dev/disk/by-uuid/882a76d6-c1ac-4efd-aff8-b56d43ec7ca5";
# fsType = "ext4";
# };
# fileSystems."/boot" =
# { device = "/dev/disk/by-uuid/EF30-EBA7";
# fsType = "vfat";
# options = [ "fmask=0077" "dmask=0077" ];
# };
# swapDevices =
# [ { device = "/dev/disk/by-uuid/1c488737-a2e7-4258-b0f3-bd1d14155d03"; }
# ];
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
# (the default) this is the recommended approach. When using systemd-networkd it's
# still possible to use this option, but it's recommended to use it in conjunction
# with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
networking.useDHCP = lib.mkDefault true;
# networking.interfaces.enp0s31f6.useDHCP = lib.mkDefault true;
# networking.interfaces.wlp0s20f3.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+268
View File
@@ -0,0 +1,268 @@
# NixOS configuration for homeserver-1
# Headless server environment (Command Line Interface only)
{ config
, pkgs
, lib
, inputs
, ...
}:
{
imports = [
../../modules/core/common.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
./hardware-configuration.nix
../../modules/core/management.nix
../../modules/core/podman.nix
../../modules/hardware/nvidia.nix
../../modules/core/known-hosts.nix
../../modules/services/immich.nix
../../modules/services/jellyfin.nix
../../modules/services/backrest.nix
../../modules/services/homepage.nix
./homepage.nix
];
services.backrest = {
enable = true;
host = "0.0.0.0";
port = 9898;
dataDir = "/data/backrest";
};
systemd.services.backrest = {
after = [ "restic-ssh-key-format.service" ];
wants = [ "restic-ssh-key-format.service" ];
};
# Allow backrest to read backup source directories recursively.
# Uses ACLs with a default mask so newly created files also inherit access.
# Runs after immich and the postgresql backup so the dirs/files exist.
systemd.services.backrest-permissions = {
description = "Grant backrest read access to backup source dirs";
wantedBy = [ "multi-user.target" ];
after = [
"immich-server.service"
"postgresqlBackup-immich.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
# Recursive read+execute ACL for backrest on immich media
${pkgs.acl}/bin/setfacl -R -m u:backrest:rx -m m::r-x /data/immich
# Default ACL so future immich files are readable by backrest
${pkgs.acl}/bin/setfacl -R -m d:u:backrest:rx -m d:m::r-x /data/immich
# Recursive read for backrest on postgresql backups
${pkgs.acl}/bin/setfacl -R -m u:backrest:rx -m m::r-x /data/backup/postgresql
${pkgs.acl}/bin/setfacl -R -m d:u:backrest:rx -m d:m::r-x /data/backup/postgresql
'';
};
services.immich-server = {
enable = true;
port = 2283;
mediaLocation = "/data/immich";
};
services.jellyfin-server = {
enable = true;
port = 8096;
mediaLocation = "/data/jellyfin";
};
services.postgresqlBackup = {
enable = true;
databases = [ "immich" ];
location = "/data/backup/postgresql";
compression = "zstd";
startAt = "weekly";
};
# The services.postgresqlBackup module creates a tmpfiles `d` rule that sets
# the backup directory to 0700 on every rebuild/switch. On a directory with
# POSIX ACLs, `chmod 0700` resets the ACL mask to `---`, which nullifies the
# `user:backrest` read ACL that backrest uses to back up these dumps. Override
# the mode so rebuilds keep the directory group-traversable (mask stays rx).
# Note: systemd-tmpfiles dedupes conflicting rules and keeps the FIRST one for
# a path, so our override must appear before the module's rule.
systemd.tmpfiles.rules = lib.mkBefore [
"d /data/backup/postgresql 0750 postgres - - -"
];
sops.secrets = {
"users/petere-password" = {
neededForUsers = true;
};
"pocket-id-env" = {
neededForUsers = false;
};
"homeserver-1/restic-passphrase" = { };
"homeserver-1/restic-ssh-key" = { };
"homeserver-1/homepage-env" = { };
"homeserver-1/samba-petere-password" = { };
};
# Set petere's Samba password from SOPS at boot. Samba keeps its own password
# database (smbpasswd/tdbsam), separate from Linux login, so this must run
# smbpasswd. Idempotent: re-applied on every boot from the secret.
systemd.services.samba-set-petere-password = {
description = "Set petere's Samba password from SOPS";
wantedBy = [ "multi-user.target" ];
after = [ "sops-nix.service" ];
before = [ "samba-smbd.service" ];
serviceConfig.Type = "oneshot";
script = ''
PASS="$(cat ${config.sops.secrets."homeserver-1/samba-petere-password".path})"
${pkgs.samba}/bin/smbpasswd -s -a petere <<EOF
$PASS
$PASS
EOF
'';
};
# Install the restic SSH key (stored multi-line in sops) and set up SSH access
# for the backrest user so restic can reach mcf-server.
# Writes to a persistent location (NOT /run) because sops-nix clears /run/secrets.
systemd.services.restic-ssh-key-format = {
description = "Install restic SSH key and configure SSH for backrest";
wantedBy = [ "multi-user.target" ];
after = [ "sops-nix.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
KEY_FILE="${config.sops.secrets."homeserver-1/restic-ssh-key".path}"
FORMATTED="/data/backrest/restic-ssh-key"
${pkgs.coreutils}/bin/install -m 640 -o root -g backrest "$KEY_FILE" "$FORMATTED"
# SSH config for backrest so restic (via Backrest) uses the correct key
mkdir -p /data/backrest/.ssh
cat > /data/backrest/.ssh/config <<EOF
Host mcf-server
HostName mcf-server
User restic-homeserver1
IdentityFile /data/backrest/restic-ssh-key
IdentitiesOnly yes
Host richmond-server
HostName richmond-server
User restic-homeserver1
IdentityFile /data/backrest/restic-ssh-key
IdentitiesOnly yes
EOF
chown -R backrest:backrest /data/backrest/.ssh
chmod 700 /data/backrest/.ssh
chmod 600 /data/backrest/.ssh/config
'';
};
networking.hostName = "homeserver-1";
# SSH configuration
services.openssh.enable = true;
# Trusted host keys for restic backup targets (via Backrest).
my.knownHosts = {
mcfServer = true;
richmondServer = true;
};
# Glances system monitor - exposed to the tailnet so the Homepage
# dashboard can display real-time stats for this machine (localhost).
services.glances = {
enable = true;
port = 61208;
extraArgs = [ "--webserver" ];
};
# Expose services on Tailscale only (not the LAN).
# Immich (2283): accessed via nginx proxy on another machine over Tailscale.
# Backrest (9898), Pocket ID (8443), Homepage (8082), Glances (61208): admin services.
networking.firewall.interfaces.tailscale.allowedTCPPorts = lib.mkAfter [
2283 # Immich
8443 # Pocket ID
9898 # Backrest
8082 # Homepage
61208 # Glances
];
# Add Pocket ID package for tooling
environment.systemPackages = with pkgs; [ pocket-id ];
# Pocket ID service configuration
services.pocket-id = {
enable = true;
environmentFile = config.sops.secrets."pocket-id-env".path;
settings = {
APP_URL = "https://homeserver-1.gerbil-opah.ts.net:8443";
PORT = 8443;
TRUST_PROXY = true;
TLS_CERT_FILE = "/etc/ssl/certs/pocket-id.crt";
TLS_KEY_FILE = "/etc/ssl/private/pocket-id.key";
};
};
systemd.services.pocket-id = {
wants = [ "pocket-id-tailscale-cert.service" ];
after = [ "pocket-id-tailscale-cert.service" ];
};
# Systemd service to obtain TLS cert via Tailscale
systemd.services.pocket-id-tailscale-cert = {
description = "Obtain TLS cert for Pocket-ID via Tailscale";
wantedBy = [ "multi-user.target" ];
wants = [
"network-online.target"
"tailscaled.service"
];
after = [
"network-online.target"
"tailscaled.service"
];
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "get-tailscale-cert" ''
set -eu
mkdir -p /etc/ssl/certs /etc/ssl/private
if [ ! -f /etc/ssl/certs/pocket-id.crt ] || [ ! -f /etc/ssl/private/pocket-id.key ]; then
${pkgs.tailscale}/bin/tailscale cert --cert-file /etc/ssl/certs/pocket-id.crt --key-file /etc/ssl/private/pocket-id.key homeserver-1.gerbil-opah.ts.net
fi
chown root:pocket-id /etc/ssl/private/pocket-id.key
chmod 640 /etc/ssl/private/pocket-id.key
chmod 644 /etc/ssl/certs/pocket-id.crt
'';
User = "root";
Group = "root";
};
};
# NVIDIA GPU Configuration for GeForce GTX 960 (Maxwell GM206)
my.hardware.nvidia = {
enable = true;
# GTX 960 (Maxwell) needs the 580.xx legacy driver branch; the default
# driver no longer supports it (NVRM: No NVIDIA GPU found).
package = config.boot.kernelPackages.nvidiaPackages.legacy_580;
};
boot.kernelModules = [ "sg" ];
# Root account is locked (no password login); access is via SSH key + sudo.
users.users.root.hashedPassword = "!";
# Standard user account (shared definition in modules/core/users.nix)
my.users.petere = {
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
};
# Trusted users for Nix operations
nix.settings.trusted-users = [
"root"
"petere"
];
}
+75
View File
@@ -0,0 +1,75 @@
{
disk = {
main = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for GRUB MBR fallback
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
data1 = {
type = "disk";
device = "/dev/sda";
content = {
type = "gpt";
partitions = {
data = {
size = "100%";
content = {
type = "btrfs";
extraArgs = [ "-f" ];
mountOptions = [
"defaults"
"nofail"
];
mountpoint = "/data";
};
};
};
};
};
data2 = {
type = "disk";
device = "/dev/sdb";
content = {
type = "gpt";
partitions = {
data = {
size = "100%";
content = {
type = "btrfs";
extraArgs = [ "-f" ];
postCreateHook = ''
btrfs device add -f /dev/disk/by-partlabel/disk-data2-data /mnt/data || true
btrfs balance start -dconvert=raid1 -mconvert=raid1 /mnt/data || true
'';
};
};
};
};
};
};
}
@@ -0,0 +1,34 @@
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"nvme"
"xhci_pci"
"ahci"
"usb_storage"
"usbhid"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.supportedFilesystems = [
"btrfs"
"ext4"
"vfat"
];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+516
View File
@@ -0,0 +1,516 @@
# Homepage dashboard (gethomepage.dev) configuration for homeserver-1
#
# This file holds the full dashboard definition (settings, services, widgets).
# It is imported by configuration.nix. Edit this file to change what the
# dashboard shows, then run:
# nixos-rebuild switch --target-host petere@homeserver-1 --flake .#homeserver-1 --use-remote-sudo
{ config
, lib
, ...
}:
let
# Build a Tailscale widget tile for the Tailnet tab. Shared so the
# highlight rules (expiry / last-seen) are defined once.
# Returns a service entry: { "<name>" = { icon; href; description; widget; } }
tailscaleTile = name: deviceid: description: {
${name} = {
icon = "sh-tailscale";
href = "https://login.tailscale.com/admin/machines";
inherit description;
widget = {
type = "tailscale";
inherit deviceid;
key = "{{HOMEPAGE_VAR_TAILSCALE_API_KEY}}";
# Highlight rules match the rendered field values (e.g. "24w", "Never",
# "8h Ago", "2w Ago"). Warn = expiring within a week, danger = offline >24h.
highlight = {
expires = {
string = [
{
level = "warn";
when = "regex";
# 1-7 days, or hours/minutes/seconds remaining (i.e. within a week)
value = "^\\d+[dhms]$";
}
];
};
last_seen = {
string = [
{
level = "danger";
when = "regex";
# days/weeks/years ago (i.e. not seen for more than 24 hours)
value = "^\\d+[dwy] Ago$";
}
];
};
};
};
};
};
in
{
services.homepage = {
enable = true;
port = 8082;
allowedHosts = [
"localhost"
"127.0.0.1"
"homeserver-1"
"homeserver-1.gerbil-opah.ts.net"
];
openFirewall = false; # Exposed on Tailscale only (see firewall in configuration.nix)
# API keys / secrets for service widgets (HOMEPAGE_VAR_* vars)
environmentFiles = [ config.sops.secrets."homeserver-1/homepage-env".path ];
settings = {
title = "HomeServer";
language = "en";
theme = "dark";
color = "slate";
statusStyle = "dot";
# Tabs: each layout group's `tab` value controls which tab it appears on.
# Groups without a `tab` (or with no layout entry) show on every tab.
layout = {
# ---- Monitoring tab ----
"Homeserver-1 Monitoring" = {
tab = "Monitoring";
style = "row";
columns = 1;
"HS1 System" = {
style = "row";
columns = 2;
};
"HS1 Disks" = {
style = "row";
columns = 2; # NVMe + SDA side by side
};
};
"MCF Server Monitoring" = {
tab = "Monitoring";
style = "row";
columns = 1;
"MCF System" = {
style = "row";
columns = 2;
};
"MCF Disks" = {
style = "row";
columns = 2; # SDB (system) + SDA (data) side by side
};
};
"Richmond Server Monitoring" = {
tab = "Monitoring";
style = "row";
columns = 1;
"Richmond System" = {
style = "row";
columns = 2;
};
"Richmond Disks" = {
style = "row";
columns = 2; # SDB (system) + SDA (data) side by side
};
};
# ---- Homeserver-1 tab ----
Media = {
tab = "Homeserver-1";
style = "row";
columns = 2;
};
System = {
tab = "Homeserver-1";
style = "row";
columns = 3;
};
# ---- MCF Server tab (populate with future mcf-server services) ----
# "MCF Server Apps" = {
# tab = "MCF Server";
# style = "row";
# columns = 4;
# };
# ---- Richmond Server tab ----
"Richmond Server Apps" = {
tab = "Richmond Server";
style = "row";
columns = 3;
};
# ---- Tailnet tab (one Tailscale widget per machine) ----
Tailnet = {
tab = "Tailnet";
style = "row";
columns = 3;
};
};
};
services = [
{
"Homeserver-1 Monitoring" = [
{
"HS1 System" = [
{
"System" = {
widget = {
type = "glances";
url = "http://127.0.0.1:61208";
version = 4; # Glances v4.x
metric = "info";
};
};
}
{
"CPU" = {
widget = {
type = "glances";
url = "http://127.0.0.1:61208";
version = 4;
metric = "cpu";
};
};
}
{
"Memory" = {
widget = {
type = "glances";
url = "http://127.0.0.1:61208";
version = 4;
metric = "memory";
};
};
}
{
"Processes" = {
widget = {
type = "glances";
url = "http://127.0.0.1:61208";
version = 4;
metric = "process";
};
};
}
];
}
{
"HS1 Disks" = [
{
"NVMe - System" = {
widget = {
type = "glances";
url = "http://127.0.0.1:61208";
version = 4;
metric = "disk:nvme0n1";
};
};
}
{
"SDA - Data" = {
widget = {
type = "glances";
url = "http://127.0.0.1:61208";
version = 4;
metric = "disk:sda";
};
};
}
];
}
];
}
{
"MCF Server Monitoring" = [
{
"MCF System" = [
{
"System" = {
widget = {
type = "glances";
url = "http://mcf-server.gerbil-opah.ts.net:61208";
version = 4; # Glances v4.x
metric = "info";
};
};
}
{
"CPU" = {
widget = {
type = "glances";
url = "http://mcf-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "cpu";
};
};
}
{
"Memory" = {
widget = {
type = "glances";
url = "http://mcf-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "memory";
};
};
}
{
"Processes" = {
widget = {
type = "glances";
url = "http://mcf-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "process";
};
};
}
];
}
{
"MCF Disks" = [
{
"SDB - System" = {
widget = {
type = "glances";
url = "http://mcf-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "disk:sdb";
};
};
}
{
"SDA - Data" = {
widget = {
type = "glances";
url = "http://mcf-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "disk:sda";
};
};
}
];
}
];
}
{
"Richmond Server Monitoring" = [
{
"Richmond System" = [
{
"System" = {
widget = {
type = "glances";
url = "http://richmond-server.gerbil-opah.ts.net:61208";
version = 4; # Glances v4.x
metric = "info";
};
};
}
{
"CPU" = {
widget = {
type = "glances";
url = "http://richmond-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "cpu";
};
};
}
{
"Memory" = {
widget = {
type = "glances";
url = "http://richmond-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "memory";
};
};
}
{
"Processes" = {
widget = {
type = "glances";
url = "http://richmond-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "process";
};
};
}
];
}
{
"Richmond Disks" = [
{
"SDB - System" = {
widget = {
type = "glances";
url = "http://richmond-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "disk:sdb";
};
};
}
{
"SDA - Data" = {
widget = {
type = "glances";
url = "http://richmond-server.gerbil-opah.ts.net:61208";
version = 4;
metric = "disk:sda";
};
};
}
];
}
];
}
{
Media = [
{
Jellyfin = {
icon = "sh-jellyfin";
href = "http://jellyfin.edley.me";
description = "Movies & TV";
siteMonitor = "http://127.0.0.1:8096";
widget = {
type = "jellyfin";
url = "http://127.0.0.1:8096";
key = "{{HOMEPAGE_VAR_JELLYFIN_API_KEY}}";
enableBlocks = true;
enableNowPlaying = true;
};
};
}
{
Immich = {
icon = "sh-immich";
href = "http://immich.edley.me";
description = "Photo & Video";
siteMonitor = "http://127.0.0.1:2283";
widget = {
type = "immich";
url = "http://127.0.0.1:2283";
key = "{{HOMEPAGE_VAR_IMMICH_API_KEY}}";
version = 2; # Immich >= 1.118
};
};
}
];
}
{
System = [
{
Backrest = {
icon = "sh-backrest";
href = "http://homeserver-1.gerbil-opah.ts.net:9898";
description = "Restic backup UI";
siteMonitor = "http://127.0.0.1:9898";
widget = {
type = "backrest";
url = "http://127.0.0.1:9898";
};
};
}
{
"Pocket ID" = {
icon = "sh-pocketbase";
href = "https://homeserver-1.gerbil-opah.ts.net:8443";
description = "SSO / Identity";
};
}
];
}
# ---- MCF Server tab: add future mcf-server services here ----
# {
# "MCF Server Apps" = [
# {
# "MyApp" = {
# icon = "sh-myservice";
# href = "http://mcf-server.gerbil-opah.ts.net:<port>";
# siteMonitor = "http://mcf-server.gerbil-opah.ts.net:<port>";
# };
# }
# ];
# }
{
"Richmond Server Apps" = [
{
PiHole = {
icon = "sh-pihole";
href = "http://richmond-server.gerbil-opah.ts.net/admin";
description = "Network-wide ad blocking";
siteMonitor = "http://richmond-server.gerbil-opah.ts.net";
widget = {
type = "pihole";
url = "http://richmond-server.gerbil-opah.ts.net";
version = 6; # Pi-hole v6
key = "{{HOMEPAGE_VAR_PIHOLE_API_KEY}}";
};
};
}
{
Castopod = {
icon = "sh-castopod";
href = "http://richmond-server.gerbil-opah.ts.net:8080";
description = "Podcasting platform";
siteMonitor = "http://richmond-server.gerbil-opah.ts.net:8080";
};
}
{
Ntfy = {
icon = "sh-ntfy";
href = "https://ntfy.edley.me";
description = "Push notifications";
siteMonitor = "http://richmond-server.gerbil-opah.ts.net:8085";
};
}
];
}
# ---- Tailnet tab: one Tailscale widget per machine ----
{
Tailnet = [
(tailscaleTile "Homeserver-1" "7629334038136604" "homeserver-1.gerbil-opah.ts.net")
(tailscaleTile "MCF Server" "6531912398509392" "mcf-server.gerbil-opah.ts.net")
(tailscaleTile "Richmond Server" "4591058654038528" "richmond-server.gerbil-opah.ts.net")
(tailscaleTile "x1carbon" "3486385364789872" "x1carbon.gerbil-opah.ts.net")
(tailscaleTile "x470" "210979648507396" "x470.gerbil-opah.ts.net")
(tailscaleTile "caitlin-x1" "4406025993575891" "caitlin-x1.gerbil-opah.ts.net")
(tailscaleTile "Mary Laptop" "5830538092696610" "mary-laptop.gerbil-opah.ts.net")
(tailscaleTile "Pluto" "4170550062707667" "pluto.gerbil-opah.ts.net")
(tailscaleTile "TheBorg" "2268885677960290" "theborg.gerbil-opah.ts.net")
(tailscaleTile "Server" "3605859775136634" "server.gerbil-opah.ts.net")
(tailscaleTile "Homeserver" "5665047953117744" "homeserver.gerbil-opah.ts.net")
(tailscaleTile "MCF Projector" "5131578183597553" "mcf-projector.gerbil-opah.ts.net")
(tailscaleTile "MCF Stream" "6804850380243723" "mcf-stream.gerbil-opah.ts.net")
(tailscaleTile "Yoga 12" "1716848030756573" "yoga12.gerbil-opah.ts.net")
(tailscaleTile "Pixel 9 Pro XL" "6766405315162342" "pixel-9-pro-xl.gerbil-opah.ts.net")
];
}
];
widgets = [
{
resources = {
cpu = true;
memory = true;
disk = "/";
};
}
{
search = {
provider = "duckduckgo";
target = "_blank";
};
}
{
datetime = {
text_size = "xl";
locale = "en-GB"; # UK date format (dd/mm/yy)
format = {
dateStyle = "short";
timeStyle = "short";
};
};
}
];
};
}
+105
View File
@@ -0,0 +1,105 @@
# /hosts/hp-laptop/configuration.nix
{
config,
pkgs,
inputs,
lib,
...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/hyprland.nix
../../modules/core/management.nix
../../modules/hardware/laptop.nix
../../modules/hardware/hp-battery-limit.nix
../../modules/core/dev.nix
];
sops.secrets = {
"users/petere-password" = {
neededForUsers = true;
};
# Nextcloud CalDAV credentials for the QuickShell calendar popup
# (vdirsyncer/khal sync). Values live in secrets.yaml under
# hp-laptop/nextcloud-cal-env; sync runs as petere so it must be readable.
"hp-laptop/nextcloud-cal-env" = {
owner = "petere";
group = "users";
mode = "0440";
};
};
home-manager.users.petere.imports = [
../../home-manager/modules/hyprland.nix
../../home-manager/modules/quickshell-cal.nix
../../home-manager/modules/quickshell-apps.nix
];
home-manager.users.petere.services.quickshell-cal.enable = true;
# Autostart app pool (toggleable from the gear quick-settings panel).
# The on/off choice is stored at runtime in
# ~/.cache/quickshell/autostart.json - adding an app here just makes it
# available as an (off by default) entry next time the panel loads.
home-manager.users.petere.services.quickshell-apps = {
enable = true;
apps = [
{
name = "Element";
cmd = "element-desktop";
}
{
name = "Nextcloud";
cmd = "nextcloud";
}
{
name = "Bitwarden";
cmd = "bitwarden";
}
];
};
networking.hostName = "hp-laptop";
# Open legacy game-streaming ports.
networking.firewall = {
enable = true;
allowedTCPPorts = [ 9756 ];
allowedUDPPorts = [ 9999 ];
};
# TeleportFling: standalone screen + audio sender for OBS Teleport.
environment.systemPackages = [
inputs.teleportfling.packages.${pkgs.system}.teleportfling-gui
inputs.teleportfling.packages.${pkgs.system}.teleportfling
];
# Laptop-specific hardware support (fingerprint reader, fwupd)
my.hardware.laptop.enable = true;
# Charging cap at 80% (protect battery longevity). Board 81AD firmware lacks
# a percentage threshold sysfs, so this runs SBCC/SBCO via acpi_call: a root
# poller inhibits charge at 80% and re-enables auto below 75%. Toggleable
# from the QuickShell gear panel (CHARGE LIMIT).
my.hardware.hpBatteryLimit.enable = true;
# Intel integrated GPU (HP consumer laptops)
hardware.graphics.enable = true;
my.users.petere = {
description = "Peter Edley";
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
subUidStart = 165536;
subGidStart = 165536;
};
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}
+62
View File
@@ -0,0 +1,62 @@
# /hosts/hp-laptop/disko-config.nix
# Two-disk layout:
# /dev/sda -> boot + ESP (/boot) + swap + root (/)
# /dev/sdb -> home (/home)
{
disk = {
root = {
type = "disk";
device = "/dev/sda";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02";
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
swap = {
size = "16G";
type = "8200";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
home = {
type = "disk";
device = "/dev/sdb";
content = {
type = "gpt";
partitions = {
home = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/home";
};
};
};
};
};
};
}
@@ -0,0 +1,36 @@
# hardware-configuration.nix for hp-laptop
# Generated placeholder — run `sudo nixos-generate-config --show-hardware-config` on the
# target machine after the initial (disko / nixos-anywhere) install and replace this file.
# NOTE: Do NOT copy the output of `nixos-generate-config` run from the installer ISO — it
# reflects the live environment (tmpfs /, /iso, squashfs overlay), not the installed system.
# NOTE: `fileSystems` and `swapDevices` are intentionally omitted here — they are
# provided by the disko module (hosts/hp-laptop/disko-config.nix).
# Disk layout (see disko-config.nix): /dev/sda = boot + ESP + swap + root (/);
# /dev/sdb = home (/home).
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"usb_storage"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
networking.useDHCP = lib.mkDefault true;
# networking.interfaces.wlp2s0.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+81
View File
@@ -0,0 +1,81 @@
# /hosts/mary-x270/configuration.nix
{ config
, pkgs
, inputs
, lib
, ...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/gnome.nix
../../modules/core/management.nix
../../modules/core/dev.nix
];
sops.secrets = {
"users/petere-password" = {
neededForUsers = true;
};
"users/mary-password" = {
neededForUsers = true;
};
"opencode-api-key" = {
owner = "petere";
group = "users";
mode = "0440";
};
};
home-manager.users.mary.imports = [
../../home-manager/modules/desktop-user.nix
];
home-manager.users.petere.imports = [
../../home-manager/modules/desktop-user.nix
];
networking.hostName = "mary-x270";
users.users.mary = {
isNormalUser = true;
description = "Mary";
extraGroups = [
"wheel"
"networkmanager"
"video"
"audio"
];
hashedPasswordFile = config.sops.secrets."users/mary-password".path;
subUidRanges = [
{
startUid = 200000;
count = 65536;
}
];
subGidRanges = [
{
startGid = 200000;
count = 65536;
}
];
};
my.users.petere = {
description = "Peter Edley";
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
subUidStart = 165536;
subGidStart = 165536;
};
hardware.graphics.enable = true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}
+42
View File
@@ -0,0 +1,42 @@
# /hosts/mary-x270/disko-config.nix
{
disk = {
nixos = {
type = "disk";
device = "/dev/sda";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02";
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
swap = {
size = "16G";
type = "8200";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
}
@@ -0,0 +1,28 @@
# /hosts/mary-x270/hardware-configuration.nix
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"usb_storage"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+55
View File
@@ -0,0 +1,55 @@
# NixOS configuration for mcf-server
# Headless server environment (Command Line Interface only)
{ config
, pkgs
, lib
, inputs
, ...
}:
{
imports = [
../../modules/core/common.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
./hardware-configuration.nix
../../modules/core/management.nix
../../modules/core/podman.nix
];
networking.hostName = "mcf-server";
# Create restic user for homeserver-1 backups
users.users.restic-homeserver1 = {
isSystemUser = true;
group = "restic-homeserver1";
shell = pkgs.zsh;
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICwGZFEr6OMm7SIPrYlt6wuuesvlmBIezqQVDxhXOHjD restic@homeserver-1"
];
};
users.groups.restic-homeserver1 = { };
# Standard user account (shared definition in modules/core/users.nix).
# SSH-key-only access; no password on this host.
# SSH configuration
services.openssh.enable = true;
# Glances system monitor - exposed to the tailnet so homeserver-1's
# Homepage dashboard can display real-time stats for this machine.
services.glances = {
enable = true;
port = 61208;
# Webserver mode (default). Binds to 0.0.0.0; the firewall rule below
# restricts access to the Tailscale interface only.
extraArgs = [ "--webserver" ];
};
# Expose Glances (61208) on Tailscale only
networking.firewall.interfaces.tailscale.allowedTCPPorts = [ 61208 ];
}
+51
View File
@@ -0,0 +1,51 @@
{
disk = {
main = {
type = "disk";
device = "/dev/sdb";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
data = {
type = "disk";
device = "/dev/sda";
content = {
type = "gpt";
partitions = {
data = {
size = "100%";
content = {
type = "filesystem";
format = "btrfs";
mountpoint = "/data";
};
};
};
};
};
};
}
@@ -0,0 +1,36 @@
# Hardware configuration for mcf-server
# Generated by nixos-generate-config; live-ISO filesystem entries removed (managed by Disko)
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"ahci"
"usbhid"
"usb_storage"
"sd_mod"
"sr_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
# fileSystems are managed by Disko — update disko-config.nix for disk layout
swapDevices = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+79
View File
@@ -0,0 +1,79 @@
# /hosts/mcf-stream/configuration.nix
{ config
, pkgs
, inputs
, lib
, ...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/gnome.nix
../../modules/core/management.nix
../../modules/hardware/nvidia.nix
../../modules/desktop/apps/obs.nix
../../modules/desktop/apps/dvd.nix
../../modules/desktop/apps/carla.nix
];
sops.secrets = {
"users/petere-password" = {
neededForUsers = true;
};
"users/guest-password" = {
neededForUsers = true;
};
};
# Auto-login as guest for this shared/streaming desktop
services.displayManager.autoLogin = {
enable = true;
user = "guest";
};
home-manager.users.petere.imports = [
../../home-manager/modules/desktop-user.nix
];
home-manager.users.guest.imports = [
../../home-manager/modules/gnome-extensions.nix
];
networking.hostName = "mcf-stream";
my.hardware.nvidia = {
enable = true;
nvidiaSettings = true;
# GTX 1050 Ti (Pascal) needs the 580.xx legacy driver branch
package = config.boot.kernelPackages.nvidiaPackages.legacy_580;
};
my.users.petere = {
description = "Peter Edley";
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
};
users.users.guest = {
isNormalUser = true;
description = "Guest";
hashedPasswordFile = config.sops.secrets."users/guest-password".path;
extraGroups = [
"networkmanager"
"video"
"audio"
];
};
# ffmpeg with NVENC encoders (h264/hevc/av1_nvenc) for GPU-accelerated
# encoding on the NVIDIA GPU
environment.systemPackages = with pkgs; [
ffmpeg
];
}
+43
View File
@@ -0,0 +1,43 @@
# /hosts/mcf-stream/disko-config.nix
# This file will contain your disko configuration for mcf-stream.
{
disk = {
nixos = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
swap = {
size = "20G";
type = "8200";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
}
@@ -0,0 +1,35 @@
# Hardware configuration for mcf-stream
# Generated for a single NVMe drive (/dev/nvme0n1)
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"ahci"
"nvme"
"usb_storage"
"usbhid"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
# fileSystems are managed by Disko — update disko-config.nix for disk layout
swapDevices = [ ];
networking.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+374
View File
@@ -0,0 +1,374 @@
# This is the NixOS configuration for richmond-server.
# It is a server and does not require a GUI.
{ config
, pkgs
, lib
, inputs
, # Re-add inputs here
... # specialArgs from flake.nix
}:
{
imports = [
# Import your common modules here
../../modules/core/common.nix
../../modules/core/management.nix
# ../../modules/some-common-module.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
./hardware-configuration.nix # Import hardware configuration
../../modules/core/podman.nix
../../modules/services/ntfy.nix
];
sops.secrets = {
"richmond-server/tailscale-authkey" = {
mode = "0600";
owner = "root";
};
"richmond-server/pihole-password" = { };
"richmond-server/castopod-api-password" = {
key = "richmond-server/castopod-api-password";
};
"richmond-server/mcf-notices-env" = { };
"richmond-server/castopod-env" = { };
"richmond-server/castopod-api-env" = { };
"users/petere-password" = {
neededForUsers = true;
};
};
# Set your hostname
networking.hostName = "richmond-server";
# Set explicit nameservers for the host to ensure it can reach registries
# regardless of local container or Tailscale DNS state.
networking.nameservers = [
"1.1.1.1"
"8.8.8.8"
];
# Allow rootless containers to bind to privileged ports
boot.kernel.sysctl = {
"net.ipv4.ip_unprivileged_port_start" = 53;
};
# Enable SSH
services.openssh.enable = true;
# Backup Server Configuration
# Borg Backup removed - x1carbon migrated to Backrest on homeserver-1
# Richmond-server now serves as backup target for Backrest (homeserver-1) only
# Create a dedicated system user for Backrest backups from homeserver-1
users.users.restic-homeserver1 = {
isSystemUser = true;
group = "restic-homeserver1";
shell = pkgs.zsh;
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICwGZFEr6OMm7SIPrYlt6wuuesvlmBIezqQVDxhXOHjD restic@homeserver-1"
];
};
# Admin user (shared definition in modules/core/users.nix)
my.users.petere = {
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
};
users.groups = {
restic-homeserver1 = { };
};
# Disable systemd-resolved to prevent it from binding to port 53,
# allowing Pi-hole to take over DNS duties.
services.resolved.enable = false;
# The 'services.resolved.extraConfig' option is deprecated and now causes a build failure.
# The 'services.resolved.enable = false;' line above is sufficient to free up port 53 for Pi-hole.
# services.resolved.extraConfig = ''
# DNSStubListener=no
# '';
# Enable Tailscale (globally enabled in modules/core/settings.nix)
services.tailscale.authKeyFile = config.sops.secrets."richmond-server/tailscale-authkey".path;
# NOTE: no "--ssh" here. Tailscale SSH would intercept port 22 on the tailnet
# and enforce the tailnet ACL, blocking the restic-homeserver1 user that
# Backrest (homeserver-1) uses for backups. Regular OpenSSH handles SSH instead.
services.tailscale.extraUpFlags = [
"--accept-dns=false"
];
# Glances system monitor - exposed to the tailnet so the Homepage
# dashboard on homeserver-1 can display real-time stats for this machine.
services.glances = {
enable = true;
port = 61208;
extraArgs = [ "--webserver" ];
};
# VLAN configuration
networking.vlans = {
management = {
id = 5;
interface = "enp1s0";
};
office = {
id = 10;
interface = "enp1s0";
};
tech = {
id = 20;
interface = "enp1s0";
};
advice = {
id = 30;
interface = "enp1s0";
};
words = {
id = 40;
interface = "enp1s0";
};
general = {
id = 50;
interface = "enp1s0";
};
printers = {
id = 60;
interface = "enp1s0";
};
};
networking.interfaces = {
management.useDHCP = true;
office.useDHCP = true;
tech.useDHCP = true;
advice.useDHCP = true;
words.useDHCP = true;
general.useDHCP = true;
printers.useDHCP = true;
};
# podman configuration is now in ../../modules/core/podman.nix
# --- Pi-hole Container Configuration ---
virtualisation.oci-containers.containers.pihole = {
image = "docker.io/pihole/pihole:2026.07.2";
autoStart = true;
ports = [
"53:53/tcp"
"53:53/udp"
"80:80/tcp" # Web UI on port 80
];
volumes = [
"/var/lib/pihole/etc-pihole:/etc/pihole"
"/var/lib/pihole/etc-dnsmasq.d:/etc/dnsmasq.d"
];
environment = {
TZ = "Europe/London";
DNSMASQ_LISTENING = "all";
PIHOLE_INTERFACE = "all";
};
environmentFiles = [
"/run/pihole-env"
];
extraOptions = [
"--cap-add=NET_ADMIN" # Needed for DHCP features
];
};
# --- MCFNotices Container Configuration ---
virtualisation.oci-containers.containers.mcf-notices = {
image = "docker.io/pedley/slideshow-builder:2.6.0";
autoStart = true;
volumes = [
# Persistent runtime config (notices-data) — app stores settings.db at
# /data/settings.db inside the container.
"/home/petere/mcf_data:/data"
];
environmentFiles = [
config.sops.secrets."richmond-server/mcf-notices-env".path
];
extraOptions = [
"--no-healthcheck"
];
};
# Improve container service resilience and ensure network is ready before starting.
systemd.services.podman-pihole = {
after = [
"network-online.target"
"pihole-data-dirs.service"
];
wants = [ "network-online.target" ];
preStart = ''
${pkgs.podman}/bin/podman rm -f pihole || true
'';
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Restart = lib.mkForce "always";
RestartSec = "10s";
};
};
# Rendered by sops-nix from the pihole-password secret; the Pi-hole container
# reads this file for FTLCONF_webserver_api_password.
sops.templates."pihole-env" = {
content = ''
FTLCONF_webserver_api_password=${config.sops.placeholder."richmond-server/pihole-password"}
'';
path = "/run/pihole-env";
mode = "0600";
};
services.mysql = {
enable = true;
package = pkgs.mariadb;
settings = {
mysqld = {
bind-address = "0.0.0.0"; # Allow container access
character-set-server = "utf8mb4";
collation-server = "utf8mb4_unicode_ci";
};
client = {
default-character-set = "utf8mb4";
};
};
};
# --- Castopod Container Configuration ---
virtualisation.oci-containers.containers.castopod = {
image = "castopod/castopod:1.15.5";
autoStart = true;
volumes = [
"/var/lib/castopod/media:/var/www/html/public/media"
"/var/lib/castopod/writable:/var/www/html/writable"
"/var/lib/castopod/plugins:/var/www/html/plugins"
"/var/lib/castopod/plugins/mcf/episode-filter:/var/www/html/plugins/mcf/episode-filter"
"/var/lib/castopod/public/plugins/mcf/episode-filter/assets:/var/www/html/public/plugins/mcf/episode-filter/assets"
];
environmentFiles = [
config.sops.secrets."richmond-server/castopod-env".path
"/run/castopod-api-env"
];
extraOptions = [
"--network=host"
];
# We use a "configuration injection" trick here via the /run/castopod-api-env file.
};
# Improve Castopod container resilience and ensure directories exist
systemd.services.podman-castopod = {
after = [
"network-online.target"
"castopod-data-dirs.service"
];
wants = [ "network-online.target" ];
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Restart = lib.mkForce "always";
RestartSec = "10s";
};
};
# NOTE: Castopod is an experiment. This env file is a "configuration injection"
# used to pass restapi settings; the original preStart wrote a single line with
# literal \n sequences. This template writes real newlines instead — review
# Castopod's behaviour on the next pass.
sops.templates."castopod-api-env" = {
content = ''
CP_DATABASE_PREFIX=cp_"
restapi.enabled=true
restapi.basicAuth=true
restapi.basicAuthUsername=pedley
restapi.basicAuthPassword=${config.sops.placeholder."richmond-server/castopod-api-password"}
dummy="
'';
path = "/run/castopod-api-env";
mode = "0600";
};
# Create persistent data directories for Castopod.
systemd.services.castopod-data-dirs = {
description = "Create data directories for Castopod container";
wantedBy = [ "multi-user.target" ];
before = [ "podman-castopod.service" ];
serviceConfig.Type = "oneshot";
script = ''
mkdir -p /var/lib/castopod/media
mkdir -p /var/lib/castopod/writable
mkdir -p /var/lib/castopod/plugins
mkdir -p /var/lib/castopod/public/plugins/mcf/episode-filter/assets
# Ensure the container's www-data user (UID 33) can write to these
chown -R 33:33 /var/lib/castopod/media /var/lib/castopod/writable /var/lib/castopod/plugins /var/lib/castopod/public
chmod -R 775 /var/lib/castopod/media /var/lib/castopod/writable /var/lib/castopod/plugins /var/lib/castopod/public
'';
};
# --- ntfy Configuration ---
services.ntfy-container = {
enable = true;
port = 8085;
};
# Open firewall ports for Pi-hole and ntfy globally.
networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [
53
80
8085
];
networking.firewall.allowedUDPPorts = [ 53 ];
# Restrict Castopod (8080) to only be accessible via Tailscale
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [
8080
61208
];
# Create persistent data directories for Pi-hole.
systemd.services.pihole-data-dirs = {
description = "Create data directories for Pi-hole container";
wantedBy = [ "multi-user.target" ];
before = [ "podman-pihole.service" ];
serviceConfig.Type = "oneshot";
script = ''
mkdir -p /var/lib/pihole/etc-pihole
mkdir -p /var/lib/pihole/etc-dnsmasq.d
'';
};
# Create the persistent data directory for the MCFNotices container.
systemd.services.mcf-notices-data-dirs = {
description = "Create data directory for MCFNotices container";
wantedBy = [ "multi-user.target" ];
before = [ "podman-mcf-notices.service" ];
serviceConfig.Type = "oneshot";
script = ''
mkdir -p /home/petere/mcf_data
chown petere:users /home/petere/mcf_data
chmod 775 /home/petere/mcf_data
'';
};
# Ensure the base directory for backups exists and has correct permissions
systemd.tmpfiles.rules = [
# Borg backup directories removed (migrated to Backrest)
"d /home/backup/restic/immich-backup 0700 restic-homeserver1 restic-homeserver1 -"
];
nix.settings.trusted-users = [
"root"
"petere"
];
}
+51
View File
@@ -0,0 +1,51 @@
{
disk = {
main = {
type = "disk";
device = "/dev/sdb";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
home = {
type = "disk";
device = "/dev/sda";
content = {
type = "gpt";
partitions = {
home = {
size = "100%";
content = {
type = "filesystem";
format = "btrfs";
mountpoint = "/home";
};
};
};
};
};
};
}
@@ -0,0 +1,58 @@
# Do not modify this file! It was generated by nixos-generate-config
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"ahci"
"usb_storage"
"usbhid"
"sd_mod"
"sr_mod"
"rtsx_usb_sdmmc"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
# fileSystems."/" =
# { device = "/dev/disk/by-uuid/e0430ebf-8a47-44cb-aac7-77639f2184b1";
# fsType = "ext4";
# };
# fileSystems."/boot" =
# { device = "/dev/disk/by-uuid/63DD-EA06";
# fsType = "vfat";
# options = [ "fmask=0077" "dmask=0077" ];
# };
# fileSystems."/home" =
# { device = "/dev/disk/by-uuid/f60ae16e-2580-452c-a9bc-f23d9a7446bc";
# fsType = "btrfs";
# };
swapDevices = [ ];
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
# (the default) this is the recommended approach. When using systemd-networkd it's
# still possible to use this option, but it's recommended to use it in conjunction
# with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
networking.useDHCP = lib.mkDefault true;
# networking.interfaces.enp1s0.useDHCP = lib.mkDefault true;
# networking.interfaces.wlp2s0.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+208
View File
@@ -0,0 +1,208 @@
# /hosts/nixos/configuration.nix
{ config
, pkgs
, inputs
, lib
, ...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/hyprland.nix
../../modules/desktop/apps/soundux.nix
../../modules/desktop/apps/freeshow.nix
../../modules/desktop/apps/x32edit.nix
../../modules/desktop/apps/mixing-station.nix
../../modules/desktop/apps/opencode.nix
../../modules/core/management.nix
../../modules/hardware/laptop.nix
../../modules/hardware/nvidia.nix
../../modules/hardware/thinkpad-battery-limit.nix
../../modules/hardware/tablet-mode.nix
../../modules/core/known-hosts.nix
../../modules/core/podman.nix
../../modules/core/dev.nix
../../modules/services/paperless.nix
];
sops.secrets = {
"x1carbon/borg-passphrase" = {
mode = "0600";
owner = "root";
};
"x1carbon/borg-ssh-key" = {
mode = "0600";
owner = "root";
};
# Nextcloud CalDAV credentials for the QuickShell calendar popup. Rendered
# to /run/secrets/hp-laptop/nextcloud-cal-env (the default path the
# qs-cal-sync backend expects) — reuse the same secret for both hosts.
"hp-laptop/nextcloud-cal-env" = {
owner = "petere";
group = "users";
mode = "0440";
};
"users/petere-password" = {
neededForUsers = true;
};
"x1carbon/telegram-bot-token" = {
owner = "petere";
group = "users";
mode = "0440";
};
"opencode-api-key" = {
owner = "petere";
group = "users";
mode = "0440";
};
};
#TEMPORARY FIX
# nixpkgs.overlays = [
# (final: prev: {
# pnpm = prev.pnpm // { nodejs-slim = final.nodejs-slim; };
# pnpm_10 = prev.pnpm_10 // { nodejs-slim = final.nodejs-slim; };
# pnpm_11 = prev.pnpm_11 // { nodejs-slim = final.nodejs-slim; };
# })
# ];
services.paperless-service.enable = true;
hardware.sensor.iio.enable = true;
home-manager.users.petere.imports = [
../../home-manager/modules/hyprland.nix
../../home-manager/modules/quickshell-cal.nix
../../home-manager/modules/quickshell-apps.nix
../../home-manager/modules/opencode.nix
../../home-manager/modules/nix-lsp.nix
../../home-manager/modules/matugen.nix
];
home-manager.users.petere.services.quickshell-cal.enable = true;
home-manager.users.petere.services.quickshell-apps = {
enable = true;
apps = [
{
name = "Element";
cmd = "element-desktop";
}
{
name = "Nextcloud";
cmd = "nextcloud";
}
{
name = "Bitwarden";
cmd = "bitwarden";
}
];
};
networking.hostName = "x1carbon";
networking.modemmanager.enable = true;
# Laptop-specific hardware (fingerprint reader, fwupd)
my.hardware.laptop.enable = true;
# Charging cap at 80% via the ThinkPad's native sysfs thresholds
# (charge_control_start/end_threshold). The EC at 76% won't resume charging
# until it drops to/below the resume threshold (75) — expect the level to
# hover between ~76 and ~80 while "on". Despite the name, this is the
# standard ThinkPad behaviour; the QML gear toggle drives this via
# `battery-charge-limit on|off|status`.
my.hardware.thinkpadBatteryLimit.enable = true;
my.hardware.nvidia = {
enable = true;
nvidiaSettings = true;
package = config.boot.kernelPackages.nvidiaPackages.legacy_580;
};
my.hardware.tabletMode.enable = true;
programs.steam.enable = true;
services.hardware.bolt.enable = true;
# Establish trust for SSH to richmond-server (borg backup target, now using Backrest).
my.knownHosts.richmondServer = true;
# Borg Backup removed - migrated to Backrest (see hosts/homeserver-1/configuration.nix)
my.users.petere = {
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
subUidStart = 100000;
subGidStart = 100000;
};
networking.firewall = {
enable = true;
allowedTCPPorts = [
9756 # TeleportFling screen/audio streaming
];
allowedUDPPorts = [
9999 # TeleportFling multicast discovery
];
allowedTCPPortRanges = [
{
from = 1714;
to = 1764;
} # KDE Connect / GSConnect
{
from = 5960;
to = 6000;
} # NDI streams
];
allowedUDPPortRanges = [
{
from = 1714;
to = 1764;
} # KDE Connect / GSConnect discovery
{
from = 5960;
to = 6000;
} # NDI reliable UDP
];
};
boot.kernelModules = [
"sg"
"v4l2loopback"
];
boot.extraModulePackages = [ pkgs.linuxPackages.v4l2loopback ];
boot.extraModprobeConfig = ''
options v4l2loopback devices=1 video_nr=1 card_label="OBS Cam" exclusive_caps=1
'';
environment.systemPackages = with pkgs; [
openshot-qt
(
(wrapOBS.override {
obs-studio = obs-studio.override { cudaSupport = true; };
})
{
plugins = with obs-studio-plugins; [
distroav
obs-backgroundremoval
obs-teleport
];
}
)
vorta
xournalpp
winbox
steam
lmstudio
lm_sensors
gimp
scribus
ventoy
];
}
+43
View File
@@ -0,0 +1,43 @@
# /hosts/x1carbon/disko-config.nix
# This file will contain your disko configuration for x1carbon.
{
disk = {
nixos = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
swap = {
size = "20G";
type = "8200";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
}
+55
View File
@@ -0,0 +1,55 @@
# /hosts/x1carbon/hardware-configuration.nix
# This file will be generated by NixOS during installation or by 'nixos-generate-config'.
# It contains hardware-specific settings for x1carbon.
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
#hardware.ipu6.enable = true;
#hardware.ipu6.platform = "ipu6";
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"usb_storage"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
# fileSystems."/" =
# { device = "/dev/disk/by-uuid/882a76d6-c1ac-4efd-aff8-b56d43ec7ca5";
# fsType = "ext4";
# };
# fileSystems."/boot" =
# { device = "/dev/disk/by-uuid/EF30-EBA7";
# fsType = "vfat";
# options = [ "fmask=0077" "dmask=0077" ];
# };
# swapDevices =
# [ { device = "/dev/disk/by-uuid/1c488737-a2e7-4258-b0f3-bd1d14155d03"; }
# ];
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
# (the default) this is the recommended approach. When using systemd-networkd it's
# still possible to use this option, but it's recommended to use it in conjunction
# with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
networking.useDHCP = lib.mkDefault true;
# networking.interfaces.enp0s31f6.useDHCP = lib.mkDefault true;
# networking.interfaces.wlp0s20f3.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+55
View File
@@ -0,0 +1,55 @@
# /hosts/nixos/configuration.nix
{ config
, pkgs
, inputs
, lib
, ...
}:
{
imports = [
./hardware-configuration.nix
../../modules/core/common.nix
../../modules/core/management.nix
../../modules/desktop/gui.nix
(import ../../modules/storage/disko.nix {
inherit inputs lib config;
diskoConfigPath = ./disko-config.nix;
})
../../modules/desktop/gnome.nix
../../modules/desktop/apps/freeshow.nix
../../modules/desktop/apps/openlp.nix
];
sops.secrets = {
"users/petere-password" = {
neededForUsers = true;
};
"users/guest-password" = {
neededForUsers = true;
};
"opencode-api-key" = {
owner = "petere";
group = "users";
mode = "0440";
};
};
home-manager.users.petere.imports = [ ../../home-manager/modules/desktop-user.nix ];
networking.hostName = "x470"; # Define your hostname
my.users.petere = {
description = "Peter Edley";
hashedPasswordFile = config.sops.secrets."users/petere-password".path;
};
users.users.guest = {
isNormalUser = true;
hashedPasswordFile = config.sops.secrets."users/guest-password".path;
};
environment.systemPackages = with pkgs; [
winbox
];
}
+43
View File
@@ -0,0 +1,43 @@
# /hosts/x470/disko-config.nix
# This file will contain your disko configuration for x470.
{
disk = {
nixos = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # for grub MBR
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
swap = {
size = "20G";
type = "8200";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
};
}
+37
View File
@@ -0,0 +1,37 @@
# /hosts/x470/hardware-configuration.nix
# This file will be generated by NixOS during installation or by 'nixos-generate-config'.
# It contains hardware-specific settings for x470.
{ config
, lib
, pkgs
, modulesPath
, ...
}:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"usb_storage"
"sd_mod"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
# Enables DHCP on each ethernet and wireless interface. In case of scripted networking
# (the default) this is the recommended approach. When using systemd-networkd it's
# still possible to use this option, but it's recommended to use it in conjunction
# with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
networking.useDHCP = lib.mkDefault true;
# networking.interfaces.enp0s31f6.useDHCP = lib.mkDefault true;
# networking.interfaces.wlp4s0.useDHCP = lib.mkDefault true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+65
View File
@@ -0,0 +1,65 @@
# Nix-Vibe task runner (see AGENTS.md for the underlying workflows)
set shell := ["zsh", "-c"]
# Apply a host's NixOS config, then relaunch QuickShell so it loads the freshly
# built shell.qml (deployed to ~/.config/quickshell as a store symlink — the
# live-reload watcher misses the new path, so a manual restart is required).
# Usage: just [hostname] — defaults to x1carbon.
default host='x1carbon':
#!/usr/bin/env bash
set -euo pipefail
sudo nixos-rebuild switch --flake ".#{{host}}"
if pgrep -f "[H]yprland" >/dev/null 2>&1; then
# comm name is ".quickshell-wra" (kernel-truncated from .quickshell-wrapped);
# -x avoids matching this recipe's own argv.
pkill -x .quickshell-wra || true
sleep 1
QS=quic"kshell"
command "$QS" >/dev/null 2>&1 &
disown || true
echo "quickshell relaunched"
else
echo "no Hyprland session on this host; skipped quickshell restart"
fi
# ===== pre-commit workflow =====
# Format the tree (nixfmt, as declared by the flake formatter).
format:
nix fmt
# Stage new files (flake only evaluates git-tracked files), then flake check.
check:
git add -A
nix flake check
# ===== deployment helpers (no quickshell restart) =====
# Build and test a config without activating it.
# Usage: just dry-build [hostname]
dry-build host='x1carbon':
sudo nixos-rebuild dry-build --flake .#{{host}}
# Apply a config without switching (for servers / remote hosts).
# Usage: just test [hostname]
test host='x1carbon':
sudo nixos-rebuild test --flake .#{{host}}
# Relaunch QuickShell on the current Hyprland session (picks up a rebuilt
# shell.qml without a full deploy).
restart-qs:
#!/usr/bin/env bash
set -euo pipefail
if ! pgrep -f "[H]yprland" >/dev/null 2>&1; then
echo "not in a Hyprland session; nothing to restart" >&2
exit 1
fi
# comm name is ".quickshell-wra" (kernel-truncated from .quickshell-wrapped);
# -x avoids matching this recipe's own argv.
pkill -x .quickshell-wra || true
sleep 1
QS=quic"kshell"
command "$QS" >/dev/null 2>&1 &
disown || true
echo "quickshell relaunched"
+79
View File
@@ -0,0 +1,79 @@
{ config
, pkgs
, lib
, ...
}:
{
imports = [
./settings.nix
./fonts.nix
./sops.nix
./users.nix
];
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
programs.zsh.enable = true;
# SSH is key-only on all hosts. petere (the only SSH user) authenticates with
# an authorized key; GUI users keep their local password for console/GUI login
# but cannot use it over SSH. Root SSH is fully disabled.
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "no";
};
};
# Passwordless sudo for the admin/agent user only. Other wheel members
# (e.g. caitlin, mary) must enter their password for sudo.
security.sudo.extraRules = [
{
users = [ "petere" ];
commands = [
{
command = "ALL";
options = [ "NOPASSWD" ];
}
];
}
];
system.activationScripts.exportAgeKey = {
text = ''
if [ -f /root/.config/sops/age/keys.txt ]; then
${pkgs.age}/bin/age-keygen -y < /root/.config/sops/age/keys.txt > /tmp/age-public-key.txt
chmod 644 /tmp/age-public-key.txt
fi
'';
deps = [ ];
};
# Automatic Nix store garbage collection and optimisation to prevent disk
# creep. Runs every 2 hours; persistent ensures missed runs are caught up on boot.
nix.gc = {
automatic = true;
dates = "*-*-* 0/2:00:00";
persistent = true;
};
# Keep the last 5 system generations (regardless of age), then collect garbage.
# nix.gc.options only accepts nix-collect-garbage flags (age-based), so we
# override the service to add count-based generation pruning.
systemd.services.nix-gc.script = lib.mkForce ''
${pkgs.nix}/bin/nix-env --profile /nix/var/nix/profiles/system --delete-generations +5
exec ${pkgs.nix}/bin/nix-collect-garbage
'';
nix.optimise.automatic = true;
environment.systemPackages = with pkgs; [
# Antigravity removed - no longer used
];
system.stateVersion = "23.11";
}
+13
View File
@@ -0,0 +1,13 @@
{ pkgs, ... }:
{
# nixfmt is the standard Nix formatter (faster, stricter, future-proof).
# The flake formatter is also set to nixfmt.
environment.systemPackages = with pkgs; [
nixfmt
python3
flutter
uv
sops
];
}
+17
View File
@@ -0,0 +1,17 @@
{ config, pkgs, ... }:
{
fonts.fontconfig.enable = true;
fonts.fontDir.enable = true;
fonts.packages = with pkgs; [
fira-code
fira-code-symbols
nerd-fonts.fira-code
corefonts
vista-fonts
];
systemd.tmpfiles.rules = [
"L+ /usr/share/fonts - - - - /run/current-system/sw/share/X11/fonts"
];
}
+22
View File
@@ -0,0 +1,22 @@
{ config, lib, ... }:
let
cfg = config.my.knownHosts;
in
{
options.my.knownHosts = {
mcfServer = lib.mkEnableOption "mcf-server SSH host key";
richmondServer = lib.mkEnableOption "richmond-server SSH host key";
};
config = lib.mkMerge [
(lib.mkIf cfg.mcfServer {
services.openssh.knownHosts."mcf-server".publicKey =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOeOgD+GNQw5Isw/AumZcDFzdzO6YnKJEFWcuUcKPI2";
})
(lib.mkIf cfg.richmondServer {
services.openssh.knownHosts."richmond-server".publicKey =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEqDXL9w8QwUcqxtW3kyHW/LUDqCGqf6JQ3ZZw52vRQY";
})
];
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs, ... }:
{
# Common admin/monitoring tools available on every host.
environment.systemPackages = with pkgs; [
htop
bottom
iotop
ncdu
ripgrep
fd
jq
lsof
tmux
];
# Firmware updates for all machines (laptops AND servers).
services.fwupd.enable = true;
}
+17
View File
@@ -0,0 +1,17 @@
{ pkgs, config, ... }:
{
virtualisation.podman = {
enable = true;
dockerCompat = true;
dockerSocket.enable = true;
defaultNetwork.settings.dns_enabled = false;
};
virtualisation.oci-containers.backend = "podman";
environment.systemPackages = with pkgs; [
podman-compose
];
}
+34
View File
@@ -0,0 +1,34 @@
{ config
, pkgs
, lib
, ...
}:
{
# Set your time zone
time.timeZone = "Europe/London";
# Configure console keyboard layout
console.keyMap = "uk";
# Configure X server keyboard layout if X server is enabled
services.xserver.xkb.layout = lib.mkIf config.services.xserver.enable "gb";
services.tailscale.enable = true;
users.mutableUsers = false;
nix.settings = {
accept-flake-config = true;
trusted-users = [ "petere" ];
experimental-features = [
"nix-command"
"flakes"
];
};
environment.systemPackages = with pkgs; [
# gemini-cli
kitty.terminfo
];
}
+20
View File
@@ -0,0 +1,20 @@
{ config
, pkgs
, inputs
, ...
}:
{
sops = {
age = {
keyFile = "/root/.config/sops/age/keys.txt";
generateKey = false;
};
defaultSopsFile = ../../secrets.yaml;
secrets."gemini-api-key" = {
owner = "petere";
group = "users";
mode = "0440";
};
};
}
+67
View File
@@ -0,0 +1,67 @@
{ config
, pkgs
, lib
, ...
}:
let
cfg = config.my.users.petere;
in
{
options.my.users.petere = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to create the petere admin user.";
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional GECOS description for petere.";
};
hashedPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Path to the hashed password file, or null for SSH-key-only access.";
};
subUidStart = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Start UID for petere's rootless subuid range, or null to disable.";
};
subGidStart = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = "Start GID for petere's rootless subgid range, or null to disable.";
};
};
config = lib.mkIf cfg.enable {
users.users.petere = {
isNormalUser = true;
shell = pkgs.zsh;
extraGroups = [ "wheel" ];
description = lib.mkIf (cfg.description != null) cfg.description;
hashedPasswordFile = lib.mkIf (cfg.hashedPasswordFile != null) cfg.hashedPasswordFile;
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJiCtkYDBfieK3i4TbVomeyXa185yCFZUvrbMamR4bqs petere@x1carbon"
];
subUidRanges = lib.mkIf (cfg.subUidStart != null) [
{
startUid = cfg.subUidStart;
count = 65536;
}
];
subGidRanges = lib.mkIf (cfg.subGidStart != null) [
{
startGid = cfg.subGidStart;
count = 65536;
}
];
};
};
}
+8
View File
@@ -0,0 +1,8 @@
{ pkgs, ... }:
# Carla — audio plugin host / JACK & PipeWire patchbay
{
environment.systemPackages = [
pkgs.carla
];
}
+12
View File
@@ -0,0 +1,12 @@
{ pkgs, ... }:
# DVD authoring and burning tools for mcf-stream
{
environment.systemPackages = with pkgs; [
brasero # GNOME disc burning (data & video DVD)
dvdstyler # DVD authoring with menus
dvdauthor # CLI tooling for authoring DVD video
libdvdcss # Play/rip encrypted DVDs
gnome-multi-writer # USB/optical image writer
];
}
+42
View File
@@ -0,0 +1,42 @@
{ config, pkgs, ... }:
let
freeshowAppImage = pkgs.stdenv.mkDerivation {
pname = "freeshow-appimage";
version = "1.5.6";
src = pkgs.fetchurl {
url = "https://github.com/ChurchApps/FreeShow/releases/download/v1.5.6/FreeShow-1.5.6-x86_64.AppImage";
sha256 = "10z1xgqzwfbs4api12myyx368yp8ahzbmjpfmwnh8c340f8b22f4";
};
dontUnpack = true;
dontBuild = true;
installPhase = ''
mkdir -p $out/bin
ln -s ${pkgs.appimage-run}/bin/appimage-run $out/bin/freeshow
mkdir -p $out/share/applications
cat > $out/share/applications/freeshow.desktop << EOF
[Desktop Entry]
Name=FreeShow
Exec=${pkgs.appimage-run}/bin/appimage-run $src
Icon=freeshow
Type=Application
Categories=Multimedia;
EOF
'';
meta = with pkgs.lib; {
description = "FreeShow AppImage wrapper";
homepage = "https://github.com/ChurchApps/FreeShow";
license = licenses.gpl3Only; # Assuming GPLv3 based on GitHub repo
platforms = platforms.linux;
};
};
in
{
environment.systemPackages = [
freeshowAppImage
];
}
+66
View File
@@ -0,0 +1,66 @@
{ pkgs, ... }:
let
mixingStation = pkgs.stdenv.mkDerivation rec {
pname = "mixing-station";
version = "3.1.4";
src = pkgs.fetchurl {
url = "https://mixingstation.app/backend/api/web/download/update/mixing-station-pc/release";
sha256 = "sha256-8d+rGfcOMz8ZoaTy4wd9SEExyc/IaUDysf1WC8xyTC0=";
};
nativeBuildInputs = [
pkgs.unzip
pkgs.makeWrapper
];
buildInputs = with pkgs; [
libGL
libx11
libxext
libxcursor
libxrandr
libxxf86vm
libxi
libpulseaudio
];
unpackPhase = ''
unzip $src
'';
installPhase = ''
mkdir -p $out/bin
mkdir -p $out/share/mixing-station
cp -r * $out/share/mixing-station/
makeWrapper ${pkgs.openjdk21}/bin/java $out/bin/mixing-station \
--add-flags "-jar $out/share/mixing-station/mixing-station-desktop.jar" \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath buildInputs}
mkdir -p $out/share/applications
cat > $out/share/applications/mixing-station.desktop << EOF
[Desktop Entry]
Name=Mixing Station
Exec=$out/bin/mixing-station
Icon=audio-mixer
Type=Application
Categories=AudioVideo;Audio;
Comment=Remote control for digital mixers
EOF
'';
meta = with pkgs.lib; {
description = "Remote control for digital mixers";
homepage = "https://mixingstation.app/";
license = licenses.unfree;
platforms = platforms.linux;
};
};
in
{
environment.systemPackages = [
mixingStation
];
}
+20
View File
@@ -0,0 +1,20 @@
{ pkgs, ... }:
# OBS Studio for live streaming/recording on mcf-stream
{
environment.systemPackages = [
(
(pkgs.wrapOBS.override {
# Enable NVIDIA NVENC GPU encoding
obs-studio = pkgs.obs-studio.override { cudaSupport = true; };
})
{
plugins = with pkgs.obs-studio-plugins; [
distroav
obs-backgroundremoval
obs-teleport
];
}
)
];
}
+8
View File
@@ -0,0 +1,8 @@
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [
opencode
opencode-desktop
];
}
+42
View File
@@ -0,0 +1,42 @@
{ pkgs, ... }:
{
# Install OpenLP from official flatpak bundle
# Update the version in the URL when new releases are available
systemd.services.openlp-flatpak-install = {
description = "Install OpenLP from official flatpak bundle";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "install-openlp" ''
set -e
OPENLP_VERSION="3.1.7"
OPENLP_URL="https://get.openlp.org/$OPENLP_VERSION/openlp-$OPENLP_VERSION-1.flatpak"
OPENLP_ID="org.openlp.OpenLP"
# Check if already installed
if ${pkgs.flatpak}/bin/flatpak list --app | grep -q "$OPENLP_ID"; then
echo "OpenLP is already installed"
exit 0
fi
# Download and install the bundle
echo "Downloading OpenLP $OPENLP_VERSION..."
TEMP_FILE=$(${pkgs.coreutils}/bin/mktemp)
${pkgs.curl}/bin/curl -L -o "$TEMP_FILE" "$OPENLP_URL"
echo "Installing OpenLP from bundle..."
${pkgs.flatpak}/bin/flatpak install --system --bundle --noninteractive -y "$TEMP_FILE"
# Cleanup
${pkgs.coreutils}/bin/rm -f "$TEMP_FILE"
echo "OpenLP installed successfully"
'';
};
};
}
+5
View File
@@ -0,0 +1,5 @@
{
services.flatpak.packages = [
"io.github.Soundux"
];
}

Some files were not shown because too many files have changed in this diff Show More