diff --git a/flake-modules/distant-shore.nix b/flake-modules/distant-shore.nix new file mode 100644 index 0000000..6c5a023 --- /dev/null +++ b/flake-modules/distant-shore.nix @@ -0,0 +1,14 @@ +# Standalone nixosSystem registration for distant-shore. +# Temporary: clan integration (zerotier/data-mesher/dm-pull-deploy) needs +# vars generated via sops on the admin machine. Until that runs, this +# keeps the box buildable without clan deps. Delete this file when +# distant-shore moves into flake-modules/clan.nix. +{ inputs, ... }: { + flake.nixosConfigurations.distant-shore = inputs.nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + inputs.disko.nixosModules.disko + ../nixos/hosts/distant-shore.nix + ]; + }; +} diff --git a/nixos/disko-distant-shore.nix b/nixos/disko-distant-shore.nix new file mode 100644 index 0000000..ab35aac --- /dev/null +++ b/nixos/disko-distant-shore.nix @@ -0,0 +1,37 @@ +# Declarative disk layout for distant-shore (ThinkPad X13 Gen 2 — 256 GB +# SK Hynix NVMe). UEFI/systemd-boot, no encryption: it's a headless, +# WiFi-only server that must reboot unattended (clan dm-pull-deploy), so +# a LUKS passphrase prompt at boot would hang it. Mirrors sunken-ship's +# plain-ext4 choice. Device is wiped + repartitioned at install time by +# clan/nixos-anywhere. +{ + disko.devices = { + disk.main = { + type = "disk"; + device = "/dev/nvme0n1"; + content = { + type = "gpt"; + partitions = { + ESP = { + size = "512M"; + type = "EF00"; + content = { + type = "filesystem"; + format = "vfat"; + mountpoint = "/boot"; + mountOptions = [ "fmask=0022" "dmask=0022" ]; + }; + }; + root = { + size = "100%"; + content = { + type = "filesystem"; + format = "ext4"; + mountpoint = "/"; + }; + }; + }; + }; + }; + }; +} diff --git a/nixos/hosts/distant-shore-hardware.nix b/nixos/hosts/distant-shore-hardware.nix new file mode 100644 index 0000000..3c52633 --- /dev/null +++ b/nixos/hosts/distant-shore-hardware.nix @@ -0,0 +1,18 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ config, lib, pkgs, modulesPath, ... }: + +{ + imports = + [ (modulesPath + "/installer/scan/not-detected.nix") + ]; + + boot.initrd.availableKernelModules = [ "xhci_pci" "thunderbolt" "nvme" ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ "kvm-intel" ]; + boot.extraModulePackages = [ ]; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; +} diff --git a/nixos/hosts/distant-shore.nix b/nixos/hosts/distant-shore.nix new file mode 100644 index 0000000..33a7026 --- /dev/null +++ b/nixos/hosts/distant-shore.nix @@ -0,0 +1,114 @@ +# NixOS server on a ThinkPad X13 Gen 2 (Intel i5-1145G7, 16 GB). +# WiFi-only, headless, unattended auto-rebuild via clan dm-pull-deploy. +# No LUKS (mirrors sunken-ship) so reboots don't block on a passphrase. +# +# Blank-slate server for now — no application services. Give it a purpose +# later (just add services here and let dm-pull-deploy roll it out). +{ config, lib, pkgs, ... }: + +{ + imports = [ + ./distant-shore-hardware.nix + ../disko-distant-shore.nix + ]; + + boot.loader.systemd-boot.enable = true; + # Secure Boot is enforced and the BIOS supervisor password is unknown, so + # we can't enrol our own SB keys. Instead, shim (MS-signed) is placed on + # the ESP and chain-loads systemd-boot; the NVRAM boot entry points at + # shim. We manage that entry imperatively via efibootmgr; letting bootctl + # touch EFI variables would replace it on every rebuild. + boot.loader.efi.canTouchEfiVariables = false; + boot.loader.efi.efiSysMountPoint = "/boot"; # matches disko ESP mountpoint + + # --- Secure Boot via shim + MOK (no firmware key enrolment possible) ------ + # The firmware trusts Microsoft-signed shim; shim trusts our enrolled MOK. + # On every bootloader install we: (1) sign systemd-boot with the MOK and + # drop it where shim chain-loads it (grubx64.efi), (2) install shim as the + # firmware-booted binary (+ MokManager), (3) MOK-sign every kernel image + # systemd-boot will hand off to (shim verifies them via the shim-lock + # protocol). Re-runs on each nixos-rebuild so auto-deployed generations + # stay bootable. Keys + shim live in /etc/secrets (outside the repo). + boot.loader.systemd-boot.extraInstallCommands = '' + # NixOS's bootloader-install systemd unit runs with a minimal PATH that + # doesn't include coreutils, so use absolute paths for cp/mv. + KEY=/etc/secrets/MOK.key + CRT=/etc/secrets/MOK.crt + sb() { ${pkgs.sbsigntool}/bin/sbsign --key "$KEY" --cert "$CRT" --output "$2" "$1"; } + # systemd-boot -> shim's chain-load target + sb /boot/EFI/systemd/systemd-bootx64.efi /boot/EFI/BOOT/grubx64.efi + # shim (MS-signed) is what the firmware boots; MokManager beside it + ${pkgs.coreutils}/bin/cp -f /etc/secrets/shimx64.efi /boot/EFI/BOOT/BOOTX64.EFI + ${pkgs.coreutils}/bin/cp -f /etc/secrets/mmx64.efi /boot/EFI/BOOT/mmx64.efi + # MOK-sign each kernel (skip already-signed; never touch initrds) + for k in /boot/EFI/nixos/*bzImage.efi; do + [ -e "$k" ] || continue + if ! ${pkgs.sbsigntool}/bin/sbverify --cert "$CRT" "$k" >/dev/null 2>&1; then + ${pkgs.sbsigntool}/bin/sbsign --key "$KEY" --cert "$CRT" --output "$k.tmp" "$k" \ + && ${pkgs.coreutils}/bin/mv -f "$k.tmp" "$k" + fi + done + ''; + + networking.hostName = "distant-shore"; + # WiFi via NetworkManager. The wpa_supplicant stack hit two issues on this + # box: (1) it strips CAP_CHOWN so wpa couldn't create its ctrl_interface, + # and (2) dhcpcd didn't grab a lease after the (late) association at boot, + # needing a manual restart — fatal for an unattended headless server. NM + # handles association + DHCP atomically and connected first-try here. + # The PSK stays out of the repo: it's substituted from /etc/secrets/nm.env + # ($PSK_INTENO) into the declared profile at activation. + networking.networkmanager.enable = true; + networking.networkmanager.ensureProfiles.environmentFiles = [ "/etc/secrets/nm.env" ]; + networking.networkmanager.ensureProfiles.profiles."Inteno-89FE" = { + connection = { id = "Inteno-89FE"; type = "wifi"; autoconnect = true; }; + wifi = { ssid = "Inteno-89FE"; mode = "infrastructure"; }; + wifi-security = { key-mgmt = "wpa-psk"; psk = "$PSK_INTENO"; }; + ipv4.method = "auto"; + ipv6.method = "auto"; + }; + hardware.enableRedistributableFirmware = true; # iwlwifi for the Intel AX201 WiFi + time.timeZone = "Europe/Copenhagen"; + + # It's a laptop acting as a server: keep running with the lid shut. + services.logind.settings.Login.HandleLidSwitch = "ignore"; + services.logind.settings.Login.HandleLidSwitchExternalPower = "ignore"; + + # Reduce screen burn-in / power: blank the TTY after a minute. + boot.kernelParams = [ "consoleblank=60" ]; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + programs.nix-ld.enable = true; # run dynamically linked binaries (e.g. Claude Code remote CLI) + nix.settings.trusted-users = [ "root" "danny" ]; + system.stateVersion = "25.11"; + + users.users.danny = { + isNormalUser = true; + extraGroups = [ "wheel" "video" "audio" ]; + openssh.authorizedKeys.keys = [ + # Mac admin / fleet key (~/.ssh/id_ed25519_sunken_ship) — the key the + # Mac uses to reach the fleet; clan machines update relies on it. + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKW/akfIiVU5o63YrTAJVZhMj7kXfYHOnXDtlpVFW7pf danny@mac-admin" + # Per-host key (~/.ssh/id_ed25519_distant_shore) — plain `ssh distant-shore`. + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH61JOiOOPrAXekakAwTJg5yCSDfOIjlSvMYkpXrarAf distant-shore" + # sunken-ship (dm-pull-deploy push node) — reach distant-shore over ZT. + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9t4YAaoHvVouqp+qyFOq8o3SAtXMiAmjF6J0ldyx4g danny@sunken-ship" + ]; + }; + users.users.root.openssh.authorizedKeys.keys = + config.users.users.danny.openssh.authorizedKeys.keys; + + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + KbdInteractiveAuthentication = false; + }; + }; + + security.sudo.wheelNeedsPassword = false; + + # mokutil — manage MOK enrolment for the shim chain; sbsigntool — inspect + # signatures on bootloader/kernel images when debugging. + environment.systemPackages = with pkgs; [ git mokutil sbsigntool ]; +} diff --git a/nixos/hosts/phantom-ship.nix b/nixos/hosts/phantom-ship.nix index f232e63..1386fd3 100644 --- a/nixos/hosts/phantom-ship.nix +++ b/nixos/hosts/phantom-ship.nix @@ -241,18 +241,37 @@ in # Code deployed out-of-band via rsync to /home/danny/shipyard/ # (staying in-tree in ~/python-projects/26_shipyard/ until spun out to its own repo). # Bot token (not in repo): ~danny/.secrets/telegram-bot-token-shipyard - # Data (feedback.jsonl, pointer cache): ~danny/.local/share/shipyard/ + # Data (feedback.jsonl, feedback.db, pointer cache, feedback_media/): + # ~danny/.local/share/shipyard/ + # + # Feedback now accepts photos / voice / video / docs / stickers etc. + # Phase A captures + stores raw files; Phase B derives OCR text + # (tesseract), speech transcripts (whisper-cpp), poster frames + # (ffmpeg) and PDF text (pdftotext) — all via subprocess, so each + # tool degrades gracefully if missing. systemd.services.shipyard = let pythonEnv = pkgs.python3.withPackages (ps: with ps; [ python-telegram-bot httpx + pillow # EXIF strip on captured photos ]); + # tesseract with English + Russian tessdata — vyscul writes in + # Russian, screenshots can land in either language. + tesseractWithLangs = pkgs.tesseract.override { + enableLanguages = [ "eng" "rus" ]; + }; in { description = "Shipyard Telegram bot (mini-app launcher + feedback)"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; - path = [ pythonEnv ]; + path = [ + pythonEnv + pkgs.ffmpeg # video/animation posters, sticker decode + tesseractWithLangs # photo OCR + pkgs.whisper-cpp # voice/audio transcription + pkgs.poppler_utils # pdftotext (document handling) + ]; environment = { SHIPYARD_BOT_TOKEN_FILE = "/home/danny/.secrets/telegram-bot-token-shipyard"; # Owner-only commands (/admin, /grant, /revoke) — anyone else gets ignored.