From 181ce4fc49a5668d39b0cfa7288044e8a66edd48 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Mon, 13 Apr 2026 15:10:56 +0200 Subject: [PATCH 01/31] nix: Point the nix path to a static value Otherwise updates are not taken into account when deploying without a reboot. --- modules/package-manager.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/package-manager.nix b/modules/package-manager.nix index 34dc2718..c1349b28 100644 --- a/modules/package-manager.nix +++ b/modules/package-manager.nix @@ -14,11 +14,12 @@ let in { config = { + environment.etc.nixpkgs.source = builtins.storePath pkgs.path; nix = { package = pkgs.lix; nixPath = [ # Always point to the authorized sources. - "nixpkgs=${pkgs.path}" + "nixpkgs=/etc/nixpkgs" ]; } // (lib.optionalAttrs proxyCfg.enable { From f338bf59995662ef90655fff377b5c8983bcf5ea Mon Sep 17 00:00:00 2001 From: raltheo Date: Thu, 16 Apr 2026 10:47:59 +0200 Subject: [PATCH 02/31] =?UTF-8?q?Ajoute=20le=20support=20mat=C3=A9riel=20d?= =?UTF-8?q?u=20ThinkPad=20X13=20Gen=201=20AMD=20(20UF/20UG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hardware/default.nix | 1 + hardware/x13-20ug.nix | 38 ++++++++++++++++++++++++++++++++++++++ modules/self.nix | 1 + 3 files changed, 40 insertions(+) create mode 100644 hardware/x13-20ug.nix diff --git a/hardware/default.nix b/hardware/default.nix index dc53b9cf..79c32c83 100644 --- a/hardware/default.nix +++ b/hardware/default.nix @@ -10,5 +10,6 @@ ./t14g6.nix ./x9-15.nix ./e14-g7.nix + ./x13-20ug.nix ]; } diff --git a/hardware/x13-20ug.nix b/hardware/x13-20ug.nix new file mode 100644 index 00000000..e37cd7fb --- /dev/null +++ b/hardware/x13-20ug.nix @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2026 raltheo +# +# SPDX-License-Identifier: MIT +# ThinkPad X13 (Type 20UF, 20UG) — AMD Ryzen +# Généré depuis nixos-generate-config et adapté pour Securix. + +{ + config, + lib, + pkgs, + modulesPath, + ... +}: +{ + imports = [ (modulesPath + "/installer/scan/not-detected.nix") ]; + + config = lib.mkIf (config.securix.self.machine.hardwareSKU == "x13-20ug") { + boot.initrd.availableKernelModules = [ + "nvme" + "ehci_pci" + "xhci_pci_renesas" + "xhci_pci" + "rtsx_pci_sdmmc" + ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ "kvm-amd" ]; + boot.extraModulePackages = [ ]; + + hardware.firmware = [ + pkgs.linux-firmware + pkgs.wireless-regdb + ]; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + powerManagement.cpuFreqGovernor = lib.mkDefault "powersave"; + hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; + }; +} diff --git a/modules/self.nix b/modules/self.nix index 19fdcb3d..d7b438c7 100644 --- a/modules/self.nix +++ b/modules/self.nix @@ -163,6 +163,7 @@ in "t14g6" "x9-15" "e14-g7" + "x13-20ug" ]; description = "Identifiant de configuration du matériel"; example = "x280"; From f3819d3d940610a40e1aa45c62d6f0f7c0e419fa Mon Sep 17 00:00:00 2001 From: "Julien DAUPHANT (DINUM)" Date: Mon, 20 Apr 2026 14:56:30 +0200 Subject: [PATCH 03/31] config: Revise README Updated project title and added contribution guidelines. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 351572ee..c70a4fb6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# Sécurix: Base OS sécurisé pour poste d'administration +# Sécurix: Base OS pour poste sécurisé Ce projet est en **alpha**, aucun support n'est proposé pour l'heure. @@ -51,6 +52,11 @@ Sécurix repose sur NixOS avec un noyau Linux personnalisé conformément aux r ## Contribuer Les contributions sont les bienvenues ! Consultez les tickets ouverts et le guide de contribution pour participer. +Vous pouvez ouvrir des tickets pour proposer des fonctionnalités et discuter de l'architecture. +Les PR générées par IA sans relecture ni test seront fermées, les contributions par le même auteur pourront être bloquées par la suite. + +Ce README est en français mais le reste du code est en anglais. + ### Lancement des tests From 3157baf3e66cf5eed00cfa61a69e6340b5b69296 Mon Sep 17 00:00:00 2001 From: agrimault-dinum Date: Mon, 13 Apr 2026 18:20:26 +0200 Subject: [PATCH 04/31] feat: add --securix-branch to upgrade command --- modules/updates/permissionless-upgrade.nix | 68 +++++++++++++++++----- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/modules/updates/permissionless-upgrade.nix b/modules/updates/permissionless-upgrade.nix index 71b83a2d..d1a14bf1 100644 --- a/modules/updates/permissionless-upgrade.nix +++ b/modules/updates/permissionless-upgrade.nix @@ -99,6 +99,10 @@ let Utile pour tester une branche locale non encore poussée, en combinaison avec .BR \-\-branch . + .TP + .BI \-\-securix\-branch " NOM" + Branche du dépôt Securix à utiliser à la place du pin npins + .RI ( NPINS_OVERRIDE_securix ). . .SH COMPORTEMENT DE MISE À JOUR .SS Branche principale @@ -145,6 +149,12 @@ let .B sudo upgrade \-\-branch ma-branche test .RE .PP + Tester une branche du dépôt securix : + .PP + .RS 4 + .B sudo upgrade \-\-securix\-branch ma\-branche\-securix test + .RE + .PP Simuler l'activation sans rien appliquer : .PP .RS 4 @@ -221,20 +231,22 @@ let réalisées sans rien appliquer. OPTIONS - --branch NOM Branche Git à utiliser - (défaut : ${config.securix.auto-updates.branch}) - --subdir CHEMIN Sous-répertoire du dépôt contenant la config NixOS - (défaut : ${self.infraRepositorySubdir}) - --do-not-pull Ne pas récupérer les changements distants avant de - reconstruire - --use-sn Utilise le serial number pour installer la mise à - jour - --help, -h Affiche cette aide et quitte + --branch NOM Branche Git à utiliser + (défaut : ${config.securix.auto-updates.branch}) + --subdir CHEMIN Sous-répertoire du dépôt contenant la config NixOS + (défaut : ${self.infraRepositorySubdir}) + --do-not-pull Ne pas récupérer les changements distants avant de + reconstruire + --securix-branch NOM Branche du dépôt securix (surcharge du npins) + --use-sn Utilise le serial number pour installer la mise à + jour + --help, -h Affiche cette aide et quitte EXEMPLES sudo upgrade switch sudo upgrade boot sudo upgrade --branch mon-correctif test + sudo upgrade --securix-branch ma-branche-securix test sudo upgrade --do-not-pull dry-activate EOF } @@ -244,6 +256,9 @@ let SUBDIR="${self.infraRepositorySubdir}" REMOTE_PULL=true USE_SN=false + SECURIX_BRANCH="" + INFRA_TEMP_DIR="" + SECURIX_OVERRIDE_TEMP_DIR="" # Parse arguments while [[ "$#" -gt 0 ]]; do @@ -264,6 +279,10 @@ let REMOTE_PULL=false shift 1 ;; + --securix-branch) + SECURIX_BRANCH="$2" + shift 2 + ;; --use-sn) USE_SN=true shift 1 @@ -291,6 +310,19 @@ let # Set the TPM2 SSH agent to retrieve the repository. export SSH_AUTH_SOCK=/var/tmp/ssh-tpm-agent.sock + upgrade_cleanup() { + local exit_code=$? + if [ -n "$INFRA_TEMP_DIR" ]; then + git -C "${self.infraRepositoryPath}" worktree remove "$INFRA_TEMP_DIR" || true + rm -rf "$INFRA_TEMP_DIR" + fi + if [ -n "$SECURIX_OVERRIDE_TEMP_DIR" ]; then + rm -rf "$SECURIX_OVERRIDE_TEMP_DIR" + fi + exit "$exit_code" + } + trap upgrade_cleanup EXIT + # Check if ${self.infraRepositoryPath} exist if [ ! -d "${self.infraRepositoryPath}/.git" ]; then echo "Repository does not exist, cloning..." @@ -316,12 +348,11 @@ let !cfg.enableAnyBranch ) ''echo "Branch $BRANCH is not eligible for manual upgrade." && exit 1''} # Create a secure temporary directory - TEMP_DIR=$(mktemp -d) - trap 'git -C "${self.infraRepositoryPath}" worktree remove "$TEMP_DIR"; rm -rf "$TEMP_DIR"' EXIT - + INFRA_TEMP_DIR=$(mktemp -d) + # Extract a worktree for the specified branch in the temporary directory - git -C "${self.infraRepositoryPath}" worktree add "$TEMP_DIR" "$BRANCH" || exit 1 - REPO_PATH="$TEMP_DIR" + git -C "${self.infraRepositoryPath}" worktree add "$INFRA_TEMP_DIR" "$BRANCH" || exit 1 + REPO_PATH="$INFRA_TEMP_DIR" # Update the worktree. # When it's not main, accept force pushes. @@ -329,6 +360,15 @@ let fi fi + # Important note: nixos-rebuild does not support passing --arg to nix-build + # https://github.com/NixOS/nixpkgs/blob/nixos-25.11/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py + if [ -n "$SECURIX_BRANCH" ]; then + SECURIX_OVERRIDE_TEMP_DIR=$(mktemp -d) + SECURIX_REPO_URL=https://github.com/cloud-gouv/securix.git + git clone --branch "$SECURIX_BRANCH" "$SECURIX_REPO_URL" "$SECURIX_OVERRIDE_TEMP_DIR" || exit 1 + export NPINS_OVERRIDE_securix="$SECURIX_OVERRIDE_TEMP_DIR" + fi + TERMINAL="${self.machine.identifier}" # Run nixos-rebuild with the given verb if [ "$USE_SN" = true ]; then From 8d9a0550d1fddd5661b638a8c053b5eb53052ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20H=C3=A9raudeau?= Date: Mon, 20 Apr 2026 17:34:47 +0200 Subject: [PATCH 05/31] config: Readme Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was a typo—it said “file,” but the licenses are actually in a folder. 🙂 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c70a4fb6..baff99f5 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,4 @@ de cette VM. ## Licence -Sécurix est distribué sous licence MIT. Voir le fichier `LICENSE` pour plus de détails. +Sécurix est distribué sous licence [MIT](https://github.com/cloud-gouv/securix/blob/main/LICENSES/MIT.txt). Voir le dossier `LICENSE` pour plus de détails. From 1fe306bef581cc5482d89f2a5b0cb72da6c002c7 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 12 Mar 2026 11:10:37 +0100 Subject: [PATCH 06/31] chore: add yubikey-reset tool --- community/yubikey-reset/default.nix | 130 ++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 community/yubikey-reset/default.nix diff --git a/community/yubikey-reset/default.nix b/community/yubikey-reset/default.nix new file mode 100644 index 00000000..8838c69d --- /dev/null +++ b/community/yubikey-reset/default.nix @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: 2026 Pauline Legrand +# +# SPDX-License-Identifier: MIT + +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.securix.yubikey-reset; +in +{ + options.securix.yubikey-reset = { + enable = lib.mkEnableOption "yubikey-reset CLI tool (factory reset & audit)"; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ + (pkgs.writeShellScriptBin "yubikey-reset" '' + set -euo pipefail + + # --- Colors --- + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' + + LOG_FILE="yubikey_erasure_report_$(date -u +%Y%m%dT%H%M%SZ).txt" + + echo -e "''${BLUE}===============================================''${NC}" + echo -e "''${BLUE} YUBIKEY FACTORY RESET & AUDIT TOOL ''${NC}" + echo -e "''${BLUE}===============================================''${NC}" + + # 1. Identify Device + echo -e "\n''${YELLOW}[STEP] Identifying Device...''${NC}" + if ! ${pkgs.yubikey-manager}/bin/ykman info; then + echo -e "''${RED}Error: No YubiKey detected. Please plug in your device.''${NC}" + exit 1 + fi + + # 2. Security Confirmation + echo -e "\n''${RED}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!''${NC}" + echo -e "''${RED}WARNING: THIS WILL PERMANENTLY ERASE ALL SECRETS!''${NC}" + echo -e "''${RED}This includes FIDO2, SSH keys, GPG keys, and 2FA codes.''${NC}" + echo -e "''${RED}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!''${NC}" + echo + read -rp "Are you sure you want to proceed with the reset? (y/N): " confirm + if [[ ! $confirm =~ ^[Yy]$ ]]; then + echo -e "\nOperation aborted by user." + exit 0 + fi + + # 3. Reset Sequence + echo -e "\n''${YELLOW}[STEP 1/5] Resetting FIDO2...''${NC}" + echo -e "''${BLUE}ACTION REQUIRED:''${NC} Unplug your YubiKey, plug it back in, and press ENTER immediately." + read -rs _ + echo "Touch the YubiKey when it flashes..." + ${pkgs.yubikey-manager}/bin/ykman fido reset -f + + echo -e "\n''${YELLOW}[STEP 2/5] Resetting OTP Slots...''${NC}" + ${pkgs.yubikey-manager}/bin/ykman otp delete 1 -f \ + || echo -e "''${YELLOW}Slot 1 already empty or restricted.''${NC}" + sleep 1 + ${pkgs.yubikey-manager}/bin/ykman otp delete 2 -f \ + || echo -e "''${YELLOW}Slot 2 already empty or restricted.''${NC}" + + echo -e "\n''${YELLOW}[STEP 3/5] Resetting PIV (Smart Card)...''${NC}" + ${pkgs.yubikey-manager}/bin/ykman piv reset -f + + echo -e "\n''${YELLOW}[STEP 4/5] Resetting OpenPGP...''${NC}" + ${pkgs.yubikey-manager}/bin/ykman openpgp reset -f + + echo -e "\n''${YELLOW}[STEP 5/5] Resetting OATH (TOTP/HOTP)...''${NC}" + ${pkgs.yubikey-manager}/bin/ykman oath reset -f + + # 4. Evidence Generation + echo -e "\n''${BLUE}===============================================''${NC}" + echo -e "''${BLUE} GENERATING ERASURE EVIDENCE ''${NC}" + echo -e "''${BLUE}===============================================''${NC}" + + { + echo "===============================================================" + echo " YUBIKEY ERASURE AUDIT REPORT" + echo " Date: $(date -u) (UTC)" + echo "===============================================================" + echo "" + echo "[1] HARDWARE IDENTIFICATION" + ${pkgs.yubikey-manager}/bin/ykman info + echo "" + echo "[2] FIDO2 STATUS (Should show 'Not set')" + ${pkgs.yubikey-manager}/bin/ykman fido info + echo "" + echo "[3] OTP STATUS (Slots should be 'empty')" + ${pkgs.yubikey-manager}/bin/ykman otp info + echo "" + echo "[4] PIV STATUS (Should show default PIN/PUK warnings)" + ${pkgs.yubikey-manager}/bin/ykman piv info + echo "" + echo "[5] OATH STATUS (Should be empty)" + ${pkgs.yubikey-manager}/bin/ykman oath accounts list + echo "" + echo "[6] OPENPGP STATUS (Keys should be 'None')" + ${pkgs.yubikey-manager}/bin/ykman openpgp info + echo "===============================================================" + echo "END OF REPORT" + } > "$LOG_FILE" + + cat "$LOG_FILE" + + echo -e "\n''${GREEN}[SUCCESS] YubiKey has been factory reset.''${NC}" + echo -e "''${GREEN}[SUCCESS] Audit report saved to: $(pwd)/$LOG_FILE''${NC}" + '') + ]; + security.sudo.extraRules = [ + { + groups = [ "operator" ]; + commands = [ + { + command = "/run/current-system/sw/bin/yubikey-reset"; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; + }; +} From f757755e95358783eac283b3542ddb2462a730c5 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 12 Mar 2026 11:12:27 +0100 Subject: [PATCH 07/31] chore: modify yubikey reset reuse --- community/yubikey-reset/default.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/community/yubikey-reset/default.nix b/community/yubikey-reset/default.nix index 8838c69d..b36ddcdc 100644 --- a/community/yubikey-reset/default.nix +++ b/community/yubikey-reset/default.nix @@ -128,3 +128,4 @@ in ]; }; } + From 0e975dfe47ae18ebc9848cace072a65e4e7d28ef Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 12 Mar 2026 11:12:56 +0100 Subject: [PATCH 08/31] modify by hook --- community/yubikey-reset/default.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/community/yubikey-reset/default.nix b/community/yubikey-reset/default.nix index b36ddcdc..8838c69d 100644 --- a/community/yubikey-reset/default.nix +++ b/community/yubikey-reset/default.nix @@ -128,4 +128,3 @@ in ]; }; } - From b573591a17d612f0750b6819339b09e6bfca49b9 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 12 Mar 2026 12:28:17 +0100 Subject: [PATCH 09/31] chore: add inventory generator tool --- community/default.nix | 4 + community/inventory-generator/default.nix | 56 + .../inventory-generator.html | 1387 +++++++++++++++++ .../inventory-generator/parse-options.py | 324 ++++ 4 files changed, 1771 insertions(+) create mode 100644 community/inventory-generator/default.nix create mode 100644 community/inventory-generator/inventory-generator.html create mode 100644 community/inventory-generator/parse-options.py diff --git a/community/default.nix b/community/default.nix index 01e8a811..a89ac852 100644 --- a/community/default.nix +++ b/community/default.nix @@ -6,5 +6,9 @@ imports = [ # Grist registration ./grist-registration + + ./yubikey-reset + + ./inventory-generator ]; } diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix new file mode 100644 index 00000000..6a6dfc7a --- /dev/null +++ b/community/inventory-generator/default.nix @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2026 Pauline Legrand +# +# SPDX-License-Identifier: MIT + +{ + config, + lib, + pkgs, + securixSrc, + repoSrc ? null, + ... +}: + +let + cfg = config.securix.inventory-generator; + + # Chemin vers le repo (optionnel) + repoArg = if repoSrc != null then "${repoSrc}" else ""; + + optionsJson = pkgs.runCommand "securix-options-json" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out + ''; + + inlineScript = pkgs.writeText "inline-options.py" '' + import sys + html = open(sys.argv[1]).read() + options = open(sys.argv[2]).read() + result = html.replace('@@SECURIX_OPTIONS@@', options) + open(sys.argv[3], 'w').write(result) + ''; + + pkg = pkgs.runCommand "inventory-generator" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + mkdir -p $out/share/inventory-generator + python3 ${inlineScript} \ + ${./inventory-generator.html} \ + ${optionsJson} \ + $out/share/inventory-generator/index.html + ''; +in +{ + options.securix.inventory-generator = { + enable = lib.mkEnableOption "Inventory generator HTML tool"; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ + (pkgs.writeShellScriptBin "inventory-generator" '' + xdg-open ${pkg}/share/inventory-generator/index.html + '') + ]; + }; +} diff --git a/community/inventory-generator/inventory-generator.html b/community/inventory-generator/inventory-generator.html new file mode 100644 index 00000000..6bec9820 --- /dev/null +++ b/community/inventory-generator/inventory-generator.html @@ -0,0 +1,1387 @@ + + + + + +Bureautix · Inventory Generator + + + + +
+ +
+ Inventory Generator +
inventory/{users,machines}/
+
+ +
+ +
+
+
+
Utilisateur
+
inventory/users/username.nix
+
+
+
+
+
+
+ chargement des options… +
+
+ +
+ + +
+
+
+
Options avancées
+
0 option(s) activée(s)
+
+
+
+
+
+
+ chargement des options… +
+
+ +
+ + +
+
+
+
Machine
+
inventory/machines/SN.nix
+
+
+
+
+
+
+ chargement des options… +
+
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/community/inventory-generator/parse-options.py b/community/inventory-generator/parse-options.py new file mode 100644 index 00000000..2965e7e6 --- /dev/null +++ b/community/inventory-generator/parse-options.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Pauline Legrand +# SPDX-License-Identifier: MIT + +# Parse les fichiers .nix de securix pour extraire les options mkOption + +import re +import json +import sys +from pathlib import Path + +def parse_type(type_str): + """Convertit une expression de type Nix en dict JSON.""" + t = type_str.strip() + if t == "types.bool": + return {"kind": "bool"} + elif t == "types.str" or t == "types.nonEmptyStr": + return {"kind": "str"} + elif t == "types.int" or t == "types.ints.positive": + return {"kind": "int"} + elif t == "types.path": + return {"kind": "path"} + elif t == "types.package": + return {"kind": "package"} + elif t.startswith("types.nullOr"): + inner = re.sub(r"^types\.nullOr\s*", "", t).strip() + return {"kind": "nullable", "inner": parse_type(inner)} + elif t.startswith("types.listOf"): + inner = re.sub(r"^types\.listOf\s*", "", t).strip() + if inner.startswith("("): + inner = inner[1:-1].strip() + return {"kind": "list", "inner": parse_type(inner)} + elif t.startswith("types.enum"): + m = re.search(r'\[([^\]]+)\]', t) + if m: + raw = m.group(1) + values = re.findall(r'"([^"]+)"', raw) + return {"kind": "enum", "values": values} + return {"kind": "enum", "values": []} + elif t.startswith("types.attrsOf"): + return {"kind": "attrsOf"} + elif t.startswith("types.submodule"): + return {"kind": "submodule"} + return {"kind": "str"} + +def extract_options_from_file(filepath, module_prefix): + """Extrait les mkOption d'un fichier .nix avec leur chemin complet.""" + content = Path(filepath).read_text(encoding="utf-8") + options = [] + + i = 0 + lines = content.split('\n') + option_stack = [] + in_options_block = False + + for line_no, line in enumerate(lines): + stripped = line.strip() + + if re.match(r'options\.(' + module_prefix + r'\.\S+)\s*=\s*\{', stripped) or \ + re.match(r'options\.(' + module_prefix + r')\s*=\s*\{', stripped): + in_options_block = True + m = re.match(r'options\.(\S+)\s*=', stripped) + if m: + option_stack = [m.group(1)] + + m_inline_enable = re.match( + r'options\.(' + module_prefix + r'\.\S+)\s*=\s*(?:lib\.)?mkEnableOption\s*(.*)', + stripped + ) + m_inline_option = re.match( + r'options\.(' + module_prefix + r'\.\S+)\s*=\s*(?:lib\.)?mkOption\s*\{', + stripped + ) + + if m_inline_enable: + full_path = m_inline_enable.group(1) + desc_part = m_inline_enable.group(2).strip().strip(';') + desc = None + m_desc = re.match(r'^"([^"]+)"', desc_part) + if m_desc: + desc = m_desc.group(1) + opt = { + "path": full_path, + "internal": False, + "visible": True, + "hasDefault": True, + "default": False, + "type": {"kind": "bool"}, + "description": desc, + "example": None, + "isEnable": True, + "enableGroup": None, + } + options.append(opt) + continue + + if m_inline_option: + full_path = m_inline_option.group(1) + block_lines = [] + depth = 1 + j = line_no + 1 + while j < len(lines) and depth > 0: + l = lines[j] + depth += l.count('{') - l.count('}') + block_lines.append(l) + j += 1 + block = '\n'.join(block_lines) + + opt = {"path": full_path, "internal": False, "visible": True, "hasDefault": False, + "isEnable": False, "enableGroup": None} + + m_type = re.search(r'type\s*=\s*((?:lib\.)?types\.[^;]+);', block) + if m_type: + type_str = m_type.group(1).strip().replace('lib.', '') + opt["type"] = parse_type(type_str) + else: + opt["type"] = {"kind": "str"} + + m_desc2 = re.search(r'description\s*=\s*(?:lib\.mdDoc\s*)?(?:\'\'(.*?)\'\'|"([^"]+)")', block, re.DOTALL) + if m_desc2: + opt["description"] = (m_desc2.group(1) or m_desc2.group(2) or "").strip() + else: + opt["description"] = None + + m_ex = re.search(r'example\s*=\s*"([^"]+)"', block) + opt["example"] = m_ex.group(1) if m_ex else None + + m_def = re.search(r'default\s*=\s*"([^"]+)"', block) + if m_def: + opt["default"] = m_def.group(1); opt["hasDefault"] = True + elif re.search(r'default\s*=\s*null', block): + opt["default"] = None; opt["hasDefault"] = True + elif re.search(r'default\s*=\s*\[\s*\]', block): + opt["default"] = []; opt["hasDefault"] = True + elif re.search(r'default\s*=\s*true', block): + opt["default"] = True; opt["hasDefault"] = True + elif re.search(r'default\s*=\s*false', block): + opt["default"] = False; opt["hasDefault"] = True + else: + opt["default"] = None + + if not opt["internal"] and opt["visible"]: + options.append(opt) + continue + + if not in_options_block: + continue + + m_enable = re.match(r'(\w+)\s*=\s*(?:lib\.)?mkEnableOption\s*', stripped) + m = re.match(r'(\w+)\s*=\s*(?:lib\.)?mkOption\s*\{', stripped) + + if m_enable and option_stack: + opt_name = m_enable.group(1) + full_path = ".".join(option_stack + [opt_name]) + opt = { + "path": full_path, + "internal": False, + "visible": True, + "hasDefault": True, + "default": False, + "type": {"kind": "bool"}, + "description": None, + "example": None, + "isEnable": True, + "enableGroup": None, + } + m_desc = re.search(r'mkEnableOption\s+"([^"]+)"', stripped) + if not m_desc: + m_desc = re.search(r"mkEnableOption\s+''([^']+)''", stripped) + if m_desc: + opt["description"] = m_desc.group(1) + if not opt["internal"] and opt["visible"]: + options.append(opt) + continue + if m and option_stack: + opt_name = m.group(1) + full_path = ".".join(option_stack + [opt_name]) + + block_lines = [] + depth = 1 + j = line_no + 1 + while j < len(lines) and depth > 0: + l = lines[j] + depth += l.count('{') - l.count('}') + block_lines.append(l) + j += 1 + block = '\n'.join(block_lines) + + opt = {"path": full_path, "internal": False, "visible": True, "hasDefault": False} + + m_type = re.search(r'type\s*=\s*(types\.[^;]+);', block) + if m_type: + opt["type"] = parse_type(m_type.group(1).strip()) + else: + opt["type"] = {"kind": "str"} + + m_desc = re.search(r"description\s*=\s*(?:lib\.mdDoc\s*)?(?:''(.*?)''|\"([^\"]+)\")", block, re.DOTALL) + if m_desc: + opt["description"] = (m_desc.group(1) or m_desc.group(2) or "").strip() + else: + opt["description"] = None + + m_ex = re.search(r'example\s*=\s*"([^"]+)"', block) + if m_ex: + opt["example"] = m_ex.group(1) + else: + opt["example"] = None + + m_def = re.search(r'default\s*=\s*"([^"]+)"', block) + if m_def: + opt["default"] = m_def.group(1) + opt["hasDefault"] = True + elif re.search(r'default\s*=\s*null', block): + opt["default"] = None + opt["hasDefault"] = True + elif re.search(r'default\s*=\s*\[\s*\]', block): + opt["default"] = [] + opt["hasDefault"] = True + elif re.search(r'default\s*=\s*true', block): + opt["default"] = True + opt["hasDefault"] = True + elif re.search(r'default\s*=\s*false', block): + opt["default"] = False + opt["hasDefault"] = True + else: + opt["default"] = None + + if re.search(r'internal\s*=\s*true', block): + opt["internal"] = True + if re.search(r'visible\s*=\s*false', block): + opt["visible"] = False + + if not opt["internal"] and opt["visible"]: + options.append(opt) + + return options + +def detect_enable_groups(options): + """ + Détecte les groupes qui ont un .enable et marque les autres options du groupe. + Ex: securix.yubikey-reset.enable → le groupe 'securix.yubikey-reset' a un enable. + Retourne un set de préfixes de groupe qui ont un enable. + """ + enable_groups = set() + for opt in options: + if opt["path"].endswith(".enable"): + prefix = opt["path"][:-len(".enable")] + enable_groups.add(prefix) + return enable_groups + + +def detect_repo_prefix(repo_path): + """ + Détecte dynamiquement le préfixe d'options utilisé dans un repo + (ex: 'bureautix', 'monprojet', ...) en excluant 'securix' et 'nixos'. + Retourne le préfixe le plus fréquent, ou None si introuvable. + """ + counts = {} + for nix_file in Path(repo_path).rglob("*.nix"): + try: + content = nix_file.read_text(encoding="utf-8", errors="replace") + except Exception: + continue + for line in content.split('\n'): + m = re.match(r'\s*options\.(\w+)\.', line) + if m: + prefix = m.group(1) + if prefix not in ('securix', 'nixos', 'lib', 'config', 'pkgs'): + counts[prefix] = counts.get(prefix, 0) + 1 + if not counts: + return None + return max(counts, key=counts.get) + + +def main(): + securix_src = Path(sys.argv[1]) + repo_src = Path(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else None + + scan_dirs = [ + (securix_src / "modules", "securix"), + (securix_src / "community", "securix"), + ] + + if repo_src and repo_src.exists(): + repo_prefix = detect_repo_prefix(repo_src) + if repo_prefix: + scan_dirs.append((repo_src, repo_prefix)) + + all_options = [] + seen_paths = set() + + for scan_dir, prefix in scan_dirs: + if not scan_dir.exists(): + continue + for nix_file in sorted(scan_dir.rglob("*.nix")): + content = nix_file.read_text(encoding="utf-8", errors="replace") + has_mkoption = "mkOption" in content or "mkEnableOption" in content + has_options = f"options.{prefix}" in content + if not has_mkoption or not has_options: + continue + try: + opts = extract_options_from_file(nix_file, prefix) + for opt in opts: + if opt["path"] not in seen_paths: + seen_paths.add(opt["path"]) + all_options.append(opt) + except Exception as e: + print(f"Warning: {nix_file}: {e}", file=sys.stderr) + + enable_groups = detect_enable_groups(all_options) + for opt in all_options: + group = ".".join(opt["path"].split(".")[:-1]) + opt["enableGroup"] = group if group in enable_groups else None + opt["isEnable"] = opt["path"].endswith(".enable") + + result = { + "schemaVersion": 1, + "options": all_options, + "enableGroups": sorted(enable_groups), + } + print(json.dumps(result, ensure_ascii=False, separators=(',', ':'))) + +if __name__ == "__main__": + main() \ No newline at end of file From 5e6f55959bf6f96396387249f690c39d72d55ccc Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 12 Mar 2026 12:28:35 +0100 Subject: [PATCH 10/31] rfc style --- community/inventory-generator/default.nix | 24 ++++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix index 6a6dfc7a..81beff42 100644 --- a/community/inventory-generator/default.nix +++ b/community/inventory-generator/default.nix @@ -17,11 +17,9 @@ let # Chemin vers le repo (optionnel) repoArg = if repoSrc != null then "${repoSrc}" else ""; - optionsJson = pkgs.runCommand "securix-options-json" - { nativeBuildInputs = [ pkgs.python3 ]; } - '' - python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out - ''; + optionsJson = pkgs.runCommand "securix-options-json" { nativeBuildInputs = [ pkgs.python3 ]; } '' + python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out + ''; inlineScript = pkgs.writeText "inline-options.py" '' import sys @@ -31,15 +29,13 @@ let open(sys.argv[3], 'w').write(result) ''; - pkg = pkgs.runCommand "inventory-generator" - { nativeBuildInputs = [ pkgs.python3 ]; } - '' - mkdir -p $out/share/inventory-generator - python3 ${inlineScript} \ - ${./inventory-generator.html} \ - ${optionsJson} \ - $out/share/inventory-generator/index.html - ''; + pkg = pkgs.runCommand "inventory-generator" { nativeBuildInputs = [ pkgs.python3 ]; } '' + mkdir -p $out/share/inventory-generator + python3 ${inlineScript} \ + ${./inventory-generator.html} \ + ${optionsJson} \ + $out/share/inventory-generator/index.html + ''; in { options.securix.inventory-generator = { From 97bd410b8a4b0873c796ad5982f5fba52ae87cfe Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 12 Mar 2026 12:35:23 +0100 Subject: [PATCH 11/31] chore: add reuse html --- community/inventory-generator/inventory-generator.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/community/inventory-generator/inventory-generator.html b/community/inventory-generator/inventory-generator.html index 6bec9820..cab4f935 100644 --- a/community/inventory-generator/inventory-generator.html +++ b/community/inventory-generator/inventory-generator.html @@ -1,9 +1,15 @@ + + -Bureautix · Inventory Generator +Securix · Inventory Generator
- -
- Inventory Generator +
inventory/{users,machines}/
@@ -732,7 +771,7 @@ type: 'text', id: `taginput-${fieldId}`, placeholder: 'Entrée pour valider', - style: 'font-size:12px;margin-top:4px;' + style: 'font-size: 14px;margin-top:4px;' }); inputEl.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ',') { @@ -828,21 +867,26 @@ const SKIP = new Set(['securix.self.user.username', 'securix.self.user.email']); const SKIP_INTERNAL = new Set(['securix.self.selfDescriptionType', 'securix.self.machine.identifier']); - const USER_OPTS_PATHS = new Set([ - 'securix.self.hashedPassword', + const USER_REQUIRED_PATHS = new Set([ 'securix.self.u2f_keys', - 'securix.self.defaultLoginShell', - 'securix.self.bit', 'securix.self.allowedVPNs', 'securix.self.teams', ]); - const userOpts = options.filter(o => USER_OPTS_PATHS.has(o.path) && !o.internal); - - if (userOpts.length) { - body.appendChild(renderSection('user', 'Sécurité & Accès', userOpts.map(o => ({ - ...o, - id: o.path, - label: o.path.split('.').pop(), + const USER_OPTIONAL_PATHS = new Set([ + 'securix.self.hashedPassword', + 'securix.self.defaultLoginShell', + 'securix.self.bit', + ]); + const userRequired = options.filter(o => USER_REQUIRED_PATHS.has(o.path) && !o.internal); + const userOptional = options.filter(o => USER_OPTIONAL_PATHS.has(o.path) && !o.internal); + if (userRequired.length) { + body.appendChild(renderSection('user', 'Sécurité & Accès', userRequired.map(o => ({ + ...o, id: o.path, label: o.path.split('.').pop(), + })))); + } + if (userOptional.length) { + body.appendChild(renderSection('user', 'Optionnel', userOptional.map(o => ({ + ...o, id: o.path, label: o.path.split('.').pop(), required: false, hasDefault: true, })))); } } @@ -858,13 +902,16 @@ const SKIP = new Set(['securix.self.machine.serialNumber', 'securix.self.machine.identifier']); const SKIP_INTERNAL = new Set(['securix.self.selfDescriptionType', 'securix.self.edition', 'securix.self.mainDisk']); - const MACHINE_OPTS_PATHS = new Set([ + const MACHINE_REQUIRED_PATHS = new Set([ 'securix.self.hardwareSKU', 'securix.self.inventoryId', + ]); + const MACHINE_OPTIONAL_PATHS = new Set([ 'securix.self.infraRepositoryPath', 'securix.self.infraRepositorySubdir', ]); - const machineOpts = options.filter(o => MACHINE_OPTS_PATHS.has(o.path) && !o.internal); + const machineRequired = options.filter(o => MACHINE_REQUIRED_PATHS.has(o.path) && !o.internal); + const machineOptional = options.filter(o => MACHINE_OPTIONAL_PATHS.has(o.path) && !o.internal); const mainDiskOpt = options.find(o => o.path === 'securix.self.mainDisk'); const diskField = mainDiskOpt ? [{ @@ -874,14 +921,20 @@ type: { kind: 'enum', values: ['/dev/nvme0n1', '/dev/sda', '/dev/vda'] }, default: '/dev/nvme0n1', hasDefault: true, + required: true, }] : []; - if (machineOpts.length || diskField.length) { + if (machineRequired.length || diskField.length) { body.appendChild(renderSection('machine', 'Configuration', [ ...diskField, - ...machineOpts.map(o => ({ ...o, id: o.path, label: o.path.split('.').pop() })) + ...machineRequired.map(o => ({ ...o, id: o.path, label: o.path.split('.').pop(), required: !o.hasDefault })) ])); } + if (machineOptional.length) { + body.appendChild(renderSection('machine', 'Optionnel', machineOptional.map(o => ({ + ...o, id: o.path, label: o.path.split('.').pop(), required: false, hasDefault: true, + })))); + } body.appendChild(renderSection('machine', 'Utilisateurs assignés', [{ id: '_machine.users', @@ -926,76 +979,162 @@ const advOpts = getAdvOpts(options); if (!advOpts.length) { - body.innerHTML = '
Aucune option avancée disponible.
'; + body.innerHTML = '
Aucune option de module disponible.
'; + updateAdvCount(); return; } const groups = groupByPrefix(advOpts); - for (const [groupName, opts] of Object.entries(groups)) { - const title = el('div', { class: 'adv-section-title' }); - title.textContent = groupName; - body.appendChild(title); - - for (const opt of opts) { - body.appendChild(buildAdvOption(opt)); - } + body.appendChild(buildAdvGroup(groupName, opts)); } - updateAdvCount(); } -function buildAdvOption(opt) { - if (!advState[opt.path]) { - advState[opt.path] = { enabled: false, target: 'user', value: null }; +const advGroupState = {}; + +function buildAdvGroup(groupName, opts) { + if (!advGroupState[groupName]) { + advGroupState[groupName] = { enabled: false, target: 'user' }; } - const s = advState[opt.path]; + const gs = advGroupState[groupName]; - const wrap = el('div', { class: 'adv-option', id: `adv-opt-${opt.path}` }); + const wrap = el('div', { class: 'adv-group', id: 'adv-group-' + groupName }); + if (gs.enabled) wrap.classList.add('enabled'); - const header = el('div', { class: 'adv-option-header' }); + const header = el('div', { class: 'adv-group-header' }); - const checkbox = el('div', { class: 'adv-checkbox', id: `adv-cb-${opt.path}` }); - if (s.enabled) checkbox.textContent = '✓'; + const checkbox = el('div', { class: 'adv-checkbox', id: 'adv-gcb-' + groupName }); + checkbox.textContent = gs.enabled ? '\u2713' : ''; - const pathEl = el('div', { class: 'adv-option-path' }); - pathEl.title = opt.description || opt.path; - pathEl.textContent = opt.path; + const nameEl = el('div', { class: 'adv-group-name' }); + nameEl.textContent = groupName; const targetToggle = el('div', { class: 'adv-target-toggle' }); - const btnUser = el('button', { class: 'adv-target-btn' + (s.target === 'user' ? ' active-user' : '') }); + const btnUser = el('button', { class: 'adv-target-btn' + (gs.target === 'user' ? ' active-user' : '') }); btnUser.textContent = 'user'; - const btnMachine = el('button', { class: 'adv-target-btn' + (s.target === 'machine' ? ' active-machine' : '') }); + const btnMachine = el('button', { class: 'adv-target-btn' + (gs.target === 'machine' ? ' active-machine' : '') }); btnMachine.textContent = 'machine'; - btnUser.onclick = (e) => { e.stopPropagation(); setAdvTarget(opt.path, 'user'); }; - btnMachine.onclick = (e) => { e.stopPropagation(); setAdvTarget(opt.path, 'machine'); }; - + btnUser.onclick = (e) => { e.stopPropagation(); setAdvGroupTarget(groupName, 'user'); }; + btnMachine.onclick = (e) => { e.stopPropagation(); setAdvGroupTarget(groupName, 'machine'); }; targetToggle.appendChild(btnUser); targetToggle.appendChild(btnMachine); header.appendChild(checkbox); - header.appendChild(pathEl); + header.appendChild(nameEl); header.appendChild(targetToggle); - header.onclick = (e) => { if (e.target === btnUser || e.target === btnMachine) return; - toggleAdvOpt(opt.path); + toggleAdvGroup(groupName); }; - wrap.appendChild(header); - const body = el('div', { class: 'adv-option-body', id: `adv-body-${opt.path}` }); - body.appendChild(renderAdvField(opt)); - wrap.appendChild(body); + const groupBody = el('div', { class: 'adv-group-body', id: 'adv-gbody-' + groupName }); + + const subOpts = opts.filter(o => !o.path.endsWith('.enable')); + + if (subOpts.length === 0) { + const info = el('div', { style: 'padding:8px 12px;font-size: 13px;color:var(--muted);font-family:IBM Plex Mono,monospace;' }); + info.textContent = 'Aucune sous-option configurable.'; + groupBody.appendChild(info); + } + + for (const opt of subOpts) { + if (!advState[opt.path]) { + advState[opt.path] = { enabled: false, value: null }; + } + + const subRow = el('div', { + class: 'adv-suboption' + (advState[opt.path].enabled ? ' checked' : ''), + id: 'adv-sub-' + opt.path + }); + const subCb = el('div', { class: 'adv-sub-checkbox', id: 'adv-scb-' + opt.path }); + subCb.textContent = advState[opt.path].enabled ? '\u2713' : ''; + + const subLabel = el('div', { class: 'adv-sub-label' }); + subLabel.textContent = opt.path.split('.').pop(); + subLabel.title = opt.description || opt.path; + + subRow.appendChild(subCb); + subRow.appendChild(subLabel); + subRow.onclick = () => toggleAdvSub(opt.path); + groupBody.appendChild(subRow); + const subField = el('div', { + class: 'adv-sub-field' + (advState[opt.path].enabled ? ' visible' : ''), + id: 'adv-sf-' + opt.path + }); + subField.appendChild(renderAdvField(opt)); + groupBody.appendChild(subField); + } + + wrap.appendChild(groupBody); return wrap; } +function toggleAdvGroup(groupName) { + advGroupState[groupName].enabled = !advGroupState[groupName].enabled; + const wrap = document.getElementById('adv-group-' + groupName); + const cb = document.getElementById('adv-gcb-' + groupName); + const enabled = advGroupState[groupName].enabled; + wrap.classList.toggle('enabled', enabled); + cb.textContent = enabled ? '\u2713' : ''; + updateAdvCount(); +} + +function setAdvGroupTarget(groupName, target) { + advGroupState[groupName].target = target; + const wrap = document.getElementById('adv-group-' + groupName); + wrap.querySelectorAll('.adv-target-btn').forEach(b => { + b.className = 'adv-target-btn'; + if (b.textContent === 'user' && target === 'user') b.classList.add('active-user'); + if (b.textContent === 'machine' && target === 'machine') b.classList.add('active-machine'); + }); +} + +function toggleAdvSub(path) { + advState[path].enabled = !advState[path].enabled; + const row = document.getElementById('adv-sub-' + path); + const cb = document.getElementById('adv-scb-' + path); + const field = document.getElementById('adv-sf-' + path); + const enabled = advState[path].enabled; + row.classList.toggle('checked', enabled); + cb.textContent = enabled ? '\u2713' : ''; + if (field) field.classList.toggle('visible', enabled); +} + +function updateAdvCount() { + const count = Object.values(advGroupState).filter(s => s.enabled).length; + document.getElementById('adv-count').textContent = count + ' module(s) activé(s)'; +} + +function generateAdvNixLines(target) { + const lines = []; + for (const [groupName, gs] of Object.entries(advGroupState)) { + if (!gs.enabled || gs.target !== target) continue; + const hasEnable = securixOptions.some(o => o.path === groupName + '.enable'); + if (hasEnable) lines.push(' ' + groupName + '.enable = true;'); + for (const [path, s] of Object.entries(advState)) { + if (!s.enabled || !path.startsWith(groupName + '.')) continue; + const opt = securixOptions.find(o => o.path === path); + if (!opt) continue; + const val = s.value; + if (val === null || val === '' || val === undefined) continue; + const type = opt.type || { kind: 'str' }; + if (type.kind === 'bool') lines.push(' ' + path + ' = ' + val + ';'); + else if (type.kind === 'list') lines.push(' ' + path + ' = ' + nixList(Array.isArray(val) ? val : []) + ';'); + else if (type.kind === 'package') lines.push(' ' + path + ' = ' + val + ';'); + else if (type.kind === 'enum') { if (val) lines.push(' ' + path + ' = ' + nixStr(val) + ';'); } + else lines.push(' ' + path + ' = ' + nixStr(val) + ';'); + } + } + return lines; +} + function renderAdvField(opt) { const type = opt.type || { kind: 'str' }; - const fieldId = `adv-field-${opt.path}`; - + const fieldId = 'adv-field-' + opt.path; if (type.kind === 'bool') { const row = el('div', { class: 'toggle-row' }); const toggle = el('div', { class: 'toggle' }); @@ -1007,17 +1146,13 @@ row.onclick = () => { advState[opt.path].value = !advState[opt.path].value; const v = advState[opt.path].value; - toggle.classList.toggle('on', v); - row.classList.toggle('active', v); - valEl.textContent = String(v); + toggle.classList.toggle('on', v); row.classList.toggle('active', v); valEl.textContent = String(v); }; row.appendChild(toggle); row.appendChild(label); row.appendChild(valEl); return row; - } else if (type.kind === 'enum') { const sel = el('select', { id: fieldId }); - const emptyOpt = el('option', { value: '' }, '— non spécifié'); - sel.appendChild(emptyOpt); + sel.appendChild(el('option', { value: '' }, '— non spécifié')); for (const v of (type.values || [])) { const o = el('option', { value: v }, v); if (v === opt.default) o.selected = true; @@ -1026,30 +1161,20 @@ advState[opt.path].value = sel.value; sel.onchange = () => { advState[opt.path].value = sel.value; }; return sel; - } else if (type.kind === 'list') { - const wrapId = `adv-wrap-${opt.path}`; + const wrapId = 'adv-wrap-' + opt.path; tagState[wrapId] = advState[opt.path].value || []; advState[opt.path].value = tagState[wrapId]; const wrap = el('div', { class: 'tag-input-wrap', id: wrapId }); - const hint = el('div', { class: 'tag-hint' }, 'Entrée pour valider'); - wrap.appendChild(hint); - const inputEl = el('input', { type: 'text', placeholder: 'Entrée pour valider', style: 'font-size:12px;margin-top:4px;' }); + wrap.appendChild(el('div', { class: 'tag-hint' }, 'Entrée pour valider')); + const inputEl = el('input', { type: 'text', placeholder: 'Entrée pour valider', style: 'font-size: 14px;margin-top:4px;' }); inputEl.onkeydown = (e) => { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault(); - const v = inputEl.value.trim().replace(/,$/, ''); - if (v) addTag(wrapId, inputEl, v); - } else if (e.key === 'Backspace' && inputEl.value === '' && tagState[wrapId].length > 0) { - removeTag(wrapId, tagState[wrapId].length - 1); - } + if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const v = inputEl.value.trim().replace(/,$/, ''); if (v) addTag(wrapId, inputEl, v); } + else if (e.key === 'Backspace' && inputEl.value === '' && tagState[wrapId].length > 0) removeTag(wrapId, tagState[wrapId].length - 1); }; wrap.onclick = () => inputEl.focus(); - const group = el('div'); - group.appendChild(wrap); - group.appendChild(inputEl); - return group; - + const grp = el('div'); grp.appendChild(wrap); grp.appendChild(inputEl); + return grp; } else if (type.kind === 'package') { const sel = el('select', { id: fieldId }); for (const s of [{value:'pkgs.bash',label:'bash'},{value:'pkgs.zsh',label:'zsh'},{value:'pkgs.fish',label:'fish'}]) { @@ -1060,13 +1185,8 @@ advState[opt.path].value = sel.value; sel.onchange = () => { advState[opt.path].value = sel.value; }; return sel; - } else { - const inp = el('input', { - type: 'text', - id: fieldId, - placeholder: opt.example ? String(opt.example) : '', - }); + const inp = el('input', { type: 'text', id: fieldId, placeholder: opt.example ? String(opt.example) : '' }); const def = opt.hasDefault && opt.default !== null ? String(opt.default) : ''; inp.value = advState[opt.path].value ?? def; advState[opt.path].value = inp.value; @@ -1075,78 +1195,6 @@ } } -function toggleAdvOpt(path) { - advState[path].enabled = !advState[path].enabled; - const wrap = document.getElementById(`adv-opt-${path}`); - const cb = document.getElementById(`adv-cb-${path}`); - if (advState[path].enabled) { - wrap.classList.add('enabled'); - cb.textContent = '✓'; - } else { - wrap.classList.remove('enabled'); - cb.textContent = ''; - } - updateAdvCount(); -} - -function setAdvTarget(path, target) { - advState[path].target = target; - const wrap = document.getElementById(`adv-opt-${path}`); - wrap.querySelectorAll('.adv-target-btn').forEach(b => { - b.className = 'adv-target-btn'; - if (b.textContent === 'user' && target === 'user') b.classList.add('active-user'); - if (b.textContent === 'machine' && target === 'machine') b.classList.add('active-machine'); - }); -} - -function updateAdvCount() { - const count = Object.values(advState).filter(s => s.enabled).length; - document.getElementById('adv-count').textContent = `${count} option(s) activée(s)`; -} - -function generateAdvNixLines(target) { - const lines = []; - const enabledGroups = new Set(); - - for (const [path, s] of Object.entries(advState)) { - if (!s.enabled || s.target !== target) continue; - const opt = securixOptions.find(o => o.path === path); - if (!opt) continue; - if (opt.enableGroup && !opt.isEnable) { - enabledGroups.add(opt.enableGroup); - } - } - - for (const group of enabledGroups) { - lines.push(` ${group}.enable = true;`); - } - - for (const [path, s] of Object.entries(advState)) { - if (!s.enabled || s.target !== target) continue; - const opt = securixOptions.find(o => o.path === path); - if (!opt) continue; - if (opt.isEnable && enabledGroups.has(opt.enableGroup || path.replace(/\.enable$/, ''))) continue; - - const val = s.value; - if (val === null || val === '' || val === undefined) continue; - - const type = opt.type || { kind: 'str' }; - - if (type.kind === 'bool') { - lines.push(` ${path} = ${val};`); - } else if (type.kind === 'list') { - const arr = Array.isArray(val) ? val : []; - lines.push(` ${path} = ${nixList(arr)};`); - } else if (type.kind === 'package') { - lines.push(` ${path} = ${val};`); - } else if (type.kind === 'enum') { - if (val) lines.push(` ${path} = ${nixStr(val)};`); - } else { - lines.push(` ${path} = ${nixStr(val)};`); - } - } - return lines; -} function addTag(wrapId, inputEl, val) { tagState[wrapId].push(val); @@ -1222,9 +1270,10 @@ lines.push(' securix.self.user = {'); lines.push(` email = ${nixStr(email)};`); lines.push(` username = ${nixStr(username)};`); - lines.push(` hashedPassword = "!";`); + const hpVal = getValue('user', 'securix.self.hashedPassword'); + lines.push(` hashedPassword = ${nixStr(hpVal && hpVal !== '!' ? hpVal : '!')};`); - const USER_GEN_OPTS = new Set(['securix.self.hashedPassword','securix.self.u2f_keys','securix.self.defaultLoginShell','securix.self.bit','securix.self.allowedVPNs','securix.self.teams']); + const USER_GEN_OPTS = new Set(['securix.self.u2f_keys','securix.self.allowedVPNs','securix.self.teams','securix.self.defaultLoginShell','securix.self.bit']); for (const opt of securixOptions) { if (!USER_GEN_OPTS.has(opt.path)) continue; if (opt.internal) continue; From ebf203521b3744ef4df65f4d6cdcb6a158bf80ff Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Wed, 1 Apr 2026 15:57:56 +0200 Subject: [PATCH 14/31] delete: yubikey reset --- community/default.nix | 4 +- community/yubikey-reset/default.nix | 130 ---------------------------- 2 files changed, 1 insertion(+), 133 deletions(-) delete mode 100644 community/yubikey-reset/default.nix diff --git a/community/default.nix b/community/default.nix index a89ac852..8a8c8bdd 100644 --- a/community/default.nix +++ b/community/default.nix @@ -6,9 +6,7 @@ imports = [ # Grist registration ./grist-registration - - ./yubikey-reset - + ./inventory-generator ]; } diff --git a/community/yubikey-reset/default.nix b/community/yubikey-reset/default.nix deleted file mode 100644 index d474259f..00000000 --- a/community/yubikey-reset/default.nix +++ /dev/null @@ -1,130 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Pauline Legrand -# -# SPDX-License-Identifier: MIT - -{ - config, - lib, - pkgs, - ... -}: - -let - cfg = config.securix.yubikey-reset; -in -{ - options.securix.yubikey-reset = { - enable = lib.mkEnableOption "yubikey-reset CLI tool (factory reset & audit)"; - }; - - config = lib.mkIf cfg.enable { - environment.systemPackages = [ - (pkgs.writeShellScriptBin "yubikey-reset" '' - set -euo pipefail - - # --- Colors --- - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - BLUE='\033[0;34m' - NC='\033[0m' - - LOG_FILE="yubikey_erasure_report_$(date -u +%Y%m%dT%H%M%SZ).txt" - - echo -e "''${BLUE}===============================================''${NC}" - echo -e "''${BLUE} YUBIKEY FACTORY RESET & AUDIT TOOL ''${NC}" - echo -e "''${BLUE}===============================================''${NC}" - - # 1. Identify Device - echo -e "\n''${YELLOW}[STEP] Identifying Device...''${NC}" - if ! ${lib.getExe pkgs.yubikey-manager} info; then - echo -e "''${RED}Error: No YubiKey detected. Please plug in your device.''${NC}" - exit 1 - fi - - # 2. Security Confirmation - echo -e "\n''${RED}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!''${NC}" - echo -e "''${RED}WARNING: THIS WILL PERMANENTLY ERASE ALL SECRETS!''${NC}" - echo -e "''${RED}This includes FIDO2, SSH keys, GPG keys, and 2FA codes.''${NC}" - echo -e "''${RED}!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!''${NC}" - echo - read -rp "Are you sure you want to proceed with the reset? (y/N): " confirm - if [[ ! $confirm =~ ^[Yy]$ ]]; then - echo -e "\nOperation aborted by user." - exit 0 - fi - - # 3. Reset Sequence - echo -e "\n''${YELLOW}[STEP 1/5] Resetting FIDO2...''${NC}" - echo -e "''${BLUE}ACTION REQUIRED:''${NC} Unplug your YubiKey, plug it back in, and press ENTER immediately." - read -rs _ - echo "Touch the YubiKey when it flashes..." - ${pkgs.yubikey-manager}/bin/ykman fido reset -f - - echo -e "\n''${YELLOW}[STEP 2/5] Resetting OTP Slots...''${NC}" - ${pkgs.yubikey-manager}/bin/ykman otp delete 1 -f \ - || echo -e "''${YELLOW}Slot 1 already empty or restricted.''${NC}" - sleep 1 - ${pkgs.yubikey-manager}/bin/ykman otp delete 2 -f \ - || echo -e "''${YELLOW}Slot 2 already empty or restricted.''${NC}" - - echo -e "\n''${YELLOW}[STEP 3/5] Resetting PIV (Smart Card)...''${NC}" - ${pkgs.yubikey-manager}/bin/ykman piv reset -f - - echo -e "\n''${YELLOW}[STEP 4/5] Resetting OpenPGP...''${NC}" - ${pkgs.yubikey-manager}/bin/ykman openpgp reset -f - - echo -e "\n''${YELLOW}[STEP 5/5] Resetting OATH (TOTP/HOTP)...''${NC}" - ${pkgs.yubikey-manager}/bin/ykman oath reset -f - - # 4. Evidence Generation - echo -e "\n''${BLUE}===============================================''${NC}" - echo -e "''${BLUE} GENERATING ERASURE EVIDENCE ''${NC}" - echo -e "''${BLUE}===============================================''${NC}" - - { - echo "===============================================================" - echo " YUBIKEY ERASURE AUDIT REPORT" - echo " Date: $(date -u) (UTC)" - echo "===============================================================" - echo "" - echo "[1] HARDWARE IDENTIFICATION" - ${pkgs.yubikey-manager}/bin/ykman info - echo "" - echo "[2] FIDO2 STATUS (Should show 'Not set')" - ${pkgs.yubikey-manager}/bin/ykman fido info - echo "" - echo "[3] OTP STATUS (Slots should be 'empty')" - ${pkgs.yubikey-manager}/bin/ykman otp info - echo "" - echo "[4] PIV STATUS (Should show default PIN/PUK warnings)" - ${pkgs.yubikey-manager}/bin/ykman piv info - echo "" - echo "[5] OATH STATUS (Should be empty)" - ${pkgs.yubikey-manager}/bin/ykman oath accounts list - echo "" - echo "[6] OPENPGP STATUS (Keys should be 'None')" - ${pkgs.yubikey-manager}/bin/ykman openpgp info - echo "===============================================================" - echo "END OF REPORT" - } > "$LOG_FILE" - - cat "$LOG_FILE" - - echo -e "\n''${GREEN}[SUCCESS] YubiKey has been factory reset.''${NC}" - echo -e "''${GREEN}[SUCCESS] Audit report saved to: $(pwd)/$LOG_FILE''${NC}" - '') - ]; - security.sudo.extraRules = [ - { - groups = [ "operator" ]; - commands = [ - { - command = "/run/current-system/sw/bin/yubikey-reset"; - options = [ "NOPASSWD" ]; - } - ]; - } - ]; - }; -} From 2cb255a652bce25c1ad1173f461ce36f3017c6eb Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Wed, 1 Apr 2026 15:58:29 +0200 Subject: [PATCH 15/31] nixfmt-rfc-style --- community/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/default.nix b/community/default.nix index 8a8c8bdd..ea839eb6 100644 --- a/community/default.nix +++ b/community/default.nix @@ -6,7 +6,7 @@ imports = [ # Grist registration ./grist-registration - + ./inventory-generator ]; } From 5ca4b56a167e986bdaa22945b8c72af9a5661aef Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Wed, 8 Apr 2026 17:15:25 +0200 Subject: [PATCH 16/31] fix: delete font import from internet --- .../inventory-generator.html | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/community/inventory-generator/inventory-generator.html b/community/inventory-generator/inventory-generator.html index 3ab42663..c817db41 100644 --- a/community/inventory-generator/inventory-generator.html +++ b/community/inventory-generator/inventory-generator.html @@ -11,8 +11,6 @@ Inventory Generator + + + Inventory Generator + @@ -566,7 +19,6 @@
-
@@ -588,12 +40,11 @@
-
Options avancées
-
0 option(s) activée(s)
+
0 module(s) activé(s)
@@ -608,7 +59,6 @@
-
@@ -631,7 +81,6 @@
- - + \ No newline at end of file diff --git a/community/inventory-generator/inventory-generator.js b/community/inventory-generator/inventory-generator.js new file mode 100644 index 00000000..f5b0895b --- /dev/null +++ b/community/inventory-generator/inventory-generator.js @@ -0,0 +1,893 @@ +/* + * SPDX-FileCopyrightText: 2026 Pauline Legrand + * SPDX-License-Identifier: MIT + */ + +const state = { user: {}, machine: {} }; +const tagState = {}; +const advState = {}; +const advGroupState = {}; + +let securixOptions = []; +let currentFilename = ''; +let currentContent = ''; + + +const FIXED_USER_FIELDS = [ + { + section: 'Identité', + fields: [ + { + id: 'securix.self.user.username', + label: 'username', + type: 'str', + required: true, + placeholder: 'dtintin', + onInput: function (v) { + document.getElementById('u-filename-preview').textContent = v || 'username'; + } + }, + { + id: 'securix.self.user.email', + label: 'email', + type: 'email', + required: true, + placeholder: 'dupont.tintin@email.fr' + } + ] + } +]; + +const FIXED_MACHINE_FIELDS = [ + { + section: 'Matériel', + fields: [ + { + id: 'securix.self.machine.serialNumber', + label: 'Numéro de série', + type: 'str', + required: true, + placeholder: 'PF63VYZ9', + uppercase: true, + onInput: function (v) { + document.getElementById('m-filename-preview').textContent = v.toUpperCase() || 'SN'; + } + } + ] + } +]; + + +function el(tag, attrs, ...children) { + attrs = attrs || {}; + const e = document.createElement(tag); + + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') { + e.className = v; + } else if (k.startsWith('on')) { + e.addEventListener(k.slice(2), v); + } else { + e.setAttribute(k, v); + } + } + + for (const c of children) { + if (typeof c === 'string') { + e.appendChild(document.createTextNode(c)); + } else if (c) { + e.appendChild(c); + } + } + + return e; +} + + +function renderInputFor(opt, stateTarget, stateKey) { + const type = opt.type || { kind: 'str' }; + const key = stateKey || opt.id || opt.path; + + if (type.kind === 'bool') { + const init = stateTarget + ? (stateTarget[key] != null ? stateTarget[key] : (opt.default != null ? opt.default : false)) + : (opt.default != null ? opt.default : false); + + const row = el('div', { class: 'toggle-row' + (init ? ' active' : '') }); + const tog = el('div', { class: 'toggle' + (init ? ' on' : '') }); + const valEl = el('div', { class: 'toggle-value' }, String(init)); + + if (stateTarget) { + stateTarget[key] = init; + } + + row.onclick = function () { + const v = !row.classList.contains('active'); + row.classList.toggle('active', v); + tog.classList.toggle('on', v); + valEl.textContent = String(v); + if (stateTarget) { + stateTarget[key] = v; + } + }; + + row.append(tog, el('div', { class: 'toggle-label' }, key), valEl); + return row; + + } else if (type.kind === 'enum') { + const sel = el('select'); + + if (!opt.required) { + sel.appendChild(el('option', { value: '' }, '— non spécifié')); + } + + for (const v of (type.values || [])) { + const o = el('option', { value: v }, v); + if (v === opt.default) { + o.selected = true; + } + sel.appendChild(o); + } + + if (stateTarget) { + stateTarget[key] = sel.value; + } + + sel.onchange = function () { + if (stateTarget) { + stateTarget[key] = sel.value; + } + }; + + return sel; + + } else if (type.kind === 'list') { + const wrapId = 'wrap-' + key; + tagState[wrapId] = (stateTarget && stateTarget[key]) || []; + + if (stateTarget) { + stateTarget[key] = tagState[wrapId]; + } + + const hintText = opt.placeholder || + (opt.example + ? 'ex: ' + (Array.isArray(opt.example) ? opt.example[0] : opt.example) + : 'Entrée pour valider'); + + const wrap = el('div', { class: 'tag-input-wrap', id: wrapId }); + wrap.appendChild(el('div', { class: 'tag-hint' }, hintText)); + + const inp = el('input', { + type: 'text', + placeholder: 'Entrée pour valider', + style: 'font-size:14px;margin-top:4px;' + }); + + inp.onkeydown = function (e) { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const v = inp.value.trim().replace(/,$/, ''); + if (v) { + addTag(wrapId, inp, v); + } + } else if (e.key === 'Backspace' && !inp.value && tagState[wrapId].length) { + removeTag(wrapId, tagState[wrapId].length - 1); + } + }; + + if (key.includes('u2f')) { + inp.oninput = function () { + const v = inp.value.trim(); + if (v.endsWith('+presence+pin') || v.endsWith('+presence') || v.endsWith('+pin')) { + addTag(wrapId, inp, v); + } + }; + } + + wrap.onclick = function () { + inp.focus(); + }; + + const grp = el('div'); + grp.append(wrap, inp); + return grp; + + } else if (type.kind === 'package') { + const sel = el('select'); + const shells = [ + ['pkgs.bash', 'bash'], + ['pkgs.zsh', 'zsh'], + ['pkgs.fish', 'fish'], + ['pkgs.nushell', 'nushell'] + ]; + + for (const [val, lbl] of shells) { + const o = el('option', { value: val }, lbl); + if (val === (opt.default || 'pkgs.zsh')) { + o.selected = true; + } + sel.appendChild(o); + } + + if (stateTarget) { + stateTarget[key] = sel.value; + } + + sel.onchange = function () { + if (stateTarget) { + stateTarget[key] = sel.value; + } + }; + + return sel; + + } else { + const inputType = (opt.id || '').includes('email') ? 'email' : 'text'; + const inp = el('input', { + type: inputType, + placeholder: opt.placeholder || + (opt.example ? String(Array.isArray(opt.example) ? opt.example[0] : opt.example) : '') + }); + + if (opt.uppercase) { + inp.style.textTransform = 'uppercase'; + } + + const def = (opt.hasDefault && opt.default !== null) ? String(opt.default) : ''; + const currentVal = (stateTarget && stateTarget[key] != null) ? stateTarget[key] : def; + inp.value = currentVal; + + if (stateTarget) { + stateTarget[key] = currentVal; + } + + inp.oninput = function () { + let v = inp.value; + if (opt.uppercase) { + v = v.toUpperCase(); + } + if (stateTarget) { + stateTarget[key] = v; + } + if (opt.onInput) { + opt.onInput(v); + } + }; + + return inp; + } +} + + +function renderField(panelKey, opt) { + const group = el('div', { class: 'field-group' }); + const lbl = el('label'); + + lbl.textContent = opt.label || opt.id.split('.').pop(); + + if (opt.required) { + lbl.appendChild(el('span', { class: 'required' }, ' *')); + } + + if (!opt.required && !opt.hasDefault) { + lbl.appendChild(el('span', { class: 'hint' }, ' optionnel')); + } + + if (opt.description) { + const tip = el('span', { class: 'tooltip' }); + tip.append( + el('span', { class: 'tooltip-icon' }, '?'), + el('span', { class: 'tooltip-text' }, opt.description) + ); + lbl.appendChild(tip); + } + + group.appendChild(lbl); + group.appendChild(renderInputFor(opt, state[panelKey], opt.id)); + + if (opt.info) { + group.appendChild(el('div', { class: 'info-row' }, opt.info)); + } + + return group; +} + +function renderSection(panelKey, sectionLabel, fields) { + const frag = document.createDocumentFragment(); + frag.appendChild(el('div', { class: 'divider' })); + + const labelEl = el('div', { class: 'section-label' }); + labelEl.textContent = sectionLabel; + frag.appendChild(labelEl); + + for (const f of fields) { + frag.appendChild(renderField(panelKey, f)); + } + + return frag; +} + + +function buildUserPanel(options) { + const body = document.getElementById('user-panel-body'); + body.innerHTML = ''; + + for (const s of FIXED_USER_FIELDS) { + body.appendChild(renderSection('user', s.section, s.fields)); + } + + const toRow = function (o) { + return Object.assign({}, o, { id: o.path, label: o.path.split('.').pop() }); + }; + + const reqPaths = new Set(['securix.self.u2f_keys', 'securix.self.allowedVPNs', 'securix.self.teams']); + const optPaths = new Set(['securix.self.hashedPassword', 'securix.self.defaultLoginShell', 'securix.self.bit']); + + const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); + const opt = options.filter(function (o) { return optPaths.has(o.path) && !o.internal; }); + + if (req.length) { + body.appendChild(renderSection('user', 'Sécurité & Accès', req.map(toRow))); + } + + if (opt.length) { + body.appendChild(renderSection('user', 'Optionnel', opt.map(function (o) { + return Object.assign(toRow(o), { required: false, hasDefault: true }); + }))); + } +} + +function buildMachinePanel(options) { + const body = document.getElementById('machine-panel-body'); + body.innerHTML = ''; + + for (const s of FIXED_MACHINE_FIELDS) { + body.appendChild(renderSection('machine', s.section, s.fields)); + } + + const toRow = function (o) { + return Object.assign({}, o, { id: o.path, label: o.path.split('.').pop(), required: !o.hasDefault }); + }; + + const mainDiskOpt = options.find(function (o) { return o.path === 'securix.self.mainDisk'; }); + + const diskField = mainDiskOpt + ? [Object.assign({}, mainDiskOpt, { + id: mainDiskOpt.path, + label: 'Disque principal', + type: { kind: 'enum', values: ['/dev/nvme0n1', '/dev/sda', '/dev/vda'] }, + default: '/dev/nvme0n1', + hasDefault: true, + required: true + })] + : []; + + const reqPaths = new Set(['securix.self.hardwareSKU', 'securix.self.inventoryId']); + const optPaths = new Set(['securix.self.infraRepositoryPath', 'securix.self.infraRepositorySubdir']); + + const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); + const opt = options.filter(function (o) { return optPaths.has(o.path) && !o.internal; }); + + if (diskField.length || req.length) { + body.appendChild(renderSection('machine', 'Configuration', diskField.concat(req.map(toRow)))); + } + + if (opt.length) { + body.appendChild(renderSection('machine', 'Optionnel', opt.map(function (o) { + return Object.assign(toRow(o), { required: false, hasDefault: true }); + }))); + } + + body.appendChild(renderSection('machine', 'Utilisateurs assignés', [ + { + id: '_machine.users', + label: 'users', + type: { kind: 'list' }, + placeholder: 'ex: dtintin, alice', + description: 'Usernames des comptes utilisateur assignés à cette machine.' + } + ])); +} + +function buildAdvPanel(options) { + const body = document.getElementById('adv-panel-body'); + body.innerHTML = ''; + + const advOpts = options.filter(function (o) { + return !o.path.startsWith('securix.self.') && !o.internal; + }); + + if (!advOpts.length) { + const msg = el('div', { class: 'info-row' }); + msg.style.padding = '20px'; + msg.textContent = 'Aucune option de module disponible.'; + body.appendChild(msg); + updateAdvCount(); + return; + } + + const groups = {}; + for (const o of advOpts) { + const g = o.path.split('.').slice(0, 2).join('.'); + if (!groups[g]) { + groups[g] = []; + } + groups[g].push(o); + } + + for (const [name, opts] of Object.entries(groups)) { + body.appendChild(buildAdvGroup(name, opts)); + } + + updateAdvCount(); +} + +function buildAdvGroup(groupName, opts) { + if (!advGroupState[groupName]) { + advGroupState[groupName] = { enabled: false, target: 'user' }; + } + + const gs = advGroupState[groupName]; + + const wrap = el('div', { class: 'adv-group' + (gs.enabled ? ' enabled' : ''), id: 'adv-group-' + groupName }); + const header = el('div', { class: 'adv-group-header' }); + const checkbox = el('div', { class: 'adv-checkbox', id: 'adv-gcb-' + groupName }, gs.enabled ? '✓' : ''); + const nameEl = el('div', { class: 'adv-group-name' }, groupName); + + const btnUser = el('button', { class: 'adv-target-btn' + (gs.target === 'user' ? ' active-user' : '') }, 'user'); + const btnMachine = el('button', { class: 'adv-target-btn' + (gs.target === 'machine' ? ' active-machine' : '') }, 'machine'); + const targetToggle = el('div', { class: 'adv-target-toggle' }); + + btnUser.onclick = function (e) { + e.stopPropagation(); + setAdvGroupTarget(groupName, 'user'); + }; + + btnMachine.onclick = function (e) { + e.stopPropagation(); + setAdvGroupTarget(groupName, 'machine'); + }; + + targetToggle.append(btnUser, btnMachine); + + header.onclick = function (e) { + if (e.target !== btnUser && e.target !== btnMachine) { + toggleAdvGroup(groupName); + } + }; + + header.append(checkbox, nameEl, targetToggle); + wrap.appendChild(header); + + const groupBody = el('div', { class: 'adv-group-body', id: 'adv-gbody-' + groupName }); + const subOpts = opts.filter(function (o) { return !o.path.endsWith('.enable'); }); + + if (!subOpts.length) { + const noOpt = el('div'); + noOpt.style.cssText = 'padding:8px 12px;font-size:13px;color:var(--muted);font-family:var(--mono)'; + noOpt.textContent = 'Aucune sous-option configurable.'; + groupBody.appendChild(noOpt); + } + + for (const opt of subOpts) { + if (!advState[opt.path]) { + advState[opt.path] = { enabled: false, value: null }; + } + + const s = advState[opt.path]; + + const subRow = el('div', { class: 'adv-suboption' + (s.enabled ? ' checked' : ''), id: 'adv-sub-' + opt.path }); + const subCb = el('div', { class: 'adv-sub-checkbox', id: 'adv-scb-' + opt.path }, s.enabled ? '✓' : ''); + const subLabel = el('div', { class: 'adv-sub-label' }, opt.path.split('.').pop()); + subLabel.title = opt.description || opt.path; + + subRow.append(subCb, subLabel); + subRow.onclick = function () { + toggleAdvSub(opt.path); + }; + + groupBody.appendChild(subRow); + + const subField = el('div', { class: 'adv-sub-field' + (s.enabled ? ' visible' : ''), id: 'adv-sf-' + opt.path }); + subField.appendChild(renderInputFor(opt, s, 'value')); + groupBody.appendChild(subField); + } + + wrap.appendChild(groupBody); + return wrap; +} + + +function toggleAdvGroup(groupName) { + const gs = advGroupState[groupName]; + gs.enabled = !gs.enabled; + document.getElementById('adv-group-' + groupName).classList.toggle('enabled', gs.enabled); + document.getElementById('adv-gcb-' + groupName).textContent = gs.enabled ? '✓' : ''; + updateAdvCount(); +} + +function setAdvGroupTarget(groupName, target) { + advGroupState[groupName].target = target; + + document.getElementById('adv-group-' + groupName) + .querySelectorAll('.adv-target-btn') + .forEach(function (b) { + b.className = 'adv-target-btn'; + if (b.textContent === 'user' && target === 'user') { + b.classList.add('active-user'); + } + if (b.textContent === 'machine' && target === 'machine') { + b.classList.add('active-machine'); + } + }); +} + +function toggleAdvSub(path) { + const s = advState[path]; + s.enabled = !s.enabled; + document.getElementById('adv-sub-' + path).classList.toggle('checked', s.enabled); + document.getElementById('adv-scb-' + path).textContent = s.enabled ? '✓' : ''; + + const subField = document.getElementById('adv-sf-' + path); + if (subField) { + subField.classList.toggle('visible', s.enabled); + } +} + +function updateAdvCount() { + const count = Object.values(advGroupState).filter(function (s) { return s.enabled; }).length; + document.getElementById('adv-count').textContent = count + ' module(s) activé(s)'; +} + + +function nixStr(s) { + return '"' + s + '"'; +} + +function nixList(arr) { + if (!arr || !arr.length) { + return '[ ]'; + } + return '[ ' + arr.map(nixStr).join(' ') + ' ]'; +} + +function generateAdvNixLines(target) { + const lines = []; + + for (const [groupName, gs] of Object.entries(advGroupState)) { + if (!gs.enabled || gs.target !== target) { + continue; + } + + const hasEnable = securixOptions.some(function (o) { return o.path === groupName + '.enable'; }); + if (hasEnable) { + lines.push(' ' + groupName + '.enable = true;'); + } + + for (const [path, s] of Object.entries(advState)) { + if (!s.enabled || !path.startsWith(groupName + '.') || s.value == null || s.value === '') { + continue; + } + + const opt = securixOptions.find(function (o) { return o.path === path; }); + if (!opt) { + continue; + } + + const k = opt.type && opt.type.kind; + + if (k === 'bool') { + lines.push(' ' + path + ' = ' + s.value + ';'); + } else if (k === 'list') { + lines.push(' ' + path + ' = ' + nixList(Array.isArray(s.value) ? s.value : []) + ';'); + } else if (k === 'package') { + lines.push(' ' + path + ' = ' + s.value + ';'); + } else if (s.value) { + lines.push(' ' + path + ' = ' + nixStr(s.value) + ';'); + } + } + } + + return lines; +} + +function generateUserNix() { + const username = (getValue('user', 'securix.self.user.username') || '').trim(); + const email = (getValue('user', 'securix.self.user.email') || '').trim(); + const hpVal = getValue('user', 'securix.self.hashedPassword'); + + const lines = [ + '{ pkgs, ... }:', + '{', + ' securix.self.user = {', + ' email = ' + nixStr(email) + ';', + ' username = ' + nixStr(username) + ';', + ' hashedPassword = ' + nixStr(hpVal && hpVal !== '!' ? hpVal : '!') + ';' + ]; + + const OPTS = new Set([ + 'securix.self.u2f_keys', + 'securix.self.allowedVPNs', + 'securix.self.teams', + 'securix.self.defaultLoginShell', + 'securix.self.bit' + ]); + + for (const opt of securixOptions) { + if (!OPTS.has(opt.path) || opt.internal) { + continue; + } + + const key = opt.path.replace('securix.self.', ''); + const val = getValue('user', opt.path); + const k = opt.type && opt.type.kind; + + if (k === 'bool') { + lines.push(' ' + key + ' = ' + val + ';'); + } else if (k === 'list') { + const arr = val || []; + if (key === 'u2f_keys' && arr.length) { + lines.push(' ' + key + ' = [\n' + arr.map(function (v) { return ' ' + nixStr(v); }).join('\n') + '\n ];'); + } else { + lines.push(' ' + key + ' = ' + nixList(arr) + ';'); + } + } else if (k === 'package') { + if (val) { + lines.push(' ' + key + ' = ' + val + ';'); + } + } else if (k === 'enum') { + if (val) { + lines.push(' ' + key + ' = ' + nixStr(val) + ';'); + } + } else if (val && val !== (opt.hasDefault ? String(opt.default) : '')) { + lines.push(' ' + key + ' = ' + nixStr(val) + ';'); + } + } + + lines.push(' };'); + lines.push.apply(lines, generateAdvNixLines('user')); + lines.push('}'); + + return lines.join('\n') + '\n'; +} + +function generateMachineNix() { + const sn = (getValue('machine', 'securix.self.machine.serialNumber') || '').toUpperCase().trim(); + const disk = getValue('machine', 'securix.self.mainDisk') || '/dev/nvme0n1'; + const users = getValue('machine', '_machine.users') || []; + + const lines = [ + '{', + ' securix.self.mainDisk = ' + nixStr(disk) + ';', + ' securix.self.machine = {', + ' serialNumber = ' + nixStr(sn) + ';' + ]; + + const OPTS = new Set([ + 'securix.self.hardwareSKU', + 'securix.self.inventoryId', + 'securix.self.infraRepositoryPath', + 'securix.self.infraRepositorySubdir' + ]); + + for (const opt of securixOptions) { + if (!OPTS.has(opt.path) || opt.internal) { + continue; + } + + const key = opt.path.replace('securix.self.', ''); + const val = getValue('machine', opt.path); + const k = opt.type && opt.type.kind; + + if (k === 'bool') { + lines.push(' ' + key + ' = ' + val + ';'); + } else if (k === 'list') { + lines.push(' ' + key + ' = ' + nixList(val || []) + ';'); + } else if (k === 'enum') { + if (val) { + lines.push(' ' + key + ' = ' + nixStr(val) + ';'); + } + } else if (val && val !== (opt.hasDefault ? String(opt.default) : '')) { + lines.push(' ' + key + ' = ' + nixStr(val) + ';'); + } + } + + if (users.length) { + lines.push(' users = ' + nixList(users) + ';'); + } else { + lines.push(' users = [ ]; # aucun utilisateur assigné'); + } + + lines.push(' };'); + lines.push.apply(lines, generateAdvNixLines('machine')); + lines.push('}'); + + return lines.join('\n') + '\n'; +} + +function highlight(code) { + return code + .replace(/(#.*)/g, '$1') + .replace(/(\"(?:[^\"\\]|\\.)*\")/g, '$1') + .replace(/\b(true)\b/g, 'true') + .replace(/\b(false)\b/g, 'false'); +} + + +function addTag(wrapId, inp, val) { + tagState[wrapId].push(val); + inp.value = ''; + renderTags(wrapId); +} + +function removeTag(wrapId, idx) { + tagState[wrapId].splice(idx, 1); + renderTags(wrapId); +} + +function renderTags(wrapId) { + const wrap = document.getElementById(wrapId); + if (!wrap) { + return; + } + + wrap.querySelectorAll('.tag, .tag-hint').forEach(function (t) { t.remove(); }); + + const arr = tagState[wrapId]; + + if (!arr.length) { + wrap.appendChild(el('div', { class: 'tag-hint' }, 'Entrée pour valider')); + return; + } + + arr.forEach(function (v, i) { + const tag = el('div', { class: 'tag' }); + const span = el('span'); + span.title = v; + span.textContent = v.length > 40 ? v.slice(0, 38) + '…' : v; + + const rm = el('span', { class: 'tag-remove' }, '×'); + rm.onclick = function (e) { + e.stopPropagation(); + removeTag(wrapId, i); + }; + + tag.append(span, rm); + wrap.appendChild(tag); + }); +} + + +function getValue(panelKey, optId) { + const wrapId = 'wrap-' + panelKey + '__' + optId; + return (tagState[wrapId] !== undefined) ? tagState[wrapId] : state[panelKey][optId]; +} + +function resetPanel(panelKey) { + state[panelKey] = {}; + + for (const [wrapId, arr] of Object.entries(tagState)) { + if (wrapId.includes(panelKey + '__') || wrapId.includes('wrap-' + panelKey)) { + arr.length = 0; + renderTags(wrapId); + } + } + + if (panelKey === 'user') { + buildUserPanel(securixOptions); + } else { + buildMachinePanel(securixOptions); + } + + buildAdvPanel(securixOptions); +} + + +function previewUser() { + const username = (getValue('user', 'securix.self.user.username') || '').trim(); + const email = (getValue('user', 'securix.self.user.email') || '').trim(); + + if (!username || !email) { + setStatus('u-status', '× username et email requis', true); + return; + } + + currentContent = generateUserNix(); + currentFilename = username + '.nix'; + showModal('inventory/users/' + currentFilename, currentContent); + setStatus('u-status', ''); +} + +function previewMachine() { + const sn = (getValue('machine', 'securix.self.machine.serialNumber') || '').trim(); + + if (!sn) { + setStatus('m-status', '× numéro de série requis', true); + return; + } + + currentContent = generateMachineNix(); + currentFilename = sn.toUpperCase() + '.nix'; + showModal('inventory/machines/' + currentFilename, currentContent); + setStatus('m-status', ''); +} + +function showModal(title, content) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-content').innerHTML = highlight(content); + document.getElementById('modal').classList.add('visible'); +} + +function saveFile() { + const blob = new Blob([currentContent], { type: 'text/plain' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = currentFilename; + a.click(); + URL.revokeObjectURL(a.href); +} + +function copyContent() { + navigator.clipboard.writeText(currentContent).then(function () { + const btn = document.querySelector('.modal-footer .btn-secondary'); + const orig = btn.textContent; + btn.textContent = 'Copié !'; + setTimeout(function () { + btn.textContent = orig; + }, 1500); + }); +} + +function closeModal(e) { + if (e.target === document.getElementById('modal')) { + closeModalBtn(); + } +} + +function closeModalBtn() { + document.getElementById('modal').classList.remove('visible'); +} + +function setStatus(id, msg, err) { + const e = document.getElementById(id); + e.textContent = msg; + e.className = 'status' + (err ? ' err' : (msg ? ' ok' : '')); +} + + +const FALLBACK_OPTIONS = [ + { path: 'securix.self.mainDisk', type: { kind: 'str' }, description: 'Disque du système', example: '/dev/nvme0n1', hasDefault: false, internal: false }, + { path: 'securix.self.user.u2f_keys', type: { kind: 'list' }, description: 'Clés U2F', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.user.allowedVPNs', type: { kind: 'list' }, description: 'VPNs autorisés', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.user.teams', type: { kind: 'list' }, description: 'Équipes', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.machine.hardwareSKU', type: { kind: 'enum', values: ['x280', 'elitebook645g11', 'latitude5340', 't14g6', 'x9-15', 'e14-g7'] }, description: 'Identifiant matériel', hasDefault: false, internal: false }, + { path: 'securix.self.machine.inventoryId', type: { kind: 'int' }, description: "Numéro d'inventaire", hasDefault: false, internal: false }, + { path: 'securix.self.machine.users', type: { kind: 'list' }, description: 'Utilisateurs assignés', default: [], hasDefault: true, internal: false } +]; + +function loadOptions() { + const data = @@SECURIX_OPTIONS@@; + securixOptions = data.options || FALLBACK_OPTIONS; + + for (const opt of securixOptions) { + if (opt.isEnable === undefined) { + opt.isEnable = opt.path.endsWith('.enable'); + } + if (opt.enableGroup === undefined) { + const g = opt.path.split('.').slice(0, -1).join('.'); + opt.enableGroup = securixOptions.some(function (o) { return o.path === g + '.enable'; }) ? g : null; + } + } + + buildUserPanel(securixOptions); + buildAdvPanel(securixOptions); + buildMachinePanel(securixOptions); +} + +document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + closeModalBtn(); + } +}); + +loadOptions(); \ No newline at end of file From a243f37f72d26120ae82744f372c999ad013aab7 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 9 Apr 2026 11:26:27 +0200 Subject: [PATCH 18/31] fix: add css and js --- community/inventory-generator/default.nix | 34 ++++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix index 81beff42..43270521 100644 --- a/community/inventory-generator/default.nix +++ b/community/inventory-generator/default.nix @@ -17,25 +17,33 @@ let # Chemin vers le repo (optionnel) repoArg = if repoSrc != null then "${repoSrc}" else ""; - optionsJson = pkgs.runCommand "securix-options-json" { nativeBuildInputs = [ pkgs.python3 ]; } '' - python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out - ''; + optionsJson = pkgs.runCommand "securix-options-json" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out + ''; inlineScript = pkgs.writeText "inline-options.py" '' import sys - html = open(sys.argv[1]).read() + js = open(sys.argv[1]).read() options = open(sys.argv[2]).read() - result = html.replace('@@SECURIX_OPTIONS@@', options) + result = js.replace('@@SECURIX_OPTIONS@@', options) open(sys.argv[3], 'w').write(result) ''; - pkg = pkgs.runCommand "inventory-generator" { nativeBuildInputs = [ pkgs.python3 ]; } '' - mkdir -p $out/share/inventory-generator - python3 ${inlineScript} \ - ${./inventory-generator.html} \ - ${optionsJson} \ - $out/share/inventory-generator/index.html - ''; + pkg = pkgs.runCommand "inventory-generator" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + mkdir -p $out/share/inventory-generator + + cp ${./inventory-generator.html} $out/share/inventory-generator/inventory-generator.html + cp ${./inventory-generator.css} $out/share/inventory-generator/inventory-generator.css + + python3 ${inlineScript} \ + ${./inventory-generator.js} \ + ${optionsJson} \ + $out/share/inventory-generator/inventory-generator.js + ''; in { options.securix.inventory-generator = { @@ -45,7 +53,7 @@ in config = lib.mkIf cfg.enable { environment.systemPackages = [ (pkgs.writeShellScriptBin "inventory-generator" '' - xdg-open ${pkg}/share/inventory-generator/index.html + xdg-open ${pkg}/share/inventory-generator/inventory-generator.html '') ]; }; From 2c344df2001c612c4a14f9d049729b5c7431b7df Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 9 Apr 2026 11:27:00 +0200 Subject: [PATCH 19/31] nixfmt-rfc-style --- community/inventory-generator/default.nix | 28 ++++++++++------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix index 43270521..0e589766 100644 --- a/community/inventory-generator/default.nix +++ b/community/inventory-generator/default.nix @@ -17,11 +17,9 @@ let # Chemin vers le repo (optionnel) repoArg = if repoSrc != null then "${repoSrc}" else ""; - optionsJson = pkgs.runCommand "securix-options-json" - { nativeBuildInputs = [ pkgs.python3 ]; } - '' - python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out - ''; + optionsJson = pkgs.runCommand "securix-options-json" { nativeBuildInputs = [ pkgs.python3 ]; } '' + python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out + ''; inlineScript = pkgs.writeText "inline-options.py" '' import sys @@ -31,19 +29,17 @@ let open(sys.argv[3], 'w').write(result) ''; - pkg = pkgs.runCommand "inventory-generator" - { nativeBuildInputs = [ pkgs.python3 ]; } - '' - mkdir -p $out/share/inventory-generator + pkg = pkgs.runCommand "inventory-generator" { nativeBuildInputs = [ pkgs.python3 ]; } '' + mkdir -p $out/share/inventory-generator - cp ${./inventory-generator.html} $out/share/inventory-generator/inventory-generator.html - cp ${./inventory-generator.css} $out/share/inventory-generator/inventory-generator.css + cp ${./inventory-generator.html} $out/share/inventory-generator/inventory-generator.html + cp ${./inventory-generator.css} $out/share/inventory-generator/inventory-generator.css - python3 ${inlineScript} \ - ${./inventory-generator.js} \ - ${optionsJson} \ - $out/share/inventory-generator/inventory-generator.js - ''; + python3 ${inlineScript} \ + ${./inventory-generator.js} \ + ${optionsJson} \ + $out/share/inventory-generator/inventory-generator.js + ''; in { options.securix.inventory-generator = { From 8b2a91fd9f163da2c2aec70f96b46efee6617d8b Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 9 Apr 2026 11:56:31 +0200 Subject: [PATCH 20/31] chore: add retex user modification --- .../inventory-generator.js | 74 +++++++++++++++---- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/community/inventory-generator/inventory-generator.js b/community/inventory-generator/inventory-generator.js index f5b0895b..2ff36059 100644 --- a/community/inventory-generator/inventory-generator.js +++ b/community/inventory-generator/inventory-generator.js @@ -31,7 +31,6 @@ const FIXED_USER_FIELDS = [ id: 'securix.self.user.email', label: 'email', type: 'email', - required: true, placeholder: 'dupont.tintin@email.fr' } ] @@ -163,12 +162,42 @@ function renderInputFor(opt, stateTarget, stateKey) { style: 'font-size:14px;margin-top:4px;' }); + let isPasting = false; + + inp.addEventListener('paste', function (e) { + e.preventDefault(); + isPasting = true; + + const raw = (e.clipboardData || window.clipboardData).getData('text'); + const parts = raw.split(/[\n\r,]+/).map(function (s) { return s.trim(); }).filter(Boolean); + + if (parts.length === 0) { + isPasting = false; + return; + } + + if (parts.length === 1) { + inp.value = parts[0]; + isPasting = false; + return; + } + + for (const part of parts) { + addTag(wrapId, part); + } + inp.value = ''; + isPasting = false; + }); + inp.onkeydown = function (e) { + if (isPasting) { + return; + } if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const v = inp.value.trim().replace(/,$/, ''); if (v) { - addTag(wrapId, inp, v); + addTag(wrapId, v, inp); } } else if (e.key === 'Backspace' && !inp.value && tagState[wrapId].length) { removeTag(wrapId, tagState[wrapId].length - 1); @@ -179,7 +208,7 @@ function renderInputFor(opt, stateTarget, stateKey) { inp.oninput = function () { const v = inp.value.trim(); if (v.endsWith('+presence+pin') || v.endsWith('+presence') || v.endsWith('+pin')) { - addTag(wrapId, inp, v); + addTag(wrapId, v, inp); } }; } @@ -320,11 +349,19 @@ function buildUserPanel(options) { return Object.assign({}, o, { id: o.path, label: o.path.split('.').pop() }); }; - const reqPaths = new Set(['securix.self.u2f_keys', 'securix.self.allowedVPNs', 'securix.self.teams']); + const u2fPaths = new Set(['securix.self.u2f_keys']); + const reqPaths = new Set(['securix.self.allowedVPNs', 'securix.self.teams']); const optPaths = new Set(['securix.self.hashedPassword', 'securix.self.defaultLoginShell', 'securix.self.bit']); - const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); - const opt = options.filter(function (o) { return optPaths.has(o.path) && !o.internal; }); + const u2f = options.filter(function (o) { return u2fPaths.has(o.path) && !o.internal; }); + const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); + const opt = options.filter(function (o) { return optPaths.has(o.path) && !o.internal; }); + + if (u2f.length) { + body.appendChild(renderSection('user', 'Clés U2F', u2f.map(function (o) { + return Object.assign(toRow(o)); + }))); + } if (req.length) { body.appendChild(renderSection('user', 'Sécurité & Accès', req.map(toRow))); @@ -362,8 +399,12 @@ function buildMachinePanel(options) { })] : []; - const reqPaths = new Set(['securix.self.hardwareSKU', 'securix.self.inventoryId']); - const optPaths = new Set(['securix.self.infraRepositoryPath', 'securix.self.infraRepositorySubdir']); + const reqPaths = new Set(['securix.self.hardwareSKU']); + const optPaths = new Set([ + 'securix.self.inventoryId', + 'securix.self.infraRepositoryPath', + 'securix.self.infraRepositorySubdir' + ]); const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); const opt = options.filter(function (o) { return optPaths.has(o.path) && !o.internal; }); @@ -714,9 +755,11 @@ function highlight(code) { } -function addTag(wrapId, inp, val) { +function addTag(wrapId, val, inp) { tagState[wrapId].push(val); - inp.value = ''; + if (inp) { + inp.value = ''; + } renderTags(wrapId); } @@ -785,10 +828,9 @@ function resetPanel(panelKey) { function previewUser() { const username = (getValue('user', 'securix.self.user.username') || '').trim(); - const email = (getValue('user', 'securix.self.user.email') || '').trim(); - if (!username || !email) { - setStatus('u-status', '× username et email requis', true); + if (!username) { + setStatus('u-status', '× username requis', true); return; } @@ -857,9 +899,9 @@ function setStatus(id, msg, err) { const FALLBACK_OPTIONS = [ { path: 'securix.self.mainDisk', type: { kind: 'str' }, description: 'Disque du système', example: '/dev/nvme0n1', hasDefault: false, internal: false }, - { path: 'securix.self.user.u2f_keys', type: { kind: 'list' }, description: 'Clés U2F', default: [], hasDefault: true, internal: false }, - { path: 'securix.self.user.allowedVPNs', type: { kind: 'list' }, description: 'VPNs autorisés', default: [], hasDefault: true, internal: false }, - { path: 'securix.self.user.teams', type: { kind: 'list' }, description: 'Équipes', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.u2f_keys', type: { kind: 'list' }, description: 'Clés U2F', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.allowedVPNs', type: { kind: 'list' }, description: 'VPNs autorisés', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.teams', type: { kind: 'list' }, description: 'Équipes', default: [], hasDefault: true, internal: false }, { path: 'securix.self.machine.hardwareSKU', type: { kind: 'enum', values: ['x280', 'elitebook645g11', 'latitude5340', 't14g6', 'x9-15', 'e14-g7'] }, description: 'Identifiant matériel', hasDefault: false, internal: false }, { path: 'securix.self.machine.inventoryId', type: { kind: 'int' }, description: "Numéro d'inventaire", hasDefault: false, internal: false }, { path: 'securix.self.machine.users', type: { kind: 'list' }, description: 'Utilisateurs assignés', default: [], hasDefault: true, internal: false } From 5856207d8dd4cb7493689acf1f11f065aa8e2a0e Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 9 Apr 2026 12:09:18 +0200 Subject: [PATCH 21/31] chore: better ux to paste value --- .../inventory-generator.js | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/community/inventory-generator/inventory-generator.js b/community/inventory-generator/inventory-generator.js index 2ff36059..8d04111e 100644 --- a/community/inventory-generator/inventory-generator.js +++ b/community/inventory-generator/inventory-generator.js @@ -31,6 +31,7 @@ const FIXED_USER_FIELDS = [ id: 'securix.self.user.email', label: 'email', type: 'email', + hasDefault: true, placeholder: 'dupont.tintin@email.fr' } ] @@ -162,39 +163,21 @@ function renderInputFor(opt, stateTarget, stateKey) { style: 'font-size:14px;margin-top:4px;' }); - let isPasting = false; - - inp.addEventListener('paste', function (e) { - e.preventDefault(); - isPasting = true; - - const raw = (e.clipboardData || window.clipboardData).getData('text'); - const parts = raw.split(/[\n\r,]+/).map(function (s) { return s.trim(); }).filter(Boolean); - - if (parts.length === 0) { - isPasting = false; - return; - } - - if (parts.length === 1) { - inp.value = parts[0]; - isPasting = false; - return; - } + let justPasted = false; - for (const part of parts) { - addTag(wrapId, part); - } - inp.value = ''; - isPasting = false; + inp.addEventListener('paste', function () { + justPasted = true; + setTimeout(function () { + justPasted = false; + }, 0); }); inp.onkeydown = function (e) { - if (isPasting) { - return; - } if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); + if (justPasted) { + return; + } const v = inp.value.trim().replace(/,$/, ''); if (v) { addTag(wrapId, v, inp); From 04e695586ef1117407674de64d4796a1c3474c0b Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Thu, 9 Apr 2026 12:19:50 +0200 Subject: [PATCH 22/31] fix: delete undefined value --- community/inventory-generator/inventory-generator.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/community/inventory-generator/inventory-generator.js b/community/inventory-generator/inventory-generator.js index 8d04111e..56838193 100644 --- a/community/inventory-generator/inventory-generator.js +++ b/community/inventory-generator/inventory-generator.js @@ -167,15 +167,19 @@ function renderInputFor(opt, stateTarget, stateKey) { inp.addEventListener('paste', function () { justPasted = true; - setTimeout(function () { + }); + + inp.addEventListener('keyup', function (e) { + if (e.key === 'Enter') { justPasted = false; - }, 0); + } }); inp.onkeydown = function (e) { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (justPasted) { + justPasted = false; return; } const v = inp.value.trim().replace(/,$/, ''); @@ -245,7 +249,7 @@ function renderInputFor(opt, stateTarget, stateKey) { inp.style.textTransform = 'uppercase'; } - const def = (opt.hasDefault && opt.default !== null) ? String(opt.default) : ''; + const def = (opt.hasDefault && opt.default !== null && opt.default !== undefined) ? String(opt.default) : ''; const currentVal = (stateTarget && stateTarget[key] != null) ? stateTarget[key] : def; inp.value = currentVal; From d718d98df1690ce3bd3414786e702cd30cad1f08 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Tue, 14 Apr 2026 16:18:59 +0200 Subject: [PATCH 23/31] fix: delete python parser and use nix function --- community/inventory-generator/default.nix | 145 ++++++-- .../inventory-generator.js | 44 +-- .../inventory-generator/parse-options.py | 324 ------------------ 3 files changed, 138 insertions(+), 375 deletions(-) delete mode 100644 community/inventory-generator/parse-options.py diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix index 0e589766..9862a9e6 100644 --- a/community/inventory-generator/default.nix +++ b/community/inventory-generator/default.nix @@ -13,44 +13,131 @@ let cfg = config.securix.inventory-generator; - - # Chemin vers le repo (optionnel) - repoArg = if repoSrc != null then "${repoSrc}" else ""; - - optionsJson = pkgs.runCommand "securix-options-json" { nativeBuildInputs = [ pkgs.python3 ]; } '' - python3 ${./parse-options.py} ${securixSrc} ${repoArg} > $out - ''; - - inlineScript = pkgs.writeText "inline-options.py" '' - import sys - js = open(sys.argv[1]).read() - options = open(sys.argv[2]).read() - result = js.replace('@@SECURIX_OPTIONS@@', options) - open(sys.argv[3], 'w').write(result) - ''; - - pkg = pkgs.runCommand "inventory-generator" { nativeBuildInputs = [ pkgs.python3 ]; } '' - mkdir -p $out/share/inventory-generator - - cp ${./inventory-generator.html} $out/share/inventory-generator/inventory-generator.html - cp ${./inventory-generator.css} $out/share/inventory-generator/inventory-generator.css - - python3 ${inlineScript} \ - ${./inventory-generator.js} \ - ${optionsJson} \ - $out/share/inventory-generator/inventory-generator.js - ''; in { options.securix.inventory-generator = { enable = lib.mkEnableOption "Inventory generator HTML tool"; + repoOptionsPrefix = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Préfixe des options du repo dérivé (ex: 'bureautix'). Laisser null si pas de repo dérivé."; + example = "bureautix"; + }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ (pkgs.writeShellScriptBin "inventory-generator" '' - xdg-open ${pkg}/share/inventory-generator/inventory-generator.html + TMPDIR=$(mktemp -d) + trap "rm -rf ''$TMPDIR" EXIT + + SECURIX_SRC="${securixSrc}" + NIX_PKGS="${pkgs.path}" + REPO_SRC="${if repoSrc != null then toString repoSrc else ""}" + REPO_PREFIX="${if cfg.repoOptionsPrefix != null then cfg.repoOptionsPrefix else ""}" + + echo "Chargement des options..." + OPTIONS=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -E " + let + pkgs = import $NIX_PKGS {}; + lib = pkgs.lib; + + securixFiles = lib.filter (f: lib.hasSuffix \".nix\" (toString f)) ( + lib.filesystem.listFilesRecursive $SECURIX_SRC/modules + ++ lib.filesystem.listFilesRecursive $SECURIX_SRC/community + ); + + repoFiles = + if \"$REPO_SRC\" == \"\" then [] + else lib.filter (f: lib.hasSuffix \".nix\" (toString f)) ( + lib.filesystem.listFilesRecursive $REPO_SRC + ); + + isOptionFile = f: + let content = builtins.readFile f; in + (lib.hasInfix \"mkOption\" content || lib.hasInfix \"mkEnableOption\" content) + && (lib.hasInfix \"options.securix\" content + || (\"$REPO_PREFIX\" != \"\" && lib.hasInfix (\"options.$REPO_PREFIX\") content)); + + optionFiles = builtins.filter isOptionFile (securixFiles ++ repoFiles); + + eval = lib.evalModules { + modules = optionFiles ++ [ { _module.check = false; } ]; + specialArgs = { + inherit pkgs lib; + vpnProfiles = {}; + operators = {}; + edition = \"unbranded\"; + defaultTags = []; + sources = {}; + securixSrc = $SECURIX_SRC; + repoSrc = if \"$REPO_SRC\" == \"\" then null else $REPO_SRC; + }; + }; + + raw = lib.optionAttrSetToDocList (builtins.removeAttrs eval.options [ \"_module\" ]); + filtered = builtins.filter (o: !o.internal && o.visible) raw; + enableGroups = lib.unique ( + map (o: lib.removeSuffix \".enable\" o.name) + (builtins.filter (o: lib.hasSuffix \".enable\" o.name) filtered) + ); + + resolveValue = d: + if d == null then null + else if !(builtins.isAttrs d && (d._type or \"\") == \"literalExpression\") then d + else + let text = lib.trim d.text; in + if text == \"null\" then null + else if text == \"true\" then true + else if text == \"false\" then false + else if text == \"[ ]\" || text == \"[]\" then [] + else if lib.hasPrefix \"\\\"\" text && lib.hasSuffix \"\\\"\" text + then lib.removePrefix \"\\\"\" (lib.removeSuffix \"\\\"\" text) + else null; + + resolveType = t: + let name = if builtins.isString t then t else t.name or \"string\"; in + if name == \"boolean\" then { kind = \"bool\"; } + else if name == \"package\" then { kind = \"package\"; } + else if lib.hasInfix \"integer\" name then { kind = \"int\"; } + else if lib.hasPrefix \"list of\" name then { kind = \"list\"; } + else if lib.hasPrefix \"one of\" name then { + kind = \"enum\"; + values = map lib.trim (lib.splitString \", \" (lib.removePrefix \"one of \" name)); + } + else { kind = \"str\"; }; + + in { + schemaVersion = 1; + inherit enableGroups; + options = map (opt: { + path = opt.name; + description = opt.description or null; + internal = opt.internal; + visible = opt.visible; + hasDefault = opt ? default; + default = resolveValue (opt.default or null); + example = resolveValue (opt.example or null); + isEnable = lib.hasSuffix \".enable\" opt.name; + enableGroup = + let g = lib.concatStringsSep \".\" (lib.init (lib.splitString \".\" opt.name)); + in if lib.elem g enableGroups then g else null; + type = resolveType (opt.type or \"string\"); + }) filtered; + } + ") + + cp ${./inventory-generator.html} ''$TMPDIR/inventory-generator.html + cp ${./inventory-generator.css} ''$TMPDIR/inventory-generator.css + ${pkgs.python3}/bin/python3 -c " +js = open('${./inventory-generator.js}').read() +opts = open('/dev/stdin').read() +open('$TMPDIR/inventory-generator.js', 'w').write(js.replace('@@SECURIX_OPTIONS@@', opts)) +" <<< "''$OPTIONS" + + xdg-open ''$TMPDIR/inventory-generator.html + sleep 5 '') ]; }; -} +} \ No newline at end of file diff --git a/community/inventory-generator/inventory-generator.js b/community/inventory-generator/inventory-generator.js index 56838193..077c3be2 100644 --- a/community/inventory-generator/inventory-generator.js +++ b/community/inventory-generator/inventory-generator.js @@ -336,9 +336,9 @@ function buildUserPanel(options) { return Object.assign({}, o, { id: o.path, label: o.path.split('.').pop() }); }; - const u2fPaths = new Set(['securix.self.u2f_keys']); - const reqPaths = new Set(['securix.self.allowedVPNs', 'securix.self.teams']); - const optPaths = new Set(['securix.self.hashedPassword', 'securix.self.defaultLoginShell', 'securix.self.bit']); + const u2fPaths = new Set(['securix.self.user.u2f_keys']); + const reqPaths = new Set(['securix.self.user.allowedVPNs', 'securix.self.user.teams']); + const optPaths = new Set(['securix.self.user.hashedPassword', 'securix.self.user.defaultLoginShell', 'securix.self.user.bit']); const u2f = options.filter(function (o) { return u2fPaths.has(o.path) && !o.internal; }); const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); @@ -386,11 +386,11 @@ function buildMachinePanel(options) { })] : []; - const reqPaths = new Set(['securix.self.hardwareSKU']); + const reqPaths = new Set(['securix.self.machine.hardwareSKU']); const optPaths = new Set([ - 'securix.self.inventoryId', - 'securix.self.infraRepositoryPath', - 'securix.self.infraRepositorySubdir' + 'securix.self.machine.inventoryId', + 'securix.self.machine.infraRepositoryPath', + 'securix.self.machine.infraRepositorySubdir' ]); const req = options.filter(function (o) { return reqPaths.has(o.path) && !o.internal; }); @@ -622,7 +622,7 @@ function generateAdvNixLines(target) { function generateUserNix() { const username = (getValue('user', 'securix.self.user.username') || '').trim(); const email = (getValue('user', 'securix.self.user.email') || '').trim(); - const hpVal = getValue('user', 'securix.self.hashedPassword'); + const hpVal = getValue('user', 'securix.self.user.hashedPassword'); const lines = [ '{ pkgs, ... }:', @@ -634,11 +634,11 @@ function generateUserNix() { ]; const OPTS = new Set([ - 'securix.self.u2f_keys', - 'securix.self.allowedVPNs', - 'securix.self.teams', - 'securix.self.defaultLoginShell', - 'securix.self.bit' + 'securix.self.user.u2f_keys', + 'securix.self.user.allowedVPNs', + 'securix.self.user.teams', + 'securix.self.user.defaultLoginShell', + 'securix.self.user.bit' ]); for (const opt of securixOptions) { @@ -646,7 +646,7 @@ function generateUserNix() { continue; } - const key = opt.path.replace('securix.self.', ''); + const key = opt.path.replace('securix.self.user.', ''); const val = getValue('user', opt.path); const k = opt.type && opt.type.kind; @@ -692,10 +692,10 @@ function generateMachineNix() { ]; const OPTS = new Set([ - 'securix.self.hardwareSKU', - 'securix.self.inventoryId', - 'securix.self.infraRepositoryPath', - 'securix.self.infraRepositorySubdir' + 'securix.self.machine.hardwareSKU', + 'securix.self.machine.inventoryId', + 'securix.self.machine.infraRepositoryPath', + 'securix.self.machine.infraRepositorySubdir' ]); for (const opt of securixOptions) { @@ -703,7 +703,7 @@ function generateMachineNix() { continue; } - const key = opt.path.replace('securix.self.', ''); + const key = opt.path.replace('securix.self.machine.', ''); const val = getValue('machine', opt.path); const k = opt.type && opt.type.kind; @@ -886,9 +886,9 @@ function setStatus(id, msg, err) { const FALLBACK_OPTIONS = [ { path: 'securix.self.mainDisk', type: { kind: 'str' }, description: 'Disque du système', example: '/dev/nvme0n1', hasDefault: false, internal: false }, - { path: 'securix.self.u2f_keys', type: { kind: 'list' }, description: 'Clés U2F', default: [], hasDefault: true, internal: false }, - { path: 'securix.self.allowedVPNs', type: { kind: 'list' }, description: 'VPNs autorisés', default: [], hasDefault: true, internal: false }, - { path: 'securix.self.teams', type: { kind: 'list' }, description: 'Équipes', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.user.u2f_keys', type: { kind: 'list' }, description: 'Clés U2F', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.user.allowedVPNs', type: { kind: 'list' }, description: 'VPNs autorisés', default: [], hasDefault: true, internal: false }, + { path: 'securix.self.user.teams', type: { kind: 'list' }, description: 'Équipes', default: [], hasDefault: true, internal: false }, { path: 'securix.self.machine.hardwareSKU', type: { kind: 'enum', values: ['x280', 'elitebook645g11', 'latitude5340', 't14g6', 'x9-15', 'e14-g7'] }, description: 'Identifiant matériel', hasDefault: false, internal: false }, { path: 'securix.self.machine.inventoryId', type: { kind: 'int' }, description: "Numéro d'inventaire", hasDefault: false, internal: false }, { path: 'securix.self.machine.users', type: { kind: 'list' }, description: 'Utilisateurs assignés', default: [], hasDefault: true, internal: false } diff --git a/community/inventory-generator/parse-options.py b/community/inventory-generator/parse-options.py deleted file mode 100644 index 2965e7e6..00000000 --- a/community/inventory-generator/parse-options.py +++ /dev/null @@ -1,324 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Pauline Legrand -# SPDX-License-Identifier: MIT - -# Parse les fichiers .nix de securix pour extraire les options mkOption - -import re -import json -import sys -from pathlib import Path - -def parse_type(type_str): - """Convertit une expression de type Nix en dict JSON.""" - t = type_str.strip() - if t == "types.bool": - return {"kind": "bool"} - elif t == "types.str" or t == "types.nonEmptyStr": - return {"kind": "str"} - elif t == "types.int" or t == "types.ints.positive": - return {"kind": "int"} - elif t == "types.path": - return {"kind": "path"} - elif t == "types.package": - return {"kind": "package"} - elif t.startswith("types.nullOr"): - inner = re.sub(r"^types\.nullOr\s*", "", t).strip() - return {"kind": "nullable", "inner": parse_type(inner)} - elif t.startswith("types.listOf"): - inner = re.sub(r"^types\.listOf\s*", "", t).strip() - if inner.startswith("("): - inner = inner[1:-1].strip() - return {"kind": "list", "inner": parse_type(inner)} - elif t.startswith("types.enum"): - m = re.search(r'\[([^\]]+)\]', t) - if m: - raw = m.group(1) - values = re.findall(r'"([^"]+)"', raw) - return {"kind": "enum", "values": values} - return {"kind": "enum", "values": []} - elif t.startswith("types.attrsOf"): - return {"kind": "attrsOf"} - elif t.startswith("types.submodule"): - return {"kind": "submodule"} - return {"kind": "str"} - -def extract_options_from_file(filepath, module_prefix): - """Extrait les mkOption d'un fichier .nix avec leur chemin complet.""" - content = Path(filepath).read_text(encoding="utf-8") - options = [] - - i = 0 - lines = content.split('\n') - option_stack = [] - in_options_block = False - - for line_no, line in enumerate(lines): - stripped = line.strip() - - if re.match(r'options\.(' + module_prefix + r'\.\S+)\s*=\s*\{', stripped) or \ - re.match(r'options\.(' + module_prefix + r')\s*=\s*\{', stripped): - in_options_block = True - m = re.match(r'options\.(\S+)\s*=', stripped) - if m: - option_stack = [m.group(1)] - - m_inline_enable = re.match( - r'options\.(' + module_prefix + r'\.\S+)\s*=\s*(?:lib\.)?mkEnableOption\s*(.*)', - stripped - ) - m_inline_option = re.match( - r'options\.(' + module_prefix + r'\.\S+)\s*=\s*(?:lib\.)?mkOption\s*\{', - stripped - ) - - if m_inline_enable: - full_path = m_inline_enable.group(1) - desc_part = m_inline_enable.group(2).strip().strip(';') - desc = None - m_desc = re.match(r'^"([^"]+)"', desc_part) - if m_desc: - desc = m_desc.group(1) - opt = { - "path": full_path, - "internal": False, - "visible": True, - "hasDefault": True, - "default": False, - "type": {"kind": "bool"}, - "description": desc, - "example": None, - "isEnable": True, - "enableGroup": None, - } - options.append(opt) - continue - - if m_inline_option: - full_path = m_inline_option.group(1) - block_lines = [] - depth = 1 - j = line_no + 1 - while j < len(lines) and depth > 0: - l = lines[j] - depth += l.count('{') - l.count('}') - block_lines.append(l) - j += 1 - block = '\n'.join(block_lines) - - opt = {"path": full_path, "internal": False, "visible": True, "hasDefault": False, - "isEnable": False, "enableGroup": None} - - m_type = re.search(r'type\s*=\s*((?:lib\.)?types\.[^;]+);', block) - if m_type: - type_str = m_type.group(1).strip().replace('lib.', '') - opt["type"] = parse_type(type_str) - else: - opt["type"] = {"kind": "str"} - - m_desc2 = re.search(r'description\s*=\s*(?:lib\.mdDoc\s*)?(?:\'\'(.*?)\'\'|"([^"]+)")', block, re.DOTALL) - if m_desc2: - opt["description"] = (m_desc2.group(1) or m_desc2.group(2) or "").strip() - else: - opt["description"] = None - - m_ex = re.search(r'example\s*=\s*"([^"]+)"', block) - opt["example"] = m_ex.group(1) if m_ex else None - - m_def = re.search(r'default\s*=\s*"([^"]+)"', block) - if m_def: - opt["default"] = m_def.group(1); opt["hasDefault"] = True - elif re.search(r'default\s*=\s*null', block): - opt["default"] = None; opt["hasDefault"] = True - elif re.search(r'default\s*=\s*\[\s*\]', block): - opt["default"] = []; opt["hasDefault"] = True - elif re.search(r'default\s*=\s*true', block): - opt["default"] = True; opt["hasDefault"] = True - elif re.search(r'default\s*=\s*false', block): - opt["default"] = False; opt["hasDefault"] = True - else: - opt["default"] = None - - if not opt["internal"] and opt["visible"]: - options.append(opt) - continue - - if not in_options_block: - continue - - m_enable = re.match(r'(\w+)\s*=\s*(?:lib\.)?mkEnableOption\s*', stripped) - m = re.match(r'(\w+)\s*=\s*(?:lib\.)?mkOption\s*\{', stripped) - - if m_enable and option_stack: - opt_name = m_enable.group(1) - full_path = ".".join(option_stack + [opt_name]) - opt = { - "path": full_path, - "internal": False, - "visible": True, - "hasDefault": True, - "default": False, - "type": {"kind": "bool"}, - "description": None, - "example": None, - "isEnable": True, - "enableGroup": None, - } - m_desc = re.search(r'mkEnableOption\s+"([^"]+)"', stripped) - if not m_desc: - m_desc = re.search(r"mkEnableOption\s+''([^']+)''", stripped) - if m_desc: - opt["description"] = m_desc.group(1) - if not opt["internal"] and opt["visible"]: - options.append(opt) - continue - if m and option_stack: - opt_name = m.group(1) - full_path = ".".join(option_stack + [opt_name]) - - block_lines = [] - depth = 1 - j = line_no + 1 - while j < len(lines) and depth > 0: - l = lines[j] - depth += l.count('{') - l.count('}') - block_lines.append(l) - j += 1 - block = '\n'.join(block_lines) - - opt = {"path": full_path, "internal": False, "visible": True, "hasDefault": False} - - m_type = re.search(r'type\s*=\s*(types\.[^;]+);', block) - if m_type: - opt["type"] = parse_type(m_type.group(1).strip()) - else: - opt["type"] = {"kind": "str"} - - m_desc = re.search(r"description\s*=\s*(?:lib\.mdDoc\s*)?(?:''(.*?)''|\"([^\"]+)\")", block, re.DOTALL) - if m_desc: - opt["description"] = (m_desc.group(1) or m_desc.group(2) or "").strip() - else: - opt["description"] = None - - m_ex = re.search(r'example\s*=\s*"([^"]+)"', block) - if m_ex: - opt["example"] = m_ex.group(1) - else: - opt["example"] = None - - m_def = re.search(r'default\s*=\s*"([^"]+)"', block) - if m_def: - opt["default"] = m_def.group(1) - opt["hasDefault"] = True - elif re.search(r'default\s*=\s*null', block): - opt["default"] = None - opt["hasDefault"] = True - elif re.search(r'default\s*=\s*\[\s*\]', block): - opt["default"] = [] - opt["hasDefault"] = True - elif re.search(r'default\s*=\s*true', block): - opt["default"] = True - opt["hasDefault"] = True - elif re.search(r'default\s*=\s*false', block): - opt["default"] = False - opt["hasDefault"] = True - else: - opt["default"] = None - - if re.search(r'internal\s*=\s*true', block): - opt["internal"] = True - if re.search(r'visible\s*=\s*false', block): - opt["visible"] = False - - if not opt["internal"] and opt["visible"]: - options.append(opt) - - return options - -def detect_enable_groups(options): - """ - Détecte les groupes qui ont un .enable et marque les autres options du groupe. - Ex: securix.yubikey-reset.enable → le groupe 'securix.yubikey-reset' a un enable. - Retourne un set de préfixes de groupe qui ont un enable. - """ - enable_groups = set() - for opt in options: - if opt["path"].endswith(".enable"): - prefix = opt["path"][:-len(".enable")] - enable_groups.add(prefix) - return enable_groups - - -def detect_repo_prefix(repo_path): - """ - Détecte dynamiquement le préfixe d'options utilisé dans un repo - (ex: 'bureautix', 'monprojet', ...) en excluant 'securix' et 'nixos'. - Retourne le préfixe le plus fréquent, ou None si introuvable. - """ - counts = {} - for nix_file in Path(repo_path).rglob("*.nix"): - try: - content = nix_file.read_text(encoding="utf-8", errors="replace") - except Exception: - continue - for line in content.split('\n'): - m = re.match(r'\s*options\.(\w+)\.', line) - if m: - prefix = m.group(1) - if prefix not in ('securix', 'nixos', 'lib', 'config', 'pkgs'): - counts[prefix] = counts.get(prefix, 0) + 1 - if not counts: - return None - return max(counts, key=counts.get) - - -def main(): - securix_src = Path(sys.argv[1]) - repo_src = Path(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else None - - scan_dirs = [ - (securix_src / "modules", "securix"), - (securix_src / "community", "securix"), - ] - - if repo_src and repo_src.exists(): - repo_prefix = detect_repo_prefix(repo_src) - if repo_prefix: - scan_dirs.append((repo_src, repo_prefix)) - - all_options = [] - seen_paths = set() - - for scan_dir, prefix in scan_dirs: - if not scan_dir.exists(): - continue - for nix_file in sorted(scan_dir.rglob("*.nix")): - content = nix_file.read_text(encoding="utf-8", errors="replace") - has_mkoption = "mkOption" in content or "mkEnableOption" in content - has_options = f"options.{prefix}" in content - if not has_mkoption or not has_options: - continue - try: - opts = extract_options_from_file(nix_file, prefix) - for opt in opts: - if opt["path"] not in seen_paths: - seen_paths.add(opt["path"]) - all_options.append(opt) - except Exception as e: - print(f"Warning: {nix_file}: {e}", file=sys.stderr) - - enable_groups = detect_enable_groups(all_options) - for opt in all_options: - group = ".".join(opt["path"].split(".")[:-1]) - opt["enableGroup"] = group if group in enable_groups else None - opt["isEnable"] = opt["path"].endswith(".enable") - - result = { - "schemaVersion": 1, - "options": all_options, - "enableGroups": sorted(enable_groups), - } - print(json.dumps(result, ensure_ascii=False, separators=(',', ':'))) - -if __name__ == "__main__": - main() \ No newline at end of file From bdcb81bf17e000f8690b2ce6574dd37430c66828 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Wed, 15 Apr 2026 10:42:39 +0200 Subject: [PATCH 24/31] nixfmt-rfc-style --- community/inventory-generator/default.nix | 220 +++++++++++----------- 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix index 9862a9e6..760e69d0 100644 --- a/community/inventory-generator/default.nix +++ b/community/inventory-generator/default.nix @@ -28,116 +28,116 @@ in config = lib.mkIf cfg.enable { environment.systemPackages = [ (pkgs.writeShellScriptBin "inventory-generator" '' - TMPDIR=$(mktemp -d) - trap "rm -rf ''$TMPDIR" EXIT - - SECURIX_SRC="${securixSrc}" - NIX_PKGS="${pkgs.path}" - REPO_SRC="${if repoSrc != null then toString repoSrc else ""}" - REPO_PREFIX="${if cfg.repoOptionsPrefix != null then cfg.repoOptionsPrefix else ""}" - - echo "Chargement des options..." - OPTIONS=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -E " - let - pkgs = import $NIX_PKGS {}; - lib = pkgs.lib; - - securixFiles = lib.filter (f: lib.hasSuffix \".nix\" (toString f)) ( - lib.filesystem.listFilesRecursive $SECURIX_SRC/modules - ++ lib.filesystem.listFilesRecursive $SECURIX_SRC/community - ); - - repoFiles = - if \"$REPO_SRC\" == \"\" then [] - else lib.filter (f: lib.hasSuffix \".nix\" (toString f)) ( - lib.filesystem.listFilesRecursive $REPO_SRC - ); - - isOptionFile = f: - let content = builtins.readFile f; in - (lib.hasInfix \"mkOption\" content || lib.hasInfix \"mkEnableOption\" content) - && (lib.hasInfix \"options.securix\" content - || (\"$REPO_PREFIX\" != \"\" && lib.hasInfix (\"options.$REPO_PREFIX\") content)); - - optionFiles = builtins.filter isOptionFile (securixFiles ++ repoFiles); - - eval = lib.evalModules { - modules = optionFiles ++ [ { _module.check = false; } ]; - specialArgs = { - inherit pkgs lib; - vpnProfiles = {}; - operators = {}; - edition = \"unbranded\"; - defaultTags = []; - sources = {}; - securixSrc = $SECURIX_SRC; - repoSrc = if \"$REPO_SRC\" == \"\" then null else $REPO_SRC; - }; - }; - - raw = lib.optionAttrSetToDocList (builtins.removeAttrs eval.options [ \"_module\" ]); - filtered = builtins.filter (o: !o.internal && o.visible) raw; - enableGroups = lib.unique ( - map (o: lib.removeSuffix \".enable\" o.name) - (builtins.filter (o: lib.hasSuffix \".enable\" o.name) filtered) - ); - - resolveValue = d: - if d == null then null - else if !(builtins.isAttrs d && (d._type or \"\") == \"literalExpression\") then d - else - let text = lib.trim d.text; in - if text == \"null\" then null - else if text == \"true\" then true - else if text == \"false\" then false - else if text == \"[ ]\" || text == \"[]\" then [] - else if lib.hasPrefix \"\\\"\" text && lib.hasSuffix \"\\\"\" text - then lib.removePrefix \"\\\"\" (lib.removeSuffix \"\\\"\" text) - else null; - - resolveType = t: - let name = if builtins.isString t then t else t.name or \"string\"; in - if name == \"boolean\" then { kind = \"bool\"; } - else if name == \"package\" then { kind = \"package\"; } - else if lib.hasInfix \"integer\" name then { kind = \"int\"; } - else if lib.hasPrefix \"list of\" name then { kind = \"list\"; } - else if lib.hasPrefix \"one of\" name then { - kind = \"enum\"; - values = map lib.trim (lib.splitString \", \" (lib.removePrefix \"one of \" name)); - } - else { kind = \"str\"; }; - - in { - schemaVersion = 1; - inherit enableGroups; - options = map (opt: { - path = opt.name; - description = opt.description or null; - internal = opt.internal; - visible = opt.visible; - hasDefault = opt ? default; - default = resolveValue (opt.default or null); - example = resolveValue (opt.example or null); - isEnable = lib.hasSuffix \".enable\" opt.name; - enableGroup = - let g = lib.concatStringsSep \".\" (lib.init (lib.splitString \".\" opt.name)); - in if lib.elem g enableGroups then g else null; - type = resolveType (opt.type or \"string\"); - }) filtered; - } - ") - - cp ${./inventory-generator.html} ''$TMPDIR/inventory-generator.html - cp ${./inventory-generator.css} ''$TMPDIR/inventory-generator.css - ${pkgs.python3}/bin/python3 -c " -js = open('${./inventory-generator.js}').read() -opts = open('/dev/stdin').read() -open('$TMPDIR/inventory-generator.js', 'w').write(js.replace('@@SECURIX_OPTIONS@@', opts)) -" <<< "''$OPTIONS" - - xdg-open ''$TMPDIR/inventory-generator.html - sleep 5 + TMPDIR=$(mktemp -d) + trap "rm -rf ''$TMPDIR" EXIT + + SECURIX_SRC="${securixSrc}" + NIX_PKGS="${pkgs.path}" + REPO_SRC="${if repoSrc != null then toString repoSrc else ""}" + REPO_PREFIX="${if cfg.repoOptionsPrefix != null then cfg.repoOptionsPrefix else ""}" + + echo "Chargement des options..." + OPTIONS=$(${pkgs.nix}/bin/nix-instantiate --eval --strict --json -E " + let + pkgs = import $NIX_PKGS {}; + lib = pkgs.lib; + + securixFiles = lib.filter (f: lib.hasSuffix \".nix\" (toString f)) ( + lib.filesystem.listFilesRecursive $SECURIX_SRC/modules + ++ lib.filesystem.listFilesRecursive $SECURIX_SRC/community + ); + + repoFiles = + if \"$REPO_SRC\" == \"\" then [] + else lib.filter (f: lib.hasSuffix \".nix\" (toString f)) ( + lib.filesystem.listFilesRecursive $REPO_SRC + ); + + isOptionFile = f: + let content = builtins.readFile f; in + (lib.hasInfix \"mkOption\" content || lib.hasInfix \"mkEnableOption\" content) + && (lib.hasInfix \"options.securix\" content + || (\"$REPO_PREFIX\" != \"\" && lib.hasInfix (\"options.$REPO_PREFIX\") content)); + + optionFiles = builtins.filter isOptionFile (securixFiles ++ repoFiles); + + eval = lib.evalModules { + modules = optionFiles ++ [ { _module.check = false; } ]; + specialArgs = { + inherit pkgs lib; + vpnProfiles = {}; + operators = {}; + edition = \"unbranded\"; + defaultTags = []; + sources = {}; + securixSrc = $SECURIX_SRC; + repoSrc = if \"$REPO_SRC\" == \"\" then null else $REPO_SRC; + }; + }; + + raw = lib.optionAttrSetToDocList (builtins.removeAttrs eval.options [ \"_module\" ]); + filtered = builtins.filter (o: !o.internal && o.visible) raw; + enableGroups = lib.unique ( + map (o: lib.removeSuffix \".enable\" o.name) + (builtins.filter (o: lib.hasSuffix \".enable\" o.name) filtered) + ); + + resolveValue = d: + if d == null then null + else if !(builtins.isAttrs d && (d._type or \"\") == \"literalExpression\") then d + else + let text = lib.trim d.text; in + if text == \"null\" then null + else if text == \"true\" then true + else if text == \"false\" then false + else if text == \"[ ]\" || text == \"[]\" then [] + else if lib.hasPrefix \"\\\"\" text && lib.hasSuffix \"\\\"\" text + then lib.removePrefix \"\\\"\" (lib.removeSuffix \"\\\"\" text) + else null; + + resolveType = t: + let name = if builtins.isString t then t else t.name or \"string\"; in + if name == \"boolean\" then { kind = \"bool\"; } + else if name == \"package\" then { kind = \"package\"; } + else if lib.hasInfix \"integer\" name then { kind = \"int\"; } + else if lib.hasPrefix \"list of\" name then { kind = \"list\"; } + else if lib.hasPrefix \"one of\" name then { + kind = \"enum\"; + values = map lib.trim (lib.splitString \", \" (lib.removePrefix \"one of \" name)); + } + else { kind = \"str\"; }; + + in { + schemaVersion = 1; + inherit enableGroups; + options = map (opt: { + path = opt.name; + description = opt.description or null; + internal = opt.internal; + visible = opt.visible; + hasDefault = opt ? default; + default = resolveValue (opt.default or null); + example = resolveValue (opt.example or null); + isEnable = lib.hasSuffix \".enable\" opt.name; + enableGroup = + let g = lib.concatStringsSep \".\" (lib.init (lib.splitString \".\" opt.name)); + in if lib.elem g enableGroups then g else null; + type = resolveType (opt.type or \"string\"); + }) filtered; + } + ") + + cp ${./inventory-generator.html} ''$TMPDIR/inventory-generator.html + cp ${./inventory-generator.css} ''$TMPDIR/inventory-generator.css + ${pkgs.python3}/bin/python3 -c " + js = open('${./inventory-generator.js}').read() + opts = open('/dev/stdin').read() + open('$TMPDIR/inventory-generator.js', 'w').write(js.replace('@@SECURIX_OPTIONS@@', opts)) + " <<< "''$OPTIONS" + + xdg-open ''$TMPDIR/inventory-generator.html + sleep 5 '') ]; }; -} \ No newline at end of file +} From 4f7b82ca8496238a4825eaa2e7a51af6175b0590 Mon Sep 17 00:00:00 2001 From: plegrand-dinum Date: Fri, 24 Apr 2026 09:07:14 +0200 Subject: [PATCH 25/31] Update community/inventory-generator/inventory-generator.js Co-authored-by: agrimault-dinum --- community/inventory-generator/inventory-generator.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/community/inventory-generator/inventory-generator.js b/community/inventory-generator/inventory-generator.js index 077c3be2..0a357244 100644 --- a/community/inventory-generator/inventory-generator.js +++ b/community/inventory-generator/inventory-generator.js @@ -569,7 +569,17 @@ function updateAdvCount() { function nixStr(s) { - return '"' + s + '"'; + return ( + '"' + + s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$\{/g, '\\${') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t') + + '"' + ); } function nixList(arr) { From aeee115542a0ded7110cf8eea46ce4edb461bcb6 Mon Sep 17 00:00:00 2001 From: Antoine Eiche Date: Tue, 14 Apr 2026 10:44:35 +0200 Subject: [PATCH 26/31] mkTerminal exposes the postInstallScript attribute --- lib/default.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/default.nix b/lib/default.nix index 478bf895..c7a75bb8 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -429,6 +429,7 @@ rec { modules, edition ? args.edition, compression ? "zstd -Xcompression-level 6", + postInstallScript ? "", }: let allModules = [ @@ -469,7 +470,7 @@ rec { ]; installer = buildUSBInstallerISO { modules = allModules; - inherit compression; + inherit compression postInstallScript; }; system = pkgs.nixos allModules; }; From ff5c4def542e9d7f6af9a11f353108e213bff1ee Mon Sep 17 00:00:00 2001 From: quentindropcom Date: Tue, 21 Apr 2026 11:47:46 +0200 Subject: [PATCH 27/31] fix: validate the verb passed to the upgrade command Right now `upgrade test fix/my-branch` is accepted silently: the verb gets picked up, the trailing argument is ignored, and the rebuild ends up running on the default branch instead of the one that was asked for. Check that the verb is one of switch/boot/test/dry-activate, and refuse any extra positional argument (options like --branch have to come before the verb). Also guard the "no verb" check with ${1:-} so it still works under the set -u that writeShellApplication turns on. Closes #56 --- modules/updates/permissionless-upgrade.nix | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/modules/updates/permissionless-upgrade.nix b/modules/updates/permissionless-upgrade.nix index d1a14bf1..c1b59cc7 100644 --- a/modules/updates/permissionless-upgrade.nix +++ b/modules/updates/permissionless-upgrade.nix @@ -298,7 +298,7 @@ let done # Ensure an upgrade verb is provided - if [ -z "$1" ]; then + if [ -z "''${1:-}" ]; then echo "No upgrade verb provided. Available options: - switch: Activate the new system right now. Warning: this can break your session. - boot: Activate the new system on the next reboot. @@ -307,6 +307,29 @@ let exit 1 fi + # Validate the upgrade verb against the list of accepted values. + # Without this check, a wrong syntax such as `upgrade test my-branch` would + # be silently accepted (the extra positional argument was ignored and the + # upgrade proceeded on the default branch). See issue #56. + case "$1" in + switch|boot|test|dry-activate) ;; + *) + echo "Unknown upgrade verb: '$1'. Expected one of: switch, boot, test, dry-activate." >&2 + echo "Run 'upgrade --help' for usage." >&2 + exit 1 + ;; + esac + + # Reject any trailing positional argument: options such as --branch must + # be passed before the verb, so nothing should remain after it. + if [ "$#" -gt 1 ]; then + shift + echo "Unexpected extra argument(s) after verb: $*" >&2 + echo "Options such as --branch must be passed before the verb." >&2 + echo "Run 'upgrade --help' for usage." >&2 + exit 1 + fi + # Set the TPM2 SSH agent to retrieve the repository. export SSH_AUTH_SOCK=/var/tmp/ssh-tpm-agent.sock From 1593f038364c12b51ad802bbf2e54cb5ebd83ac0 Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sat, 25 Apr 2026 20:47:38 +0200 Subject: [PATCH 28/31] fix: --do-not-pull not working in upgrade script Add a default value for 'REPO_PATH' in permissionless-upgrade.nix --- modules/updates/permissionless-upgrade.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/updates/permissionless-upgrade.nix b/modules/updates/permissionless-upgrade.nix index c1b59cc7..13f55493 100644 --- a/modules/updates/permissionless-upgrade.nix +++ b/modules/updates/permissionless-upgrade.nix @@ -253,6 +253,7 @@ let # Default values BRANCH="${config.securix.auto-updates.branch}" + REPO_PATH="${self.infraRepositoryPath}" SUBDIR="${self.infraRepositorySubdir}" REMOTE_PULL=true USE_SN=false From 64626fea4d4b79c9be95b1c8848e7d18347bdb80 Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Tue, 28 Apr 2026 19:33:53 +0200 Subject: [PATCH 29/31] refactor: Remove useless line in permissionless-upgrade.nix --- modules/updates/permissionless-upgrade.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/updates/permissionless-upgrade.nix b/modules/updates/permissionless-upgrade.nix index 13f55493..27bc4f9d 100644 --- a/modules/updates/permissionless-upgrade.nix +++ b/modules/updates/permissionless-upgrade.nix @@ -361,8 +361,6 @@ let if [ "$REMOTE_PULL" = true ]; then git -C "${self.infraRepositoryPath}" fetch origin if [ "$BRANCH" == "${config.securix.auto-updates.branch}" ]; then - REPO_PATH="${self.infraRepositoryPath}" - # Update the repo. # On main branch, it's ABSOLUTELY forbidden to do anything else than --ff-only. git -C "${self.infraRepositoryPath}" switch "${config.securix.auto-updates.branch}" From f9367495c3095b7ff35f6211a3b7a052995acb7f Mon Sep 17 00:00:00 2001 From: abstracts33d Date: Tue, 19 May 2026 19:52:06 +0200 Subject: [PATCH 30/31] feat: flake wrapper bundling lanzaboote/agenix/disko + module surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a flake.nix that re-uses Sécurix's existing default.nix entry point, so npins-based consumers keep working unchanged. Flake inputs override npins where present; npins remains the fallback for any source the flake doesn't provide (e.g. git-hooks). Outputs exposed to flake consumers: nixosModules.securix-base — composes lanzaboote, agenix, disko, and ./modules into a single import. Consumers import one module and get the full ANSSI-hardened base. nixosModules.securix-hardware. — per-SKU hardware profiles (x280, t14g6, latitude5340, elitebook645g11/850g8, x9-15, e14-g7). nixosModules.securix-hardware-all — aggregate of all SKUs. overlays.default — Sécurix package overlay. lib.forSystem — system-indexed Sécurix lib. packages..shell — sx CLI shell. devShells..default — Sécurix dev shell. legacyPackages..sx — full Sécurix toolkit. flake.lock pins nixpkgs (nixos-unstable), lanzaboote, agenix, disko, nixos-hardware, flake-utils. REUSE/SPDX headers added so `reuse lint` passes on the new files. Non-flake consumers (the existing npins / nixos-rebuild flow) are unaffected — flake.nix is purely additive. --- flake.lock | 346 +++++++++++++++++++++++++++++++++++++++++++++ flake.lock.license | 3 + flake.nix | 111 +++++++++++++++ 3 files changed, 460 insertions(+) create mode 100644 flake.lock create mode 100644 flake.lock.license create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..ea5e9226 --- /dev/null +++ b/flake.lock @@ -0,0 +1,346 @@ +{ + "nodes": { + "agenix": { + "inputs": { + "darwin": "darwin", + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1770165109, + "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", + "owner": "ryantm", + "repo": "agenix", + "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "crane": { + "locked": { + "lastModified": 1731098351, + "narHash": "sha256-HQkYvKvaLQqNa10KEFGgWHfMAbWBfFp+4cAgkut+NNE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "ef80ead953c1b28316cc3f8613904edc2eb90c28", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "darwin": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1744478979, + "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", + "owner": "lnl7", + "repo": "nix-darwin", + "rev": "43975d782b418ebf4969e9ccba82466728c2851b", + "type": "github" + }, + "original": { + "owner": "lnl7", + "ref": "master", + "repo": "nix-darwin", + "type": "github" + } + }, + "disko": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773889306, + "narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=", + "owner": "nix-community", + "repo": "disko", + "rev": "5ad85c82cc52264f4beddc934ba57f3789f28347", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "disko", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1730504689, + "narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "506278e768c2a08bec68eb62932193e341f55c90", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "lanzaboote", + "pre-commit-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "lanzaboote": { + "inputs": { + "crane": "crane", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": [ + "nixpkgs" + ], + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1737639419, + "narHash": "sha256-AEEDktApTEZ5PZXNDkry2YV2k6t0dTgLPEmAZbnigXU=", + "owner": "nix-community", + "repo": "lanzaboote", + "rev": "a65905a09e2c43ff63be8c0e86a93712361f871e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "v0.4.2", + "repo": "lanzaboote", + "type": "github" + } + }, + "nixos-hardware": { + "locked": { + "lastModified": 1775490113, + "narHash": "sha256-2ZBhDNZZwYkRmefK5XLOusCJHnoeKkoN95hoSGgMxWM=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "c775c2772ba56e906cbeb4e0b2db19079ef11ff7", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "master", + "repo": "nixos-hardware", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1730741070, + "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "lanzaboote", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "lanzaboote", + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1731363552, + "narHash": "sha256-vFta1uHnD29VUY4HJOO/D6p6rxyObnf+InnSMT4jlMU=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "cd1af27aa85026ac759d5d3fccf650abe7e1bbf0", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "agenix": "agenix", + "disko": "disko", + "flake-utils": "flake-utils", + "lanzaboote": "lanzaboote", + "nixos-hardware": "nixos-hardware", + "nixpkgs": "nixpkgs" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1731897198, + "narHash": "sha256-Ou7vLETSKwmE/HRQz4cImXXJBr/k9gp4J4z/PF8LzTE=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "0be641045af6d8666c11c2c40e45ffc9667839b5", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.lock.license b/flake.lock.license new file mode 100644 index 00000000..02de3e3a --- /dev/null +++ b/flake.lock.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026-present arcanesys contributors + +SPDX-License-Identifier: MIT diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..14b6ead4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: 2026-present arcanesys contributors +# +# SPDX-License-Identifier: MIT +{ + description = "Sécurix — NixOS-based hardened endpoint OS (flake wrapper)"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + disko = { + url = "github:nix-community/disko"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + lanzaboote = { + url = "github:nix-community/lanzaboote/v0.4.2"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + agenix = { + url = "github:ryantm/agenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + nixos-hardware.url = "github:NixOS/nixos-hardware/master"; + + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }@inputs: + let + # Re-use Sécurix's own default.nix. We pass `sources = inputs // npins` + # so flake inputs override npins defaults where present, and npins is + # the fallback for any source the flake doesn't provide (e.g. git-hooks). + securixFor = + system: + let + pkgs = import inputs.nixpkgs { inherit system; }; + npins = import ./npins; + in + import ./. { + inherit pkgs; + sources = inputs // npins; + }; + + # Hardware SKUs exposed as individual modules + hardwareSKUs = [ + "x280" + "elitebook645g11" + "elitebook850g8" + "latitude5340" + "t14g6" + "x9-15" + "e14-g7" + ]; + in + flake-utils.lib.eachDefaultSystem ( + system: + let + sx = securixFor system; + in + { + packages = { inherit (sx) shell; }; + + devShells.default = sx.shell; + + # Re-expose the full Sécurix toolkit output (lib, pkgs, modules, + # tests, shell) indexed by system for consumers that need it. + legacyPackages.sx = sx; + } + ) + // { + # NixOS modules available to any flake consumer (e.g. nixfleet mkHost) + nixosModules = { + # Sécurix base: all ANSSI hardening + user model + VPN + PAM + etc. + securix-base = { + imports = [ + inputs.lanzaboote.nixosModules.lanzaboote + "${inputs.agenix}/modules/age.nix" + "${inputs.disko}/module.nix" + ./modules + ]; + }; + + # Hardware profiles keyed by SKU (must match securix.self.machine.hardwareSKU) + securix-hardware = builtins.listToAttrs ( + map (sku: { + name = sku; + value = ./hardware/${sku}.nix; + }) hardwareSKUs + ); + + # Aggregate: all hardware profiles + securix-hardware-all = ./hardware; + }; + + # Package overlay (custom Sécurix packages) + overlays.default = import ./pkgs/overlay.nix; + + # System-indexed lib accessor (Sécurix's lib depends on pkgs) + lib = { + forSystem = system: (securixFor system).lib; + }; + }; +} From 0f6d83c6dbc71b0fd583c63feb81ed7ae80db5f0 Mon Sep 17 00:00:00 2001 From: abstracts33d Date: Tue, 19 May 2026 19:52:20 +0200 Subject: [PATCH 31/31] feat: make securix modules composable outside lib.mkTerminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small changes to let external flake-based deployers consume nixosModules.securix-base directly, without going through lib.mkTerminal: 1. modules/bootloader.nix - boot.loader.systemd-boot.enable: lib.mkForce false → lib.mkDefault false - boot.lanzaboote.enable: true → lib.mkDefault true The original mkForce was over-defensive: it blocked legitimate consumer overrides (VM tests, hardware without UEFI Secure Boot, virtualized labs, KVM/Qubes-style scenarios). Real-hardware deployments default to the same posture as before — lanzaboote on, systemd-boot off. Weakening secure boot now requires an explicit opt-out at the consumer's call site, which is auditable. 2. modules/module-args-defaults.nix (new) Sets lib.mkDefault {} for _module.args.operators and _module.args.vpnProfiles. These are normally injected by lib.mkTerminal; without defaults, consumers composing modules directly hit eval errors before they can supply their own values. 3. modules/default.nix imports module-args-defaults so the defaults are always present. Consumers' own operators/vpnProfiles still override via standard mkDefault precedence. Net effect: nixosModules.securix-base composes cleanly under any flake-based deployer. Hardened defaults are unchanged for real-hardware deployments; weakening posture requires an explicit opt-out at the call site. --- modules/bootloader.nix | 5 ++--- modules/default.nix | 4 +++- modules/module-args-defaults.nix | 12 ++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 modules/module-args-defaults.nix diff --git a/modules/bootloader.nix b/modules/bootloader.nix index 8c928be1..b68a2b1b 100644 --- a/modules/bootloader.nix +++ b/modules/bootloader.nix @@ -1,15 +1,14 @@ # SPDX-FileCopyrightText: 2025 Ryan Lahfa # # SPDX-License-Identifier: MIT - { pkgs, lib, ... }: { boot.initrd.systemd.enable = lib.mkDefault true; - boot.loader.systemd-boot.enable = lib.mkForce false; + boot.loader.systemd-boot.enable = lib.mkDefault false; boot.lanzaboote = { - enable = true; + enable = lib.mkDefault true; pkiBundle = "/etc/secureboot"; }; diff --git a/modules/default.nix b/modules/default.nix index e076b65a..d7bbd5e0 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -1,9 +1,11 @@ # SPDX-FileCopyrightText: 2025 Ryan Lahfa # # SPDX-License-Identifier: MIT - { imports = [ + # Safe defaults for module args injected by lib.mkTerminal + ./module-args-defaults.nix + # ANSSI compliance module ./anssi diff --git a/modules/module-args-defaults.nix b/modules/module-args-defaults.nix new file mode 100644 index 00000000..ea11d291 --- /dev/null +++ b/modules/module-args-defaults.nix @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025-present arcanesys contributors +# +# SPDX-License-Identifier: MIT +# +# Safe defaults for module arguments that are normally injected by +# lib.mkTerminal. Consumers who compose securix modules directly +# (without mkTerminal) get empty defaults instead of eval errors. +{ lib, ... }: +{ + _module.args.operators = lib.mkDefault { }; + _module.args.vpnProfiles = lib.mkDefault { }; +}