diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..12a636e06 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,59 @@ +name: Python +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true +jobs: + python-test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + - name: Run tests + env: + PYTHONPATH: ${{ github.workspace }} + run: uv run --with pytest --no-project pytest tests + python-lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + - name: Install ruff + run: uv pip install --system ruff + - name: Lint code with Ruff + run: ruff check --output-format=github + - name: Check code formatting with Ruff + run: ruff format --check --diff + python-check: + if: always() + needs: + - python-test + - python-lint + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Alls Green + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/Makefile b/Makefile index beba21215..02e887dd1 100644 --- a/Makefile +++ b/Makefile @@ -192,7 +192,7 @@ dotagents-sync: ## Sync dotagents (commands, skills, MCP configuration). @$(MAKE) -C dotagents sync .PHONY: test -test: neovim-test nix-test shell-test ## Run all tests (neovim + nix + shell). +test: neovim-test nix-test shell-test python-test nix-inline-check ## Run all tests (neovim + nix + shell + python + nix-inline-check). ##@ Update @@ -876,6 +876,29 @@ shell-check-dev: ## Run ShellCheck inside the Nix dev shell (mirrors CI). .PHONY: shell-lint shell-lint: shell-check ## Lint shell scripts (alias for shell-check). +.PHONY: nix-inline-check +nix-inline-check: ## Fail if any .nix file contains inline write*Script* strings. + @echo "๐Ÿ” Checking for inline scripts in Nix files..." + @bash scripts/check-nix-inline-scripts.sh + +##@ Python + +.PHONY: python-test +python-test: ## Run Python tests with pytest. + @echo "๐Ÿงช Running Python tests..." + @uv run --with pytest --no-project pytest tests + +.PHONY: python-test-dev +python-test-dev: ## Run Python tests inside the Nix dev shell (mirrors CI). + @echo "๐Ÿงช Running Python tests inside the Nix dev shell..." + @DEVENV_ROOT=$(CURDIR) $(NIX_ALLOW_UNFREE) $(NIX_EXEC) develop $(NIX_FLAGS) .# --command $(MAKE) python-test + +.PHONY: python-lint +python-lint: ## Lint Python files with Ruff. + @echo "๐Ÿ” Linting Python files with Ruff..." + @uv run --with ruff --no-project ruff check + @uv run --with ruff --no-project ruff format --check --diff + ##@ Nix Tests .PHONY: nix-test diff --git a/devenv.nix b/devenv.nix index 1c0d056ec..0068bc6cb 100644 --- a/devenv.nix +++ b/devenv.nix @@ -10,12 +10,15 @@ pkgs.gnumake pkgs.gcc pkgs.fish - (pkgs.writeShellScriptBin "fishtape" '' - exec ${pkgs.fish}/bin/fish \ - -C "source ${pkgs.fishPlugins.fishtape_3.src}/functions/fishtape.fish" \ - -c 'fishtape $argv' \ - -- "$@" - '') + pkgs.statix + (pkgs.writeShellScriptBin "fishtape" ( + builtins.readFile ( + pkgs.replaceVars ./scripts/fishtape-wrapper.sh { + fish = pkgs.fish; + fishtape_3_src = pkgs.fishPlugins.fishtape_3.src; + } + ) + )) ]; containers = pkgs.lib.mkIf (!pkgs.stdenv.hostPlatform.isLinux) (pkgs.lib.mkForce { }); diff --git a/home-manager/modules/yek/default.nix b/home-manager/modules/yek/default.nix index 8d6b365a6..d951ac187 100644 --- a/home-manager/modules/yek/default.nix +++ b/home-manager/modules/yek/default.nix @@ -24,10 +24,14 @@ let }; # Create install-yek as a standalone script - installScript = pkgs.writeScriptBin "install-yek" '' - #!${pkgs.bash}/bin/bash - exec ${pkgs.bash}/bin/bash ${installYekScript} "$@" - ''; + installScript = pkgs.writeScriptBin "install-yek" ( + builtins.readFile ( + pkgs.replaceVars ./install-yek-shim.sh { + bash = pkgs.bash; + install_yek_script = installYekScript; + } + ) + ); # Wrapper script with install-yek path substituted yekWrapperScript = pkgs.replaceVars ./yek.sh { @@ -35,10 +39,14 @@ let }; # Create yek wrapper as a standalone script - yekWrapper = pkgs.writeScriptBin "yek" '' - #!${pkgs.bash}/bin/bash - exec ${pkgs.bash}/bin/bash ${yekWrapperScript} "$@" - ''; + yekWrapper = pkgs.writeScriptBin "yek" ( + builtins.readFile ( + pkgs.replaceVars ./yek-shim.sh { + bash = pkgs.bash; + yek_wrapper_script = yekWrapperScript; + } + ) + ); in { home.packages = [ diff --git a/home-manager/modules/yek/install-yek-shim.sh b/home-manager/modules/yek/install-yek-shim.sh new file mode 100644 index 000000000..fd3b32b7e --- /dev/null +++ b/home-manager/modules/yek/install-yek-shim.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Thin shim that delegates to the Nix-store install-yek script. +# @bash@ and @install_yek_script@ are substituted by pkgs.replaceVars. +exec @bash@/bin/bash @install_yek_script@ "$@" diff --git a/home-manager/modules/yek/yek-shim.sh b/home-manager/modules/yek/yek-shim.sh new file mode 100644 index 000000000..c9b61976c --- /dev/null +++ b/home-manager/modules/yek/yek-shim.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Thin shim that delegates to the Nix-store yek wrapper script. +# @bash@ and @yek_wrapper_script@ are substituted by pkgs.replaceVars. +exec @bash@/bin/bash @yek_wrapper_script@ "$@" diff --git a/home-manager/services/cliproxyapi/default.nix b/home-manager/services/cliproxyapi/default.nix index dd8974421..944fb79fa 100644 --- a/home-manager/services/cliproxyapi/default.nix +++ b/home-manager/services/cliproxyapi/default.nix @@ -30,24 +30,15 @@ let # Smart wrapper that handles both NixOS and non-NixOS Linux # On NixOS: docker group is properly inherited, or use /run/wrappers/bin/sg # On non-NixOS: systemd user session may lack docker group, use /usr/bin/sg - dockerStartScript = pkgs.writeShellScript "cliproxyapi-docker-start" '' - SCRIPT="${pkgs.bash}/bin/bash ${startScript}" - - # Try docker directly first (works on NixOS or when user has docker group) - if ${pkgs.docker}/bin/docker info >/dev/null 2>&1; then - exec $SCRIPT - fi - - # Docker not accessible directly, try sg to switch group - if [ -x /run/wrappers/bin/sg ]; then - exec /run/wrappers/bin/sg docker -c "$SCRIPT" - elif [ -x /usr/bin/sg ]; then - exec /usr/bin/sg docker -c "$SCRIPT" - else - echo "ERROR: Cannot access Docker. User not in docker group and no sg binary found." >&2 - exit 1 - fi - ''; + dockerStartScript = pkgs.writeShellScript "cliproxyapi-docker-start" ( + builtins.readFile ( + pkgs.replaceVars ./scripts/docker-start.sh { + bash = pkgs.bash; + start_script = startScript; + docker = pkgs.docker; + } + ) + ); wrapperScript = pkgs.replaceVars ./scripts/wrapper.sh { common = commonScript; diff --git a/home-manager/services/cliproxyapi/scripts/docker-start.sh b/home-manager/services/cliproxyapi/scripts/docker-start.sh new file mode 100644 index 000000000..2b6a037b0 --- /dev/null +++ b/home-manager/services/cliproxyapi/scripts/docker-start.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Smart wrapper that handles both NixOS and non-NixOS Linux. +# On NixOS: docker group is properly inherited, or use /run/wrappers/bin/sg. +# On non-NixOS: systemd user session may lack docker group, use /usr/bin/sg. +# @bash@, @start_script@, @docker@ are substituted by pkgs.replaceVars. +SCRIPT="@bash@/bin/bash @start_script@" + +# Try docker directly first (works on NixOS or when user has docker group) +if @docker@/bin/docker info >/dev/null 2>&1; then + exec $SCRIPT +fi + +# Docker not accessible directly, try sg to switch group +if [ -x /run/wrappers/bin/sg ]; then + exec /run/wrappers/bin/sg docker -c "$SCRIPT" +elif [ -x /usr/bin/sg ]; then + exec /usr/bin/sg docker -c "$SCRIPT" +else + echo "ERROR: Cannot access Docker. User not in docker group and no sg binary found." >&2 + exit 1 +fi diff --git a/home-manager/services/docker-postgres/default.nix b/home-manager/services/docker-postgres/default.nix index 5a10f2b4f..58f1e478b 100644 --- a/home-manager/services/docker-postgres/default.nix +++ b/home-manager/services/docker-postgres/default.nix @@ -6,26 +6,15 @@ let # Smart wrapper that handles both NixOS and non-NixOS Linux # On NixOS: docker group is properly inherited, or use /run/wrappers/bin/sg # On non-NixOS: systemd user session may lack docker group, use /usr/bin/sg - startPostgresWrapper = pkgs.writeShellScript "start-postgres-wrapper" '' - SCRIPT="${pkgs.bash}/bin/bash ${startScript}" - - # Try docker directly first (works on NixOS or when user has docker group) - if ${pkgs.docker}/bin/docker info >/dev/null 2>&1; then - exec $SCRIPT - fi - - # Docker not accessible directly, try sg to switch group - # NixOS: /run/wrappers/bin/sg (SUID wrapper) - # Non-NixOS: /usr/bin/sg (system binary with SUID) - if [ -x /run/wrappers/bin/sg ]; then - exec /run/wrappers/bin/sg docker -c "$SCRIPT" - elif [ -x /usr/bin/sg ]; then - exec /usr/bin/sg docker -c "$SCRIPT" - else - echo "ERROR: Cannot access Docker. User not in docker group and no sg binary found." >&2 - exit 1 - fi - ''; + startPostgresWrapper = pkgs.writeShellScript "start-postgres-wrapper" ( + builtins.readFile ( + pkgs.replaceVars ./start-postgres-wrapper.sh { + bash = pkgs.bash; + start_script = startScript; + docker = pkgs.docker; + } + ) + ); in { launchd.agents.docker-postgres = lib.mkIf pkgs.stdenv.isDarwin { diff --git a/home-manager/services/docker-postgres/start-postgres-wrapper.sh b/home-manager/services/docker-postgres/start-postgres-wrapper.sh new file mode 100644 index 000000000..d65fe9ea4 --- /dev/null +++ b/home-manager/services/docker-postgres/start-postgres-wrapper.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Smart wrapper that handles both NixOS and non-NixOS Linux. +# On NixOS: docker group is properly inherited, or use /run/wrappers/bin/sg (SUID wrapper). +# On non-NixOS: systemd user session may lack docker group, use /usr/bin/sg (system binary with SUID). +# @bash@, @start_script@, @docker@ are substituted by pkgs.replaceVars. +SCRIPT="@bash@/bin/bash @start_script@" + +# Try docker directly first (works on NixOS or when user has docker group) +if @docker@/bin/docker info >/dev/null 2>&1; then + exec $SCRIPT +fi + +# Docker not accessible directly, try sg to switch group +if [ -x /run/wrappers/bin/sg ]; then + exec /run/wrappers/bin/sg docker -c "$SCRIPT" +elif [ -x /usr/bin/sg ]; then + exec /usr/bin/sg docker -c "$SCRIPT" +else + echo "ERROR: Cannot access Docker. User not in docker group and no sg binary found." >&2 + exit 1 +fi diff --git a/home-manager/services/docker/default.nix b/home-manager/services/docker/default.nix index f563a6557..08635ad30 100644 --- a/home-manager/services/docker/default.nix +++ b/home-manager/services/docker/default.nix @@ -1,64 +1,37 @@ { pkgs, lib, ... }: let # Systemd service file for Docker daemon - dockerServiceFile = pkgs.writeText "docker.service" '' - [Unit] - Description=Docker Application Container Engine - Documentation=https://docs.docker.com - After=network-online.target - Wants=network-online.target - - [Service] - Type=notify - ExecStart=${pkgs.docker}/bin/dockerd - ExecReload=${pkgs.coreutils}/bin/kill -s HUP $MAINPID - Restart=always - RestartSec=10s - - [Install] - WantedBy=multi-user.target - ''; + dockerServiceFile = pkgs.writeText "docker.service" ( + builtins.readFile ( + pkgs.replaceVars ./docker.service { + docker = pkgs.docker; + coreutils = pkgs.coreutils; + } + ) + ); # Script to ensure user is in docker group and system docker is running - setupDockerScript = pkgs.writeShellScript "setup-docker" '' - set -euo pipefail - - # Define paths - GROUPS=${pkgs.shadow}/bin/groups - GREP=${pkgs.gnugrep}/bin/grep - USERMOD=${pkgs.shadow}/bin/usermod - SYSTEMCTL=${pkgs.systemd}/bin/systemctl - TEE=${pkgs.coreutils}/bin/tee - DOCKER_SERVICE_FILE=${dockerServiceFile} - - # Check if docker group exists and user is in it - if ! $GROUPS | $GREP -q docker; then - echo "Adding user to docker group..." - sudo $USERMOD -aG docker $USER - echo "โœ… Added to docker group. Please log out and back in, or run: newgrp docker" - fi - - # Check if system docker service exists and is running - if ! $SYSTEMCTL is-active --quiet docker 2>/dev/null; then - echo "Starting Docker daemon..." - if [ ! -f /etc/systemd/system/docker.service ]; then - echo "Installing Docker systemd service..." - sudo $TEE /etc/systemd/system/docker.service > /dev/null < "$DOCKER_SERVICE_FILE" - sudo $SYSTEMCTL daemon-reload - sudo $SYSTEMCTL enable docker - fi - sudo $SYSTEMCTL start docker - echo "โœ… Docker daemon started" - else - echo "โœ… Docker daemon is already running" - fi - ''; + setupDockerScript = pkgs.writeShellScript "setup-docker" ( + builtins.readFile ( + pkgs.replaceVars ./setup-docker.sh { + shadow = pkgs.shadow; + gnugrep = pkgs.gnugrep; + systemd = pkgs.systemd; + coreutils = pkgs.coreutils; + docker_service_file = dockerServiceFile; + } + ) + ); in { # Provide setup script for system Docker home.packages = lib.mkIf pkgs.stdenv.isLinux [ - (pkgs.writeShellScriptBin "docker-setup" '' - exec ${setupDockerScript} - '') + (pkgs.writeShellScriptBin "docker-setup" ( + builtins.readFile ( + pkgs.replaceVars ./docker-setup.sh { + setup_docker_script = setupDockerScript; + } + ) + )) ]; } diff --git a/home-manager/services/docker/docker-setup.sh b/home-manager/services/docker/docker-setup.sh new file mode 100644 index 000000000..28112b6da --- /dev/null +++ b/home-manager/services/docker/docker-setup.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Thin wrapper exposing setup-docker as a user-facing command. +# @setup_docker_script@ is substituted by pkgs.replaceVars. +exec @setup_docker_script@ diff --git a/home-manager/services/docker/docker.service b/home-manager/services/docker/docker.service new file mode 100644 index 000000000..f7cda8475 --- /dev/null +++ b/home-manager/services/docker/docker.service @@ -0,0 +1,15 @@ +[Unit] +Description=Docker Application Container Engine +Documentation=https://docs.docker.com +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +ExecStart=@docker@/bin/dockerd +ExecReload=@coreutils@/bin/kill -s HUP $MAINPID +Restart=always +RestartSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/home-manager/services/docker/setup-docker.sh b/home-manager/services/docker/setup-docker.sh new file mode 100644 index 000000000..27366af98 --- /dev/null +++ b/home-manager/services/docker/setup-docker.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Ensures the user is in the docker group and the system Docker daemon is running. +# @shadow@, @gnugrep@, @systemd@, @coreutils@, @docker_service_file@ +# are substituted by pkgs.replaceVars. +set -euo pipefail + +# Define paths +GROUPS_CMD=@shadow@/bin/groups +GREP=@gnugrep@/bin/grep +USERMOD=@shadow@/bin/usermod +SYSTEMCTL=@systemd@/bin/systemctl +TEE=@coreutils@/bin/tee +DOCKER_SERVICE_FILE=@docker_service_file@ + +# Check if docker group exists and user is in it +if ! "$GROUPS_CMD" | "$GREP" -q docker; then + echo "Adding user to docker group..." + sudo "$USERMOD" -aG docker "$USER" + echo "Added to docker group. Please log out and back in, or run: newgrp docker" +fi + +# Check if system docker service exists and is running +if ! "$SYSTEMCTL" is-active --quiet docker 2>/dev/null; then + echo "Starting Docker daemon..." + if [ ! -f /etc/systemd/system/docker.service ]; then + echo "Installing Docker systemd service..." + # shellcheck disable=SC2024 + sudo "$TEE" /etc/systemd/system/docker.service >/dev/null <"$DOCKER_SERVICE_FILE" + sudo "$SYSTEMCTL" daemon-reload + sudo "$SYSTEMCTL" enable docker + fi + sudo "$SYSTEMCTL" start docker + echo "Docker daemon started" +else + echo "Docker daemon is already running" +fi diff --git a/named-hosts/matic/default.nix b/named-hosts/matic/default.nix index c16c267f6..c135737ac 100644 --- a/named-hosts/matic/default.nix +++ b/named-hosts/matic/default.nix @@ -153,93 +153,29 @@ inputs.nixpkgs.lib.nixosSystem { # 3. send [oplen:4][op=1:4][pwlen:4][password bytes] # where oplen = 8 + 4 + len(password) # 4. read [8:4][result:4] โ€” result 0 = OK - unlockPy = pkgs.writeScript "unlock-gnome-keyring.py" '' - #!${pkgs.python3}/bin/python3 - import os, socket, struct, stat, sys - - def unlock(password): - uid = os.getuid() - xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}") - sock_path = os.path.join(xdg, "keyring", "control") - st = os.lstat(sock_path) - if not stat.S_ISSOCK(st.st_mode) or st.st_uid != uid: - raise RuntimeError(f"bad socket: {sock_path}") - pw = password.encode() - oplen = 8 + 4 + len(pw) - pkt = struct.pack(">II", oplen, 1) + struct.pack(">I", len(pw)) + pw - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: - s.connect(sock_path) - s.sendall(b"\x00") - s.sendall(pkt) - resp = b"" - while len(resp) < 8: - chunk = s.recv(8 - len(resp)) - if not chunk: - raise RuntimeError(f"daemon closed connection after {len(resp)} bytes") - resp += chunk - _, result = struct.unpack(">II", resp) - return result - - pw = sys.stdin.read().rstrip("\n") - result = unlock(pw) - codes = {0: "OK", 1: "DENIED", 2: "FAILED", 3: "NO_DAEMON"} - print(f"gnome-keyring unlock: {codes.get(result, result)}", flush=True) - sys.exit(0 if result == 0 else 1) - ''; + unlockPy = pkgs.writeScript "unlock-gnome-keyring.py" ( + builtins.readFile ( + pkgs.replaceVars ./unlock-gnome-keyring.py { + python3 = pkgs.python3; + } + ) + ); # PAM exec script: runs as root, decrypts TPM credential, then # uses runuser to run the Python unlock as the target user. - pamScript = pkgs.writeShellScript "pam-gnome-keyring-tpm-unlock" '' - log() { echo "gnome-keyring-tpm: $*" | ${pkgs.util-linux}/bin/logger -t gnome-keyring-tpm; } - CRED="/etc/credstore.encrypted/gnome-keyring.cred" - [ -f "$CRED" ] || exit 0 - - if [ -z "$PAM_USER" ]; then - log "PAM_USER is not set" - exit 1 - fi - - # Decrypt synchronously โ€” requires root/TPM access (not available after fork). - PW=$(${pkgs.systemd}/bin/systemd-creds decrypt --name=gnome-keyring "$CRED" - 2>/dev/null) - if [ $? -ne 0 ] || [ -z "$PW" ]; then - log "credential decrypt failed" - exit 1 - fi - - USER_UID=$(${pkgs.coreutils}/bin/id -u "$PAM_USER" 2>&1) - if [ $? -ne 0 ]; then - log "failed to resolve UID for PAM_USER='$PAM_USER': $USER_UID" - exit 1 - fi - # Skip system/greeter users (uid < 1000) - [ "$USER_UID" -lt 1000 ] && exit 0 - - # The gnome-keyring-daemon p11-kit backend is not fully initialized at - # PAM session-open time โ€” unlock attempts at this point return DENIED. - # Fork a background retry loop so login is never blocked; the daemon - # is ready within a few seconds of the user session starting. - SOCK="/run/user/$USER_UID/keyring/control" - ( - UNLOCKED=0 - for attempt in 1 2 3 4 5 6 7 8; do - ${pkgs.coreutils}/bin/sleep 3 - [ -S "$SOCK" ] || { log "attempt $attempt: socket not found"; continue; } - OUT=$(printf '%s' "$PW" | \ - ${pkgs.util-linux}/bin/runuser -u "$PAM_USER" -- \ - ${pkgs.coreutils}/bin/env XDG_RUNTIME_DIR="/run/user/$USER_UID" \ - ${unlockPy} 2>&1) - STATUS=$? - log "attempt $attempt: $OUT (exit $STATUS)" - if [ "$STATUS" -eq 0 ]; then - UNLOCKED=1 - break - fi - done - [ "$UNLOCKED" -eq 0 ] && log "all attempts exhausted โ€” keyring was NOT unlocked for $PAM_USER" - ) & - - exit 0 - ''; + pamScript = pkgs.writeShellScript "pam-gnome-keyring-tpm-unlock" ( + builtins.readFile ( + pkgs.replaceVars ./pam-gnome-keyring-tpm-unlock.sh { + logger = "${pkgs.util-linux}/bin/logger"; + systemd_creds = "${pkgs.systemd}/bin/systemd-creds"; + id = "${pkgs.coreutils}/bin/id"; + sleep = "${pkgs.coreutils}/bin/sleep"; + env = "${pkgs.coreutils}/bin/env"; + runuser = "${pkgs.util-linux}/bin/runuser"; + unlock_py = unlockPy; + } + ) + ); in { order = config.security.pam.services.greetd.rules.session.gnome_keyring.order + 10; diff --git a/named-hosts/matic/falcon-init.sh b/named-hosts/matic/falcon-init.sh new file mode 100644 index 000000000..c6337d83d --- /dev/null +++ b/named-hosts/matic/falcon-init.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# CrowdStrike Falcon sensor init script. +# @e2fsprogs@, @rsync@, @falcon@ are substituted by pkgs.replaceVars. +set -euo pipefail + +# Remove immutable attributes set by CrowdStrike (security feature) +if [ -d /opt/CrowdStrike ]; then + @e2fsprogs@/bin/chattr -i -R /opt/CrowdStrike 2>/dev/null || true +fi + +install -d -m 0770 /opt/CrowdStrike + +# Update binaries from the nix store, but preserve runtime state files. +# falconstore contains the Agent ID (AID) โ€” if lost, the sensor re-registers +# as a new host and consumes another license seat. +@rsync@/bin/rsync -a --delete \ + --exclude=falconstore \ + --exclude=falconstore.bak \ + --exclude=CsConfig \ + "@falcon@/opt/CrowdStrike/" /opt/CrowdStrike/ + +chown -R root:root /opt/CrowdStrike + +# load CID from /etc/falcon-sensor.env (root-only) +# shellcheck source=/dev/null +. /etc/falcon-sensor.env + +# set CID via falconctl inside FHS env +@falcon@/bin/fs-bash -c "/opt/CrowdStrike/falconctl -s -f --cid=\"$FALCON_CID\"" + +# sanity print +@falcon@/bin/fs-bash -c "/opt/CrowdStrike/falconctl -g --cid" diff --git a/named-hosts/matic/falcon.nix b/named-hosts/matic/falcon.nix index 7bb9e006d..d63abe80d 100644 --- a/named-hosts/matic/falcon.nix +++ b/named-hosts/matic/falcon.nix @@ -13,37 +13,15 @@ let falcon = pkgs.callPackage ./falcon { }; - initScript = pkgs.writeScript "init-falcon" '' - #!${pkgs.bash}/bin/sh - set -euo pipefail - - # Remove immutable attributes set by CrowdStrike (security feature) - if [ -d /opt/CrowdStrike ]; then - ${pkgs.e2fsprogs}/bin/chattr -i -R /opt/CrowdStrike 2>/dev/null || true - fi - - install -d -m 0770 /opt/CrowdStrike - - # Update binaries from the nix store, but preserve runtime state files. - # falconstore contains the Agent ID (AID) โ€” if lost, the sensor re-registers - # as a new host and consumes another license seat. - ${pkgs.rsync}/bin/rsync -a --delete \ - --exclude=falconstore \ - --exclude=falconstore.bak \ - --exclude=CsConfig \ - "${falcon}/opt/CrowdStrike/" /opt/CrowdStrike/ - - chown -R root:root /opt/CrowdStrike - - # load CID from /etc/falcon-sensor.env (root-only) - . /etc/falcon-sensor.env - - # set CID via falconctl inside FHS env - ${falcon}/bin/fs-bash -c "/opt/CrowdStrike/falconctl -s -f --cid=\"$FALCON_CID\"" - - # sanity print - ${falcon}/bin/fs-bash -c "/opt/CrowdStrike/falconctl -g --cid" - ''; + initScript = pkgs.writeScript "init-falcon" ( + builtins.readFile ( + pkgs.replaceVars ./falcon-init.sh { + e2fsprogs = pkgs.e2fsprogs; + rsync = pkgs.rsync; + falcon = falcon; + } + ) + ); in { systemd.tmpfiles.rules = [ diff --git a/named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh b/named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh new file mode 100644 index 000000000..c4551fe5b --- /dev/null +++ b/named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# PAM exec script: runs as root, decrypts TPM credential, then +# uses runuser to run the Python unlock as the target user. +# @logger@, @systemd_creds@, @id@, @sleep@, @env@, @runuser@, @unlock_py@ +# are substituted by pkgs.replaceVars. +log() { echo "gnome-keyring-tpm: $*" | @logger@ -t gnome-keyring-tpm; } +CRED="/etc/credstore.encrypted/gnome-keyring.cred" +[ -f "$CRED" ] || exit 0 + +if [ -z "$PAM_USER" ]; then + log "PAM_USER is not set" + exit 1 +fi + +# Decrypt synchronously โ€” requires root/TPM access (not available after fork). +PW=$(@systemd_creds@ decrypt --name=gnome-keyring "$CRED" - 2>/dev/null) +# shellcheck disable=SC2181 +if [ $? -ne 0 ] || [ -z "$PW" ]; then + log "credential decrypt failed" + exit 1 +fi + +USER_UID=$(@id@ -u "$PAM_USER" 2>&1) +# shellcheck disable=SC2181 +if [ $? -ne 0 ]; then + log "failed to resolve UID for PAM_USER='$PAM_USER': $USER_UID" + exit 1 +fi +# Skip system/greeter users (uid < 1000) +[ "$USER_UID" -lt 1000 ] && exit 0 + +# The gnome-keyring-daemon p11-kit backend is not fully initialized at +# PAM session-open time โ€” unlock attempts at this point return DENIED. +# Fork a background retry loop so login is never blocked; the daemon +# is ready within a few seconds of the user session starting. +SOCK="/run/user/$USER_UID/keyring/control" +( + UNLOCKED=0 + for attempt in 1 2 3 4 5 6 7 8; do + @sleep@ 3 + [ -S "$SOCK" ] || { + log "attempt $attempt: socket not found" + continue + } + OUT=$(printf '%s' "$PW" | + @runuser@ -u "$PAM_USER" -- \ + @env@ XDG_RUNTIME_DIR="/run/user/$USER_UID" \ + @unlock_py@ 2>&1) + STATUS=$? + log "attempt $attempt: $OUT (exit $STATUS)" + if [ "$STATUS" -eq 0 ]; then + UNLOCKED=1 + break + fi + done + [ "$UNLOCKED" -eq 0 ] && log "all attempts exhausted โ€” keyring was NOT unlocked for $PAM_USER" +) & + +exit 0 diff --git a/named-hosts/matic/unlock-gnome-keyring.py b/named-hosts/matic/unlock-gnome-keyring.py new file mode 100644 index 000000000..1e36b0171 --- /dev/null +++ b/named-hosts/matic/unlock-gnome-keyring.py @@ -0,0 +1,48 @@ +#!@python3@/bin/python3 +# Speaks the gnome-keyring control socket protocol directly. +# gnome-keyring-daemon --unlock (v48) ignores GNOME_KEYRING_CONTROL +# and always starts a new instance, so we bypass it entirely. +# +# Protocol (all big-endian): +# 1. connect to $XDG_RUNTIME_DIR/keyring/control (UNIX stream) +# 2. send \x00 โ€” daemon reads our UID via SO_PEERCRED +# 3. send [oplen:4][op=1:4][pwlen:4][password bytes] +# where oplen = 8 + 4 + len(password) +# 4. read [8:4][result:4] โ€” result 0 = OK +import os +import socket +import stat +import struct +import sys + + +def unlock(password): + uid = os.getuid() + xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}") + sock_path = os.path.join(xdg, "keyring", "control") + st = os.lstat(sock_path) + if not stat.S_ISSOCK(st.st_mode) or st.st_uid != uid: + raise RuntimeError(f"bad socket: {sock_path}") + pw = password.encode() + oplen = 8 + 4 + len(pw) + pkt = struct.pack(">II", oplen, 1) + struct.pack(">I", len(pw)) + pw + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.settimeout(10) + s.connect(sock_path) + s.sendall(b"\x00") + s.sendall(pkt) + resp = b"" + while len(resp) < 8: + chunk = s.recv(8 - len(resp)) + if not chunk: + raise RuntimeError(f"daemon closed connection after {len(resp)} bytes") + resp += chunk + _, result = struct.unpack(">II", resp) + return result + + +pw = sys.stdin.read().rstrip("\n") +result = unlock(pw) +codes = {0: "OK", 1: "DENIED", 2: "FAILED", 3: "NO_DAEMON"} +print(f"gnome-keyring unlock: {codes.get(result, result)}", flush=True) +sys.exit(0 if result == 0 else 1) diff --git a/pyproject.toml b/pyproject.toml index 01cf39d95..f422fc051 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,23 @@ name = "dotfiles" version = "0.1.0" requires-python = ">=3.13" -# UV global tools - installed as isolated environments via uv tool install -# Listed here for Renovate to track version updates -dependencies = [ + +# UV global tools - installed as isolated environments via uv tool install. +# Listed here for Renovate to track version updates. +# These conflict with each other so cannot be co-installed; use: uv tool install +[dependency-groups] +tools = [ "aider-chat>=0.86.2", "marker-pdf>=1.10.2", "mistral-vibe>=2.5.0", "ruff>=0.15.6", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I"] diff --git a/scripts/check-nix-inline-scripts.sh b/scripts/check-nix-inline-scripts.sh new file mode 100755 index 000000000..b7e5680cc --- /dev/null +++ b/scripts/check-nix-inline-scripts.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Fail if any .nix file passes an inline string literal to write*Script* functions. +# All script content must live in external files referenced via builtins.readFile +# or pkgs.replaceVars, not embedded as inline '' strings. +# +# Catches: writeScript, writeShellScript, writeShellScriptBin, writeScriptBin +# Does NOT flag: writeText (used for config files, not scripts) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +violations=$(grep -rn \ + --include='*.nix' \ + -E 'write(Shell)?(Script|ScriptBin)[[:space:]]+"[^"]+"+[[:space:]]+'"''" \ + "$ROOT" \ + --exclude-dir='.git' \ + --exclude-dir='result' \ + --exclude-dir='.direnv' \ + --exclude-dir='.worktrees' | + grep -v "^Binary" || true) + +if [ -n "$violations" ]; then + echo "ERROR: Inline script strings found in Nix files." >&2 + echo "All scripts must be external files referenced via builtins.readFile or pkgs.replaceVars." >&2 + echo "" >&2 + echo "$violations" >&2 + exit 1 +fi + +echo "โœ“ No inline scripts in Nix files" diff --git a/scripts/fishtape-wrapper.sh b/scripts/fishtape-wrapper.sh new file mode 100644 index 000000000..f073f08ef --- /dev/null +++ b/scripts/fishtape-wrapper.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Wraps the fishtape fish plugin so it can be called as a regular command. +# @fish@ and @fishtape_3_src@ are substituted by pkgs.replaceVars. +# shellcheck disable=SC2016 +exec @fish@/bin/fish \ + -C "source @fishtape_3_src@/functions/fishtape.fish" \ + -c 'fishtape $argv' \ + -- "$@" diff --git a/spec/check_nix_inline_scripts_spec.sh b/spec/check_nix_inline_scripts_spec.sh new file mode 100644 index 000000000..efa53593d --- /dev/null +++ b/spec/check_nix_inline_scripts_spec.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'scripts/check-nix-inline-scripts.sh' +SCRIPT="$PWD/scripts/check-nix-inline-scripts.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'uses strict mode' +When run bash -c "grep 'set -euo pipefail' '$SCRIPT'" +The output should include 'set -euo pipefail' +End +End + +Describe 'detection pattern' +It 'searches for writeScript patterns' +When run bash -c "grep 'write.*Script' '$SCRIPT'" +The output should include 'write' +End + +It 'excludes .git directory' +When run bash -c "grep 'exclude-dir' '$SCRIPT'" +The output should include '.git' +End + +It 'excludes .worktrees directory' +When run bash -c "grep 'exclude-dir' '$SCRIPT'" +The output should include '.worktrees' +End +End + +End diff --git a/spec/cliproxyapi_docker_start_spec.sh b/spec/cliproxyapi_docker_start_spec.sh new file mode 100644 index 000000000..f65146026 --- /dev/null +++ b/spec/cliproxyapi_docker_start_spec.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'home-manager/services/cliproxyapi/scripts/docker-start.sh' +SCRIPT="$PWD/home-manager/services/cliproxyapi/scripts/docker-start.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|/usr|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'placeholder substitutions' +It 'references @bash@' +When run bash -c "grep '@bash@' '$SCRIPT'" +The output should include '@bash@' +End + +It 'references @start_script@' +When run bash -c "grep '@start_script@' '$SCRIPT'" +The output should include '@start_script@' +End + +It 'references @docker@' +When run bash -c "grep '@docker@' '$SCRIPT'" +The output should include '@docker@' +End +End + +Describe 'docker access strategy' +It 'tries docker directly first' +When run bash -c "grep 'docker info' '$SCRIPT'" +The output should include 'docker info' +End + +It 'falls back to NixOS sg wrapper' +When run bash -c "grep '/run/wrappers/bin/sg' '$SCRIPT'" +The output should include '/run/wrappers/bin/sg' +End + +It 'falls back to system sg' +When run bash -c "grep '/usr/bin/sg' '$SCRIPT'" +The output should include '/usr/bin/sg' +End + +It 'errors when no docker access available' +When run bash -c "grep 'Cannot access Docker' '$SCRIPT'" +The output should include 'Cannot access Docker' +End +End + +End diff --git a/spec/coverage_spec.sh b/spec/coverage_spec.sh index 453765b05..7943ed1e9 100644 --- a/spec/coverage_spec.sh +++ b/spec/coverage_spec.sh @@ -65,6 +65,10 @@ It 'has spec file for home-manager/services/docker-postgres/start-postgres.sh' The path "spec/docker_postgres_spec.sh" should be exist End +It 'has spec file for home-manager/services/docker-postgres/start-postgres-wrapper.sh' +The path "spec/start_postgres_wrapper_spec.sh" should be exist +End + It 'has spec file for home-manager/services/dotfiles-updater/update.sh' The path "spec/dotfiles_updater_spec.sh" should be exist End @@ -128,6 +132,42 @@ End It 'has spec file for home-manager/modules/yek/yek.sh' The path "spec/yek_spec.sh" should be exist End + +It 'has spec file for home-manager/modules/yek/install-yek-shim.sh' +The path "spec/install_yek_shim_spec.sh" should be exist +End + +It 'has spec file for home-manager/modules/yek/yek-shim.sh' +The path "spec/yek_shim_spec.sh" should be exist +End + +It 'has spec file for named-hosts/matic/falcon-init.sh' +The path "spec/falcon_init_spec.sh" should be exist +End + +It 'has spec file for named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh' +The path "spec/pam_gnome_keyring_tpm_unlock_spec.sh" should be exist +End + +It 'has spec file for scripts/fishtape-wrapper.sh' +The path "spec/fishtape_wrapper_spec.sh" should be exist +End + +It 'has spec file for scripts/check-nix-inline-scripts.sh' +The path "spec/check_nix_inline_scripts_spec.sh" should be exist +End + +It 'has spec file for home-manager/services/cliproxyapi/scripts/docker-start.sh' +The path "spec/cliproxyapi_docker_start_spec.sh" should be exist +End + +It 'has spec file for home-manager/services/docker/setup-docker.sh' +The path "spec/docker_setup_spec.sh" should be exist +End + +It 'has spec file for home-manager/services/docker/docker-setup.sh' +The path "spec/docker_setup_wrapper_spec.sh" should be exist +End End Describe 'no shell scripts are missing from coverage list' @@ -150,24 +190,34 @@ home-manager/modules/cargo-globals/install-cargo-globals.sh home-manager/modules/local-binaries/sync-local-binaries.sh home-manager/modules/npm-globals/install-npm-globals.sh home-manager/modules/uv-globals/install-uv-globals.sh +home-manager/modules/yek/install-yek-shim.sh home-manager/modules/yek/install-yek.sh +home-manager/modules/yek/yek-shim.sh home-manager/modules/yek/yek.sh home-manager/programs/neovim/run_tests.sh home-manager/programs/tmux/session-logger.sh home-manager/services/brew-upgrader/upgrade.sh home-manager/services/cliproxyapi/scripts/backup.sh home-manager/services/cliproxyapi/scripts/common.sh +home-manager/services/cliproxyapi/scripts/docker-start.sh home-manager/services/cliproxyapi/scripts/hydrate.sh home-manager/services/cliproxyapi/scripts/start.sh home-manager/services/cliproxyapi/scripts/wrapper.sh home-manager/services/code-syncer/sync.sh +home-manager/services/docker-postgres/start-postgres-wrapper.sh home-manager/services/docker-postgres/start-postgres.sh +home-manager/services/docker/docker-setup.sh +home-manager/services/docker/setup-docker.sh home-manager/services/dotfiles-updater/update.sh home-manager/services/make-updater/update.sh home-manager/services/neverssl-keepalive/keepalive.sh install.sh named-hosts/kyber/rekey-galactica.sh named-hosts/kyber/setup.sh +named-hosts/matic/falcon-init.sh +named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh +scripts/check-nix-inline-scripts.sh +scripts/fishtape-wrapper.sh scripts/llm-update.sh scripts/update-gitalias.sh scripts/update-local-binaries.sh diff --git a/spec/docker_setup_spec.sh b/spec/docker_setup_spec.sh new file mode 100644 index 000000000..373baefe9 --- /dev/null +++ b/spec/docker_setup_spec.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'home-manager/services/docker/setup-docker.sh' +SCRIPT="$PWD/home-manager/services/docker/setup-docker.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'uses strict mode' +When run bash -c "grep 'set -euo pipefail' '$SCRIPT'" +The output should include 'set -euo pipefail' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|/usr|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'placeholder substitutions' +It 'references @shadow@ for groups and usermod' +When run bash -c "grep '@shadow@' '$SCRIPT'" +The output should include '@shadow@' +End + +It 'references @gnugrep@' +When run bash -c "grep '@gnugrep@' '$SCRIPT'" +The output should include '@gnugrep@' +End + +It 'references @systemd@' +When run bash -c "grep '@systemd@' '$SCRIPT'" +The output should include '@systemd@' +End + +It 'references @coreutils@' +When run bash -c "grep '@coreutils@' '$SCRIPT'" +The output should include '@coreutils@' +End + +It 'references @docker_service_file@' +When run bash -c "grep '@docker_service_file@' '$SCRIPT'" +The output should include '@docker_service_file@' +End +End + +Describe 'docker group management' +It 'checks group membership' +When run bash -c "grep 'docker group' '$SCRIPT'" +The output should include 'docker group' +End + +It 'uses usermod to add user to docker group' +When run bash -c "grep 'usermod' '$SCRIPT'" +The output should include 'usermod' +End +End + +Describe 'daemon management' +It 'checks if docker daemon is active' +When run bash -c "grep 'is-active' '$SCRIPT'" +The output should include 'is-active' +End + +It 'installs service file to /etc/systemd' +When run bash -c "grep '/etc/systemd/system/docker.service' '$SCRIPT'" +The output should include '/etc/systemd/system/docker.service' +End + +It 'enables docker on boot' +When run bash -c "grep 'enable docker' '$SCRIPT'" +The output should include 'enable docker' +End +End + +End diff --git a/spec/docker_setup_wrapper_spec.sh b/spec/docker_setup_wrapper_spec.sh new file mode 100644 index 000000000..fc21792f1 --- /dev/null +++ b/spec/docker_setup_wrapper_spec.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'home-manager/services/docker/docker-setup.sh' +SCRIPT="$PWD/home-manager/services/docker/docker-setup.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|/usr/bin/true|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'delegation' +It 'uses exec to delegate to setup script' +When run bash -c "grep 'exec' '$SCRIPT'" +The output should include 'exec' +End + +It 'references @setup_docker_script@' +When run bash -c "grep '@setup_docker_script@' '$SCRIPT'" +The output should include '@setup_docker_script@' +End +End + +End diff --git a/spec/falcon_init_spec.sh b/spec/falcon_init_spec.sh new file mode 100644 index 000000000..606fad25d --- /dev/null +++ b/spec/falcon_init_spec.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'named-hosts/matic/falcon-init.sh' +SCRIPT="$PWD/named-hosts/matic/falcon-init.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'uses strict mode' +When run bash -c "grep 'set -euo pipefail' '$SCRIPT'" +The output should include 'set -euo pipefail' +End +End + +Describe 'placeholder substitutions' +It 'references @e2fsprogs@ for chattr' +When run bash -c "grep '@e2fsprogs@' '$SCRIPT'" +The output should include 'chattr' +End + +It 'references @rsync@ for sync' +When run bash -c "grep '@rsync@' '$SCRIPT'" +The output should include 'rsync' +End + +It 'references @falcon@ for falconctl' +When run bash -c "grep '@falcon@' '$SCRIPT'" +The output should include 'falconctl' +End +End + +Describe 'logic' +It 'preserves falconstore from rsync deletion' +When run bash -c "grep 'falconstore' '$SCRIPT'" +The output should include '--exclude=falconstore' +End + +It 'loads CID from env file' +When run bash -c "grep 'falcon-sensor.env' '$SCRIPT'" +The output should include '/etc/falcon-sensor.env' +End + +It 'sets CID via falconctl' +When run bash -c "grep 'FALCON_CID' '$SCRIPT'" +The output should include 'FALCON_CID' +End +End + +End diff --git a/spec/fishtape_wrapper_spec.sh b/spec/fishtape_wrapper_spec.sh new file mode 100644 index 000000000..37d96b4c7 --- /dev/null +++ b/spec/fishtape_wrapper_spec.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'scripts/fishtape-wrapper.sh' +SCRIPT="$PWD/scripts/fishtape-wrapper.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z0-9_]*@|/usr|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'placeholder substitutions' +It 'references @fish@' +When run bash -c "grep '@fish@' '$SCRIPT'" +The output should include '@fish@' +End + +It 'references @fishtape_3_src@' +When run bash -c "grep '@fishtape_3_src@' '$SCRIPT'" +The output should include '@fishtape_3_src@' +End +End + +Describe 'invocation' +It 'sources fishtape.fish' +When run bash -c "grep 'fishtape.fish' '$SCRIPT'" +The output should include 'fishtape.fish' +End + +It 'uses exec to replace the process' +When run bash -c "grep '^exec' '$SCRIPT'" +The output should include 'exec' +End + +It 'passes arguments through' +When run bash -c "grep '\"\$@\"' '$SCRIPT'" +The output should include '"$@"' +End +End + +End diff --git a/spec/install_yek_shim_spec.sh b/spec/install_yek_shim_spec.sh new file mode 100644 index 000000000..92fd96cb4 --- /dev/null +++ b/spec/install_yek_shim_spec.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'home-manager/modules/yek/install-yek-shim.sh' +SCRIPT="$PWD/home-manager/modules/yek/install-yek-shim.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|/usr/bin/true|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'delegation' +It 'uses exec to delegate' +When run bash -c "grep 'exec' '$SCRIPT'" +The output should include 'exec' +End + +It 'references @bash@' +When run bash -c "grep '@bash@' '$SCRIPT'" +The output should include '@bash@' +End + +It 'references @install_yek_script@' +When run bash -c "grep '@install_yek_script@' '$SCRIPT'" +The output should include '@install_yek_script@' +End + +It 'passes all arguments through' +When run bash -c "grep '\"\$@\"' '$SCRIPT'" +The output should include '"$@"' +End +End + +End diff --git a/spec/pam_gnome_keyring_tpm_unlock_spec.sh b/spec/pam_gnome_keyring_tpm_unlock_spec.sh new file mode 100644 index 000000000..59063b149 --- /dev/null +++ b/spec/pam_gnome_keyring_tpm_unlock_spec.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh' +SCRIPT="$PWD/named-hosts/matic/pam-gnome-keyring-tpm-unlock.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|true|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'placeholder substitutions' +It 'references @logger@' +When run bash -c "grep '@logger@' '$SCRIPT'" +The output should include '@logger@' +End + +It 'references @systemd_creds@' +When run bash -c "grep '@systemd_creds@' '$SCRIPT'" +The output should include '@systemd_creds@' +End + +It 'references @id@' +When run bash -c "grep '@id@' '$SCRIPT'" +The output should include '@id@' +End + +It 'references @sleep@' +When run bash -c "grep '@sleep@' '$SCRIPT'" +The output should include '@sleep@' +End + +It 'references @env@' +When run bash -c "grep '@env@' '$SCRIPT'" +The output should include '@env@' +End + +It 'references @runuser@' +When run bash -c "grep '@runuser@' '$SCRIPT'" +The output should include '@runuser@' +End + +It 'references @unlock_py@' +When run bash -c "grep '@unlock_py@' '$SCRIPT'" +The output should include '@unlock_py@' +End +End + +Describe 'logic' +It 'exits 0 if credential file absent' +When run bash -c "grep '|| exit 0' '$SCRIPT'" +The output should include '|| exit 0' +End + +It 'checks PAM_USER is set' +When run bash -c "grep 'PAM_USER' '$SCRIPT'" +The output should include 'PAM_USER' +End + +It 'skips system users below uid 1000' +When run bash -c "grep '1000' '$SCRIPT'" +The output should include '1000' +End + +It 'runs unlock in background subshell' +When run bash -c "grep -c ') &' '$SCRIPT'" +The output should include '1' +End + +It 'retries unlock up to 8 times' +When run bash -c "grep '1 2 3 4 5 6 7 8' '$SCRIPT'" +The output should include '1 2 3 4 5 6 7 8' +End +End + +End diff --git a/spec/start_postgres_wrapper_spec.sh b/spec/start_postgres_wrapper_spec.sh new file mode 100644 index 000000000..f584ad1e9 --- /dev/null +++ b/spec/start_postgres_wrapper_spec.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'home-manager/services/docker-postgres/start-postgres-wrapper.sh' +SCRIPT="$PWD/home-manager/services/docker-postgres/start-postgres-wrapper.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|/usr|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'placeholder substitutions' +It 'references @bash@' +When run bash -c "grep '@bash@' '$SCRIPT'" +The output should include '@bash@' +End + +It 'references @start_script@' +When run bash -c "grep '@start_script@' '$SCRIPT'" +The output should include '@start_script@' +End + +It 'references @docker@' +When run bash -c "grep '@docker@' '$SCRIPT'" +The output should include '@docker@' +End +End + +Describe 'docker access strategy' +It 'tries docker directly first' +When run bash -c "grep 'docker info' '$SCRIPT'" +The output should include 'docker info' +End + +It 'falls back to NixOS sg wrapper' +When run bash -c "grep '/run/wrappers/bin/sg' '$SCRIPT'" +The output should include '/run/wrappers/bin/sg' +End + +It 'falls back to system sg' +When run bash -c "grep '/usr/bin/sg' '$SCRIPT'" +The output should include '/usr/bin/sg' +End + +It 'errors when no docker access available' +When run bash -c "grep 'Cannot access Docker' '$SCRIPT'" +The output should include 'Cannot access Docker' +End +End + +End diff --git a/spec/unlock_gnome_keyring_spec.sh b/spec/unlock_gnome_keyring_spec.sh new file mode 100644 index 000000000..c2681c37d --- /dev/null +++ b/spec/unlock_gnome_keyring_spec.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'named-hosts/matic/unlock-gnome-keyring.py' +SCRIPT="$PWD/named-hosts/matic/unlock-gnome-keyring.py" + +Describe 'script properties' +It 'has python3 placeholder shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '@python3@/bin/python3' +End + +It 'passes Python syntax check after stripping shebang' +When run bash -c "tail -n +2 '$SCRIPT' | python3 -c 'import sys; compile(sys.stdin.read(), \"\", \"exec\")'" +The status should be success +End +End + +Describe 'protocol implementation' +It 'uses UNIX socket' +When run bash -c "grep 'AF_UNIX' '$SCRIPT'" +The output should include 'AF_UNIX' +End + +It 'uses XDG_RUNTIME_DIR' +When run bash -c "grep 'XDG_RUNTIME_DIR' '$SCRIPT'" +The output should include 'XDG_RUNTIME_DIR' +End + +It 'reads from stdin' +When run bash -c "grep 'stdin.read' '$SCRIPT'" +The output should include 'stdin.read' +End + +It 'validates socket ownership' +When run bash -c "grep 'st.st_uid' '$SCRIPT'" +The output should include 'st.st_uid' +End +End + +Describe 'result codes' +It 'handles OK result' +When run bash -c "grep '\"OK\"' '$SCRIPT'" +The output should include 'OK' +End + +It 'handles DENIED result' +When run bash -c "grep 'DENIED' '$SCRIPT'" +The output should include 'DENIED' +End + +It 'exits non-zero on failure' +When run bash -c "grep 'sys.exit' '$SCRIPT'" +The output should include 'sys.exit' +End +End + +End diff --git a/spec/yek_shim_spec.sh b/spec/yek_shim_spec.sh new file mode 100644 index 000000000..257af9c4a --- /dev/null +++ b/spec/yek_shim_spec.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 + +Describe 'home-manager/modules/yek/yek-shim.sh' +SCRIPT="$PWD/home-manager/modules/yek/yek-shim.sh" + +Describe 'script properties' +It 'uses bash shebang' +When run bash -c "head -1 '$SCRIPT'" +The output should include '#!/usr/bin/env bash' +End + +It 'passes bash syntax check after stripping placeholders' +When run bash -c "sed 's|@[a-z_]*@|/usr/bin/true|g' '$SCRIPT' | bash -n" +The status should be success +End +End + +Describe 'delegation' +It 'uses exec to delegate' +When run bash -c "grep 'exec' '$SCRIPT'" +The output should include 'exec' +End + +It 'references @bash@' +When run bash -c "grep '@bash@' '$SCRIPT'" +The output should include '@bash@' +End + +It 'references @yek_wrapper_script@' +When run bash -c "grep '@yek_wrapper_script@' '$SCRIPT'" +The output should include '@yek_wrapper_script@' +End + +It 'passes all arguments through' +When run bash -c "grep '\"\$@\"' '$SCRIPT'" +The output should include '"$@"' +End +End + +End diff --git a/tests/test_unlock_gnome_keyring.py b/tests/test_unlock_gnome_keyring.py new file mode 100644 index 000000000..fa9c1290f --- /dev/null +++ b/tests/test_unlock_gnome_keyring.py @@ -0,0 +1,177 @@ +"""Tests for named-hosts/matic/unlock-gnome-keyring.py. + +The script is loaded by stripping the @python3@ placeholder shebang and +exec-ing the module body, so we can test the unlock() function directly +without spawning a subprocess. +""" + +import os +import stat +import struct +import types +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +SCRIPT = Path(__file__).parent.parent / "named-hosts/matic/unlock-gnome-keyring.py" + + +def _load_module() -> types.ModuleType: + """Import the script as a module, skipping its top-level side-effects. + + Loads only the imports and function definitions; stops before the module-level + execution block (pw = sys.stdin.read() ... sys.exit()). + """ + source = SCRIPT.read_text() + lines = source.splitlines(keepends=True) + # Strip the @python3@ shebang + if lines and lines[0].startswith("#!"): + lines = lines[1:] + + # Keep only lines up to (but not including) the module-level execution block + definition_lines = [] + for line in lines: + if line.startswith("pw = sys.stdin"): + break + definition_lines.append(line) + + code = "".join(definition_lines) + mod = types.ModuleType("unlock_gnome_keyring") + exec(compile(code, str(SCRIPT), "exec"), mod.__dict__) # noqa: S102 + return mod + + +_mod = _load_module() +unlock = _mod.unlock + + +class TestUnlockProtocol(unittest.TestCase): + """Tests for the unlock() socket protocol function.""" + + def _make_stat(self, uid: int, is_sock: bool = True) -> MagicMock: + st = MagicMock() + st.st_uid = uid + st.st_mode = stat.S_IFSOCK if is_sock else stat.S_IFREG + return st + + def _make_socket(self, result: int) -> MagicMock: + resp = struct.pack(">II", 8, result) + sock = MagicMock() + sock.__enter__ = lambda s: s + sock.__exit__ = MagicMock(return_value=False) + sock.recv.return_value = resp + return sock + + @patch("socket.socket") + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_unlock_ok(self, mock_uid, mock_lstat, mock_socket_cls): + mock_lstat.return_value = self._make_stat(uid=1000) + sock = self._make_socket(result=0) + mock_socket_cls.return_value = sock + + result = unlock("correct-password") + + self.assertEqual(result, 0) + sock.connect.assert_called_once() + sock.sendall.assert_called() + + @patch("socket.socket") + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_unlock_denied(self, mock_uid, mock_lstat, mock_socket_cls): + mock_lstat.return_value = self._make_stat(uid=1000) + sock = self._make_socket(result=1) + mock_socket_cls.return_value = sock + + result = unlock("wrong-password") + + self.assertEqual(result, 1) + + @patch("socket.socket") + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_unlock_no_daemon(self, mock_uid, mock_lstat, mock_socket_cls): + mock_lstat.return_value = self._make_stat(uid=1000) + sock = self._make_socket(result=3) + mock_socket_cls.return_value = sock + + result = unlock("any-password") + + self.assertEqual(result, 3) + + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_bad_socket_wrong_owner(self, mock_uid, mock_lstat): + mock_lstat.return_value = self._make_stat(uid=9999) + + with self.assertRaisesRegex(RuntimeError, "bad socket"): + unlock("pw") + + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_bad_socket_not_a_socket(self, mock_uid, mock_lstat): + mock_lstat.return_value = self._make_stat(uid=1000, is_sock=False) + + with self.assertRaisesRegex(RuntimeError, "bad socket"): + unlock("pw") + + @patch("socket.socket") + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_uses_xdg_runtime_dir(self, mock_uid, mock_lstat, mock_socket_cls): + mock_lstat.return_value = self._make_stat(uid=1000) + sock = self._make_socket(result=0) + mock_socket_cls.return_value = sock + + with patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/run/user/1000"}): + unlock("pw") + + mock_lstat.assert_called_with("/run/user/1000/keyring/control") + + @patch("socket.socket") + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_packet_structure(self, mock_uid, mock_lstat, mock_socket_cls): + """Verify the protocol packet layout: oplen, op=1, pwlen, password.""" + mock_lstat.return_value = self._make_stat(uid=1000) + sock = self._make_socket(result=0) + mock_socket_cls.return_value = sock + + unlock("hello") + + # sendall called at least twice: once for \x00, once for the packet + calls = sock.sendall.call_args_list + self.assertGreaterEqual(len(calls), 2) + self.assertEqual(calls[0][0][0], b"\x00") + + pkt = calls[1][0][0] + pw = b"hello" + expected_oplen = 8 + 4 + len(pw) + oplen, op = struct.unpack(">II", pkt[:8]) + pwlen = struct.unpack(">I", pkt[8:12])[0] + + self.assertEqual(oplen, expected_oplen) + self.assertEqual(op, 1) + self.assertEqual(pwlen, len(pw)) + self.assertEqual(pkt[12:], pw) + + @patch("socket.socket") + @patch("os.lstat") + @patch("os.getuid", return_value=1000) + def test_daemon_closes_connection_raises( + self, mock_uid, mock_lstat, mock_socket_cls + ): + mock_lstat.return_value = self._make_stat(uid=1000) + sock = MagicMock() + sock.__enter__ = lambda s: s + sock.__exit__ = MagicMock(return_value=False) + sock.recv.return_value = b"" # simulate closed connection + mock_socket_cls.return_value = sock + + with self.assertRaisesRegex(RuntimeError, "daemon closed"): + unlock("pw") + + +if __name__ == "__main__": + unittest.main()