diff --git a/README.md b/README.md index 351572ee..baff99f5 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 @@ -62,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. diff --git a/community/default.nix b/community/default.nix index 01e8a811..ea839eb6 100644 --- a/community/default.nix +++ b/community/default.nix @@ -6,5 +6,7 @@ imports = [ # Grist registration ./grist-registration + + ./inventory-generator ]; } diff --git a/community/inventory-generator/default.nix b/community/inventory-generator/default.nix new file mode 100644 index 00000000..760e69d0 --- /dev/null +++ b/community/inventory-generator/default.nix @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2026 Pauline Legrand +# +# SPDX-License-Identifier: MIT + +{ + config, + lib, + pkgs, + securixSrc, + repoSrc ? null, + ... +}: + +let + cfg = config.securix.inventory-generator; +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" '' + 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 + '') + ]; + }; +} diff --git a/community/inventory-generator/inventory-generator.css b/community/inventory-generator/inventory-generator.css new file mode 100644 index 00000000..aba4fa12 --- /dev/null +++ b/community/inventory-generator/inventory-generator.css @@ -0,0 +1,735 @@ +/* + * SPDX-FileCopyrightText: 2026 Pauline Legrand + * SPDX-License-Identifier: MIT + */ + + +:root { + --bg: #282c35; + --surface: #32363f; + --border: #4a5060; + --accent: #60aaff; + --accent2: #3dd68c; + --danger: #ff6b6b; + --text: #ffffff; + --muted: #a0a8b8; + --label: #d0d8e8; + --input-bg: #23262e; + --mono: 'IBM Plex Mono', 'Cascadia Code', 'Fira Mono', 'Consolas', 'Menlo', monospace; + --sans: 'IBM Plex Sans', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; +} + + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 16px; + min-height: 100vh; +} + + +header { + border-bottom: 1px solid var(--border); + padding: 16px 32px; + display: flex; + align-items: center; + gap: 12px; + background: var(--surface); +} + +.logo { + font-family: var(--mono); + font-weight: 600; + font-size: 17px; + color: var(--accent); + letter-spacing: 0.02em; +} + +.header-sub { + font-size: 14px; + color: var(--muted); + margin-left: auto; + font-family: var(--mono); +} + + +main { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + height: calc(100vh - 53px); +} + +.panel { + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel:last-child { + border-right: none; +} + +.panel-header, +.panel-footer { + padding: 14px 24px; + background: var(--surface); + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.panel-header { + border-bottom: 1px solid var(--border); +} + +.panel-footer { + border-top: 1px solid var(--border); +} + +.panel-icon { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.user-icon { + background: var(--accent); +} + +.machine-icon { + background: var(--accent2); +} + +.panel-title { + font-family: var(--mono); + font-size: 14px; + font-weight: 500; + color: var(--label); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.panel-path { + font-family: var(--mono); + font-size: 13px; + color: var(--muted); + margin-left: auto; +} + +.panel-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-body::-webkit-scrollbar { + width: 4px; +} + +.panel-body::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + + +.field-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +label { + font-size: 14px; + font-family: var(--mono); + color: var(--label); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +label .required { + color: var(--danger); + margin-left: 2px; +} + +label .hint { + color: var(--muted); + font-weight: 300; + text-transform: none; + letter-spacing: 0; + margin-left: 6px; +} + +input[type="text"], +input[type="email"], +select { + background: var(--input-bg); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--mono); + font-size: 15px; + padding: 9px 12px; + border-radius: 4px; + outline: none; + width: 100%; + transition: border-color 0.15s; +} + +input:focus, +select:focus { + border-color: var(--accent); +} + +select { + cursor: pointer; + appearance: none; + padding-right: 30px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235a6478' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} + +select option { + background: var(--surface); +} + + +.toggle-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: border-color 0.15s; +} + +.toggle-row:hover { + border-color: var(--muted); +} + +.toggle-row.active { + border-color: var(--accent); +} + +.toggle { + width: 32px; + height: 18px; + background: var(--border); + border-radius: 9px; + position: relative; + flex-shrink: 0; + transition: background 0.2s; +} + +.toggle.on { + background: var(--accent); +} + +.toggle::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + background: #fff; + border-radius: 50%; + top: 3px; + left: 3px; + transition: left 0.2s; +} + +.toggle.on::after { + left: 17px; +} + +.toggle-label { + font-family: var(--mono); + font-size: 15px; + color: var(--text); + flex: 1; +} + +.toggle-value { + font-family: var(--mono); + font-size: 13px; + color: var(--muted); +} + +.toggle-row.active .toggle-value { + color: var(--accent); +} + + +.tag-input-wrap { + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 6px 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + min-height: 42px; + cursor: text; + transition: border-color 0.15s; +} + +.tag-input-wrap:focus-within { + border-color: var(--accent); +} + +.tag { + background: #202840; + border: 1px solid var(--border); + border-radius: 3px; + padding: 3px 8px; + font-family: var(--mono); + font-size: 14px; + color: #f0f4ff; + display: flex; + align-items: center; + gap: 6px; + max-width: 100%; + word-break: break-all; +} + +.tag-remove { + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + flex-shrink: 0; +} + +.tag-remove:hover { + color: var(--danger); +} + +.tag-hint { + font-size: 13px; + color: #7c8799; + font-family: var(--mono); + padding: 2px 4px; + width: 100%; +} + + +.divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +.section-label { + color: var(--muted) !important; + font-family: var(--mono); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 2px 0; +} + +.info-row { + font-size: 13px; + color: #7c8799; + font-family: var(--mono); + padding: 4px 0; +} + + +.btn { + font-family: var(--mono); + font-size: 14px; + font-weight: 500; + letter-spacing: 0.05em; + padding: 9px 18px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.btn-primary { + background: var(--accent); + color: #0a0c10; +} + +.btn-primary:hover { + filter: brightness(1.1); +} + +.btn-primary.green { + background: var(--accent2); +} + +.btn-secondary { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--muted); + color: var(--text); +} + +.status { + margin-left: auto; + font-family: var(--mono); + font-size: 13px; + color: var(--muted); +} + +.status.ok { + color: var(--accent2); +} + +.status.err { + color: var(--danger); +} + + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 100; + align-items: center; + justify-content: center; +} + +.modal-overlay.visible { + display: flex; +} + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + width: min(700px, 90vw); + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} + +.modal-title { + font-family: var(--mono); + font-size: 14px; + font-weight: 600; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.08em; + flex: 1; +} + +.modal-close { + background: none; + border: none; + color: var(--muted); + font-size: 20px; + cursor: pointer; + line-height: 1; +} + +.modal-close:hover { + color: var(--text); +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; + background: #1e2028; +} + +.modal-footer { + border-top: 1px solid var(--border); + padding: 12px 20px; + display: flex; + gap: 10px; + justify-content: flex-end; + background: var(--surface); +} + +pre { + font-family: var(--mono); + font-size: 14px; + line-height: 1.7; + color: #f0f4ff; + white-space: pre-wrap; + word-break: break-all; +} + +.nix-str { + color: #e8906e; +} + +.nix-comment { + color: var(--muted); + font-style: italic; +} + +.nix-bool-true { + color: var(--accent); +} + +.nix-bool-false { + color: var(--danger); +} + + +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + color: var(--muted); + font-family: var(--mono); + font-size: 14px; +} + +.loading-dot { + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; + animation: pulse 1.2s ease-in-out infinite; +} + +.loading-dot:nth-child(2) { + animation-delay: 0.2s; +} + +.loading-dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.2; } + 50% { opacity: 1; } +} + + +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + background: var(--border); + border-radius: 50%; + font-size: 11px; + color: var(--muted); + cursor: help; + margin-left: 6px; + font-family: sans-serif; + vertical-align: middle; +} + +.tooltip-text { + display: none; + position: absolute; + bottom: 125%; + left: 0; + background: #1a1d24; + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 10px; + font-size: 13px; + color: var(--muted); + font-family: var(--sans); + font-weight: 300; + text-transform: none; + letter-spacing: 0; + width: 240px; + z-index: 10; + line-height: 1.5; +} + +.tooltip:hover .tooltip-text { + display: block; +} + + +.adv-group { + border: 1px solid var(--border); + border-radius: 4px; + transition: border-color 0.15s; + margin-bottom: 4px; +} + +.adv-group.enabled { + border-color: #c792ea; +} + +.adv-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + cursor: pointer; + user-select: none; + background: var(--surface); + border-radius: 4px; +} + +.adv-group.enabled .adv-group-header { + border-radius: 4px 4px 0 0; +} + +.adv-group-header:hover { + background: #3a3f4a; +} + +.adv-checkbox { + width: 14px; + height: 14px; + border: 1px solid var(--border); + border-radius: 3px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all 0.15s; +} + +.adv-group.enabled .adv-checkbox { + background: #c792ea; + border-color: #c792ea; + color: #1a0a2e; +} + +.adv-group-name { + font-family: var(--mono); + font-size: 14px; + color: #c792ea; + text-transform: uppercase; + letter-spacing: 0.08em; + flex: 1; +} + +.adv-target-toggle { + display: flex; + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +.adv-target-btn { + font-family: var(--mono); + font-size: 14px; + padding: 3px 7px; + cursor: pointer; + color: var(--muted); + background: transparent; + border: none; + transition: all 0.15s; +} + +.adv-target-btn.active-user { + background: var(--accent); + color: #0a0c10; +} + +.adv-target-btn.active-machine { + background: var(--accent2); + color: #0a1a10; +} + +.adv-group-body { + display: none; + padding: 4px 0; + border-top: 1px solid var(--border); + background: var(--input-bg); + border-radius: 0 0 4px 4px; +} + +.adv-group.enabled .adv-group-body { + display: block; +} + +.adv-suboption { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + user-select: none; +} + +.adv-suboption:hover { + background: rgba(255, 255, 255, 0.02); +} + +.adv-sub-checkbox { + width: 12px; + height: 12px; + border: 1px solid var(--border); + border-radius: 2px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + transition: all 0.15s; +} + +.adv-suboption.checked .adv-sub-checkbox { + background: #c792ea; + border-color: #c792ea; + color: #1a0a2e; +} + +.adv-sub-label { + font-family: var(--mono); + font-size: 13px; + color: var(--muted); + flex: 1; +} + +.adv-suboption.checked .adv-sub-label { + color: var(--label); +} + +.adv-sub-field { + display: none; + padding: 2px 12px 8px 32px; +} + +.adv-sub-field.visible { + display: block; +} \ No newline at end of file diff --git a/community/inventory-generator/inventory-generator.html b/community/inventory-generator/inventory-generator.html new file mode 100644 index 00000000..a23fa13e --- /dev/null +++ b/community/inventory-generator/inventory-generator.html @@ -0,0 +1,102 @@ + + + + + + + Inventory Generator + + + + +
+ +
inventory/{users,machines}/
+
+ +
+
+
+
+
Utilisateur
+
inventory/users/username.nix
+
+
+
+
+
+
+ chargement des options… +
+
+ +
+ +
+
+
+
Options avancées
+
0 module(s) activé(s)
+
+
+
+
+
+
+ chargement des options… +
+
+ +
+ +
+
+
+
Machine
+
inventory/machines/SN.nix
+
+
+
+
+
+
+ chargement des options… +
+
+ +
+
+ + + + + + \ 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..0a357244 --- /dev/null +++ b/community/inventory-generator/inventory-generator.js @@ -0,0 +1,932 @@ +/* + * 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', + hasDefault: 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;' + }); + + let justPasted = false; + + inp.addEventListener('paste', function () { + justPasted = true; + }); + + inp.addEventListener('keyup', function (e) { + if (e.key === 'Enter') { + justPasted = false; + } + }); + + inp.onkeydown = function (e) { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + if (justPasted) { + justPasted = false; + return; + } + const v = inp.value.trim().replace(/,$/, ''); + if (v) { + addTag(wrapId, v, inp); + } + } 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, v, inp); + } + }; + } + + 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 && opt.default !== undefined) ? 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 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; }); + 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))); + } + + 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.machine.hardwareSKU']); + const optPaths = new Set([ + '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; }); + 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 + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$\{/g, '\\${') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t') + + '"' + ); +} + +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.user.hashedPassword'); + + const lines = [ + '{ pkgs, ... }:', + '{', + ' securix.self.user = {', + ' email = ' + nixStr(email) + ';', + ' username = ' + nixStr(username) + ';', + ' hashedPassword = ' + nixStr(hpVal && hpVal !== '!' ? hpVal : '!') + ';' + ]; + + const OPTS = new Set([ + '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) { + if (!OPTS.has(opt.path) || opt.internal) { + continue; + } + + const key = opt.path.replace('securix.self.user.', ''); + 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.machine.hardwareSKU', + 'securix.self.machine.inventoryId', + 'securix.self.machine.infraRepositoryPath', + 'securix.self.machine.infraRepositorySubdir' + ]); + + for (const opt of securixOptions) { + if (!OPTS.has(opt.path) || opt.internal) { + continue; + } + + const key = opt.path.replace('securix.self.machine.', ''); + 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, val, inp) { + tagState[wrapId].push(val); + if (inp) { + 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(); + + if (!username) { + setStatus('u-status', '× username 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 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; + }; + }; +} 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/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; }; 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 { }; +} 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 { 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"; diff --git a/modules/updates/permissionless-upgrade.nix b/modules/updates/permissionless-upgrade.nix index 71b83a2d..27bc4f9d 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,29 +231,35 @@ 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 } # Default values BRANCH="${config.securix.auto-updates.branch}" + REPO_PATH="${self.infraRepositoryPath}" 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 +280,10 @@ let REMOTE_PULL=false shift 1 ;; + --securix-branch) + SECURIX_BRANCH="$2" + shift 2 + ;; --use-sn) USE_SN=true shift 1 @@ -279,7 +299,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. @@ -288,9 +308,45 @@ 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 + 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..." @@ -305,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}" @@ -316,12 +370,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 +382,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