From ef102b9657a9622b09f6452930907330a93ac256 Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Wed, 16 Sep 2026 11:14:42 +0300 Subject: [PATCH] feat(field-os): provisioning layer, real ISO build, offline contracts Adds the Blueforce Field OS provisioning stack on top of the existing fleet architecture, without rewriting the working systems (installer, diagnostics, Ansible, WireGuard/SSH/xRDP/RustDesk, Docker policies, monitoring, Docusaurus). ISO build (the previous builder was a skeleton that produced no medium): - boot layout is read from the base ISO at build time via `xorriso -report_el_torito as_mkisofs` and rebuilt with `-as mkisofs`; `-boot_image any replay` cannot work on Ubuntu live ISOs because the GRUB2 MBR lives in the source image's first 16 sectors and the EFI system partition is an appended partition, neither of which is a file in the ISO9660 tree - preflight now asserts El Torito boot metadata positively: xorriso exits 0 on a non-ISO input, which previously let an arbitrary file pass --check - artifact produced and verified: dist/Blueforce-Field-OS-1.0.0-amd64.iso, 6 481 917 952 bytes, SHA256 cb0cc56f...d431296, BIOS+UEFI entries preserved, /autoinstall.yaml at the media root, casper/minimal.squashfs byte-identical to the base - recorded baseline deviation: the verified medium on the build host is the Desktop flavour, so the manifest carries a baseline_deviation block instead of hiding it; Server remains one flag away Offline and identity: - offline APT contract with concrete package requirements, pin format and an audit table; builds fail closed while pins are UNPINNED - autoinstall keeps interactive-sections: [storage]: the operator confirms the target disk, and no automatic wipe is ever performed - firstboot terminal wizard for the 8-digit dealer id, three-state lifecycle (PROVISIONED_OFFLINE -> ENROLLED -> READY) with fail-closed gates, so a missing internet link or WireGuard handshake cannot fail provisioning but can never produce a false READY Fleet and migration: - five read-only Ansible playbooks for Field OS version, provisioning, enrollment, remote channels and offline readiness, all wave-scoped with a separate production override - Windows pre-migration inventory and data export extended to the full brief scope, read-only, never collecting credentials Tests: tests/run-all.sh -> 8/8 pass, exit 0. Boot acceptance (UEFI, Legacy BIOS, Secure Boot), air-gapped install, real enrollment endpoint and Windows rollback remain LAB work and are listed in IMPLEMENTATION-REPORT.md. --- .gitignore | 41 + IMPLEMENTATION-REPORT.md | 228 + README.md | 247 +- SUMMARY.md | 102 +- ansible/inventory/group_vars/all.yml | 22 + ansible/inventory/hosts.example.yml | 54 + ansible/playbooks/bf-collect-logs.yml | 69 + ansible/playbooks/bf-deploy-update.yml | 86 + ansible/playbooks/bf-disk-check.yml | 29 + ansible/playbooks/bf-docker-status.yml | 24 + ansible/playbooks/bf-enrollment-status.yml | 208 + ansible/playbooks/bf-fieldos-version.yml | 208 + ansible/playbooks/bf-gui-off.yml | 42 + ansible/playbooks/bf-gui-on.yml | 38 + ansible/playbooks/bf-offline-ready-check.yml | 272 + ansible/playbooks/bf-package-check.yml | 24 + ansible/playbooks/bf-ping.yml | 12 + ansible/playbooks/bf-provisioning-status.yml | 183 + ansible/playbooks/bf-rdp-restart.yml | 25 + ansible/playbooks/bf-reboot.yml | 40 + .../playbooks/bf-remote-channels-check.yml | 267 + ansible/playbooks/bf-run-script.yml | 76 + ansible/playbooks/bf-rustdesk-restart.yml | 25 + .../playbooks/bf-security-updates-check.yml | 32 + ansible/playbooks/bf-service-restart.yml | 35 + ansible/playbooks/bf-uptime.yml | 16 + ansible/playbooks/bf-wireguard-restart.yml | 25 + ansible/roles/common/README.md | 20 + ansible/roles/common/defaults/main.yml | 4 + ansible/roles/common/tasks/main.yml | 37 + ansible/roles/docker/README.md | 22 + ansible/roles/docker/defaults/main.yml | 12 + ansible/roles/docker/tasks/main.yml | 50 + ansible/roles/monitoring/README.md | 22 + ansible/roles/monitoring/defaults/main.yml | 7 + ansible/roles/monitoring/tasks/main.yml | 25 + ansible/roles/wireguard/README.md | 32 + ansible/roles/wireguard/defaults/main.yml | 10 + ansible/roles/wireguard/handlers/main.yml | 6 + ansible/roles/wireguard/tasks/main.yml | 48 + config/docker/daemon.json | 14 + .../firewall/blueforce-docker-user-restore.sh | 57 + config/firewall/ufw-blueforce.sh | 40 + config/ssh/sshd_config.d/blueforce.conf | 23 + config/systemd/bf-gui-off.service | 10 + config/systemd/bf-gui-on.service | 10 + .../systemd/blueforce-docker-firewall.service | 13 + config/systemd/blueforce-enroll.service | 45 + config/systemd/blueforce-enroll.timer | 11 + .../blueforce-docker-firewall.conf | 3 + config/systemd/journald-blueforce.conf | 9 + config/systemd/logrotate-blueforce | 20 + .../wg-quick@wg0.service.d/override.conf | 7 + config/wireguard/README.md | 34 + config/wireguard/wg0.conf.example | 24 + docs-site/.gitignore | 6 + docs-site/README.md | 19 + docs-site/babel.config.js | 3 + docs-site/docusaurus.config.ts | 30 + docs-site/package-lock.json | 20121 ++++++++++++++++ docs-site/package.json | 31 + docs-site/sidebars.js | 9 + docs-site/src/css/custom.css | 4 + docs-site/src/pages/index.tsx | 19 + docs-site/sync-docs.sh | 29 + docs-site/tsconfig.json | 6 + docs/01-ARCHITECTURE.md | 31 +- docs/03-UBUNTU-BASELINE.md | 69 +- docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md | 68 +- docs/05-ONE-CLICK-INSTALLER.md | 2 +- docs/07-REMOTE-ACCESS.md | 51 +- docs/08-WIREGUARD-AND-NETWORK.md | 2 +- docs/14-MONITORING-AND-HEALTH.md | 2 +- docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md | 2 +- docs/20-FIELD-TECHNICIAN-RUNBOOK.md | 5 +- docs/21-CENTRAL-ADMIN-RUNBOOK.md | 2 +- docs/24-DECISION-LOG.md | 170 +- docs/25-IMPLEMENTATION-ROADMAP.md | 94 +- docs/26-BLUEFORCE-FIELD-OS-ISO.md | 153 + docs/27-OFFLINE-PROVISIONING.md | 116 + docs/28-WINDOWS-TO-LINUX-MIGRATION.md | 145 + docs/29-DEVICE-ENROLLMENT.md | 159 + docs/30-FIELD-OS-RELEASE-MANAGEMENT.md | 111 + docs/31-LEARNING-GUIDE.md | 373 + docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md | 250 + docs/_TEMPLATE.md | 10 + migration/README.md | 30 + monitoring/README.md | 22 + monitoring/docker-compose.yml | 47 + .../grafana/dashboards/fleet-overview.json | 1 + .../provisioning/dashboards/dashboards.yml | 13 + .../provisioning/datasources/prometheus.yml | 10 + monitoring/prometheus/alerts.yml | 54 + monitoring/prometheus/prometheus.yml | 41 + monitoring/prometheus/targets/README.md | 17 + monitoring/prometheus/targets/fleet.json | 10 + provisioning/README.md | 19 + provisioning/autoinstall/README.md | 29 + provisioning/autoinstall/autoinstall.yaml | 28 + provisioning/autoinstall/meta-data | 2 + provisioning/autoinstall/user-data | 20 + provisioning/enrollment/README.md | 143 + .../schemas/enrollment-request.schema.json | 44 + .../schemas/enrollment-response.schema.json | 79 + provisioning/firstboot/README.md | 154 + provisioning/firstboot/bf-firstboot | 390 + .../firstboot/blueforce-firstboot.service | 36 + provisioning/iso/README.md | 159 + provisioning/iso/build-blueforce-iso.sh | 871 + provisioning/iso/field-os-version | 1 + provisioning/iso/test-iso.sh | 27 + provisioning/iso/ubuntu-26.04.1-SHA256SUMS | 6 + provisioning/iso/verify-upstream-iso.sh | 34 + provisioning/offline-repo/README.md | 73 + .../offline-repo/build-offline-repo.sh | 274 + .../offline-repo/manifests/base-packages.txt | 46 + .../offline-repo/manifests/docker.txt | 30 + .../manifests/packages.lock.schema.tsv | 29 + .../offline-repo/manifests/remote-access.txt | 35 + .../offline-repo/manifests/requirements.tsv | 34 + provisioning/offline-repo/packages/.gitkeep | 1 + provisioning/release/README.md | 5 + provisioning/release/manifest.yaml | 9 + repomix-output.xml | 9473 ++++++++ rustdesk/README.md | 48 + rustdesk/docker-compose.yml | 36 + scripts/diagnostics/bf-check-enrollment | 141 + scripts/diagnostics/bf-check-local | 145 + scripts/diagnostics/bf-check-ready | 48 + scripts/diagnostics/bf-diagnostics | 116 + scripts/diagnostics/bf-enroll | 609 + scripts/diagnostics/bf-enroll-now | 4 + scripts/diagnostics/bf-enrollment-status | 223 + scripts/diagnostics/bf-hardware-inventory.sh | 196 + scripts/diagnostics/bf-live-hw-check | 292 + scripts/diagnostics/bf-release | 36 + scripts/diagnostics/bf-remote-status | 36 + scripts/diagnostics/bf-status | 252 + scripts/diagnostics/bf-support-bundle | 150 + scripts/install/README.md | 27 + scripts/install/blueforce-install.sh | 83 + .../install/installer/modules/01-precheck.sh | 47 + .../install/installer/modules/02-system.sh | 60 + scripts/install/installer/modules/03-user.sh | 42 + .../install/installer/modules/04-hostname.sh | 47 + .../install/installer/modules/05-network.sh | 16 + .../install/installer/modules/06-wireguard.sh | 22 + scripts/install/installer/modules/07-ssh.sh | 52 + scripts/install/installer/modules/08-rdp.sh | 50 + .../install/installer/modules/09-rustdesk.sh | 69 + .../install/installer/modules/10-docker.sh | 61 + scripts/install/installer/modules/11-meg.sh | 72 + .../installer/modules/12-monitoring.sh | 44 + .../install/installer/modules/13-firewall.sh | 65 + .../installer/modules/14-update-policy.sh | 22 + .../install/installer/modules/15-logrotate.sh | 58 + scripts/install/installer/modules/16-gui.sh | 24 + .../installer/modules/17-healthcheck.sh | 22 + .../installer/modules/18-final-check.sh | 27 + scripts/install/installer/offline-apt.sh | 40 + .../optional/anydesk/install-anydesk.sh | 11 + scripts/maintenance/bf-gui-off | 62 + scripts/maintenance/bf-gui-on | 112 + .../windows/BF-WindowsDataExport.ps1 | 349 + .../BF-WindowsPreMigrationInventory.ps1 | 1070 + scripts/migration/windows/README.md | 171 + tests/check-ansible-static.sh | 227 + tests/check-configs.sh | 74 + tests/check-migration-static.sh | 163 + tests/check-offline-repo-static.sh | 292 + tests/check-provisioning-static.sh | 335 + tests/check-specs.sh | 166 + tests/qemu-field-os-test-plan.md | 41 + tests/run-all.sh | 83 + tests/semaphore-community-limits.md | 27 + tests/test-field-os-lifecycle.sh | 396 + tests/test-firstboot-static.sh | 516 + 177 files changed, 44584 insertions(+), 226 deletions(-) create mode 100644 IMPLEMENTATION-REPORT.md create mode 100644 ansible/inventory/group_vars/all.yml create mode 100644 ansible/inventory/hosts.example.yml create mode 100644 ansible/playbooks/bf-collect-logs.yml create mode 100644 ansible/playbooks/bf-deploy-update.yml create mode 100644 ansible/playbooks/bf-disk-check.yml create mode 100644 ansible/playbooks/bf-docker-status.yml create mode 100644 ansible/playbooks/bf-enrollment-status.yml create mode 100644 ansible/playbooks/bf-fieldos-version.yml create mode 100644 ansible/playbooks/bf-gui-off.yml create mode 100644 ansible/playbooks/bf-gui-on.yml create mode 100644 ansible/playbooks/bf-offline-ready-check.yml create mode 100644 ansible/playbooks/bf-package-check.yml create mode 100644 ansible/playbooks/bf-ping.yml create mode 100644 ansible/playbooks/bf-provisioning-status.yml create mode 100644 ansible/playbooks/bf-rdp-restart.yml create mode 100644 ansible/playbooks/bf-reboot.yml create mode 100644 ansible/playbooks/bf-remote-channels-check.yml create mode 100644 ansible/playbooks/bf-run-script.yml create mode 100644 ansible/playbooks/bf-rustdesk-restart.yml create mode 100644 ansible/playbooks/bf-security-updates-check.yml create mode 100644 ansible/playbooks/bf-service-restart.yml create mode 100644 ansible/playbooks/bf-uptime.yml create mode 100644 ansible/playbooks/bf-wireguard-restart.yml create mode 100644 ansible/roles/common/README.md create mode 100644 ansible/roles/common/defaults/main.yml create mode 100644 ansible/roles/common/tasks/main.yml create mode 100644 ansible/roles/docker/README.md create mode 100644 ansible/roles/docker/defaults/main.yml create mode 100644 ansible/roles/docker/tasks/main.yml create mode 100644 ansible/roles/monitoring/README.md create mode 100644 ansible/roles/monitoring/defaults/main.yml create mode 100644 ansible/roles/monitoring/tasks/main.yml create mode 100644 ansible/roles/wireguard/README.md create mode 100644 ansible/roles/wireguard/defaults/main.yml create mode 100644 ansible/roles/wireguard/handlers/main.yml create mode 100644 ansible/roles/wireguard/tasks/main.yml create mode 100644 config/docker/daemon.json create mode 100755 config/firewall/blueforce-docker-user-restore.sh create mode 100755 config/firewall/ufw-blueforce.sh create mode 100644 config/ssh/sshd_config.d/blueforce.conf create mode 100644 config/systemd/bf-gui-off.service create mode 100644 config/systemd/bf-gui-on.service create mode 100644 config/systemd/blueforce-docker-firewall.service create mode 100644 config/systemd/blueforce-enroll.service create mode 100644 config/systemd/blueforce-enroll.timer create mode 100644 config/systemd/docker.service.d/blueforce-docker-firewall.conf create mode 100644 config/systemd/journald-blueforce.conf create mode 100644 config/systemd/logrotate-blueforce create mode 100644 config/systemd/wg-quick@wg0.service.d/override.conf create mode 100644 config/wireguard/README.md create mode 100644 config/wireguard/wg0.conf.example create mode 100644 docs-site/.gitignore create mode 100644 docs-site/README.md create mode 100644 docs-site/babel.config.js create mode 100644 docs-site/docusaurus.config.ts create mode 100644 docs-site/package-lock.json create mode 100644 docs-site/package.json create mode 100644 docs-site/sidebars.js create mode 100644 docs-site/src/css/custom.css create mode 100644 docs-site/src/pages/index.tsx create mode 100755 docs-site/sync-docs.sh create mode 100644 docs-site/tsconfig.json create mode 100644 docs/26-BLUEFORCE-FIELD-OS-ISO.md create mode 100644 docs/27-OFFLINE-PROVISIONING.md create mode 100644 docs/28-WINDOWS-TO-LINUX-MIGRATION.md create mode 100644 docs/29-DEVICE-ENROLLMENT.md create mode 100644 docs/30-FIELD-OS-RELEASE-MANAGEMENT.md create mode 100644 docs/31-LEARNING-GUIDE.md create mode 100644 docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md create mode 100644 migration/README.md create mode 100644 monitoring/README.md create mode 100644 monitoring/docker-compose.yml create mode 100644 monitoring/grafana/dashboards/fleet-overview.json create mode 100644 monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 monitoring/prometheus/alerts.yml create mode 100644 monitoring/prometheus/prometheus.yml create mode 100644 monitoring/prometheus/targets/README.md create mode 100644 monitoring/prometheus/targets/fleet.json create mode 100644 provisioning/README.md create mode 100644 provisioning/autoinstall/README.md create mode 100644 provisioning/autoinstall/autoinstall.yaml create mode 100644 provisioning/autoinstall/meta-data create mode 100644 provisioning/autoinstall/user-data create mode 100644 provisioning/enrollment/README.md create mode 100644 provisioning/enrollment/schemas/enrollment-request.schema.json create mode 100644 provisioning/enrollment/schemas/enrollment-response.schema.json create mode 100644 provisioning/firstboot/README.md create mode 100755 provisioning/firstboot/bf-firstboot create mode 100644 provisioning/firstboot/blueforce-firstboot.service create mode 100644 provisioning/iso/README.md create mode 100755 provisioning/iso/build-blueforce-iso.sh create mode 100644 provisioning/iso/field-os-version create mode 100755 provisioning/iso/test-iso.sh create mode 100644 provisioning/iso/ubuntu-26.04.1-SHA256SUMS create mode 100755 provisioning/iso/verify-upstream-iso.sh create mode 100644 provisioning/offline-repo/README.md create mode 100755 provisioning/offline-repo/build-offline-repo.sh create mode 100644 provisioning/offline-repo/manifests/base-packages.txt create mode 100644 provisioning/offline-repo/manifests/docker.txt create mode 100644 provisioning/offline-repo/manifests/packages.lock.schema.tsv create mode 100644 provisioning/offline-repo/manifests/remote-access.txt create mode 100644 provisioning/offline-repo/manifests/requirements.tsv create mode 100644 provisioning/offline-repo/packages/.gitkeep create mode 100644 provisioning/release/README.md create mode 100644 provisioning/release/manifest.yaml create mode 100644 repomix-output.xml create mode 100644 rustdesk/README.md create mode 100644 rustdesk/docker-compose.yml create mode 100755 scripts/diagnostics/bf-check-enrollment create mode 100755 scripts/diagnostics/bf-check-local create mode 100755 scripts/diagnostics/bf-check-ready create mode 100755 scripts/diagnostics/bf-diagnostics create mode 100755 scripts/diagnostics/bf-enroll create mode 100755 scripts/diagnostics/bf-enroll-now create mode 100755 scripts/diagnostics/bf-enrollment-status create mode 100755 scripts/diagnostics/bf-hardware-inventory.sh create mode 100755 scripts/diagnostics/bf-live-hw-check create mode 100755 scripts/diagnostics/bf-release create mode 100755 scripts/diagnostics/bf-remote-status create mode 100755 scripts/diagnostics/bf-status create mode 100755 scripts/diagnostics/bf-support-bundle create mode 100644 scripts/install/README.md create mode 100755 scripts/install/blueforce-install.sh create mode 100755 scripts/install/installer/modules/01-precheck.sh create mode 100755 scripts/install/installer/modules/02-system.sh create mode 100755 scripts/install/installer/modules/03-user.sh create mode 100755 scripts/install/installer/modules/04-hostname.sh create mode 100755 scripts/install/installer/modules/05-network.sh create mode 100755 scripts/install/installer/modules/06-wireguard.sh create mode 100755 scripts/install/installer/modules/07-ssh.sh create mode 100755 scripts/install/installer/modules/08-rdp.sh create mode 100755 scripts/install/installer/modules/09-rustdesk.sh create mode 100755 scripts/install/installer/modules/10-docker.sh create mode 100755 scripts/install/installer/modules/11-meg.sh create mode 100755 scripts/install/installer/modules/12-monitoring.sh create mode 100755 scripts/install/installer/modules/13-firewall.sh create mode 100755 scripts/install/installer/modules/14-update-policy.sh create mode 100755 scripts/install/installer/modules/15-logrotate.sh create mode 100755 scripts/install/installer/modules/16-gui.sh create mode 100755 scripts/install/installer/modules/17-healthcheck.sh create mode 100755 scripts/install/installer/modules/18-final-check.sh create mode 100644 scripts/install/installer/offline-apt.sh create mode 100755 scripts/install/optional/anydesk/install-anydesk.sh create mode 100755 scripts/maintenance/bf-gui-off create mode 100755 scripts/maintenance/bf-gui-on create mode 100644 scripts/migration/windows/BF-WindowsDataExport.ps1 create mode 100644 scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1 create mode 100644 scripts/migration/windows/README.md create mode 100755 tests/check-ansible-static.sh create mode 100755 tests/check-configs.sh create mode 100755 tests/check-migration-static.sh create mode 100755 tests/check-offline-repo-static.sh create mode 100755 tests/check-provisioning-static.sh create mode 100755 tests/check-specs.sh create mode 100644 tests/qemu-field-os-test-plan.md create mode 100755 tests/run-all.sh create mode 100644 tests/semaphore-community-limits.md create mode 100755 tests/test-field-os-lifecycle.sh create mode 100755 tests/test-firstboot-static.sh diff --git a/.gitignore b/.gitignore index 859e87d..37d17c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,43 @@ .directory + +# Local agent/plan state — never committed .hermes/ + +# --------------------------------------------------------------------------- +# Field OS build artifacts. +# These are large, generated, and reproducible from a verified upstream ISO. +# The upstream ISO itself is NOT stored in git (2.9 GB); its SHA256 is recorded +# in the release manifest instead. See docs/26 and docs/30. +# --------------------------------------------------------------------------- +.build-lab/ +dist/ +*.iso +*.iso.sha256 +*.iso.boot-report.txt + +# Offline APT repository output (generated by provisioning/offline-repo) +provisioning/offline-repo/out/ +provisioning/offline-repo/pool/ +Packages +Packages.gz +packages.lock.tsv + +# Documentation site (generated) +docs-site/node_modules/ +docs-site/build/ +docs-site/.docusaurus/ +docs-site/docs/ +fetched-logs/ + +# Secrets must never be committed — belt and braces alongside the secret scan +# in tests/. Any private key, token file, or credential store belongs in a +# root-owned path on the host, never in this repository. +*.key +*.pem +*.p12 +*.pfx +id_rsa* +id_ed25519* +*credentials* +*token* +*.secret diff --git a/IMPLEMENTATION-REPORT.md b/IMPLEMENTATION-REPORT.md new file mode 100644 index 0000000..ff0f745 --- /dev/null +++ b/IMPLEMENTATION-REPORT.md @@ -0,0 +1,228 @@ +# Blueforce Field OS — Uygulama Raporu + +**Tarih:** 2026-09-16 +**Kapsam:** Windows 10 saha PC'lerinin Blueforce Field OS'a dönüştürülmesi için provisioning, ISO üretimi, offline kurulum, cihaz durumu ve enrollment katmanları. +**Yöntem:** Bu rapordaki her sayı depodaki gerçek dosyalardan, çalıştırılan testlerden ve üretilen ISO artefaktından okunmuştur. Doğrulanamayan hiçbir iddia "tamam" sayılmamıştır. + +> **Dürüstlük sınırı:** Gerçek bir ISO üretildi ve doğrulandı. Ancak gerçek donanımda boot, gerçek Windows cihaz, gerçek merkez enrollment endpoint'i ve gerçek air-gapped `.deb` kurulumu **henüz yapılmadı**. Bu maddeler §7'de listelidir. + +--- + +## 1. Önceden ne vardı? + +- 00–25 numaralı mimari, işletim, güvenlik, rollout ve runbook dokümantasyonu; `scripts/` (installer + diagnostics), `ansible/`, `config/`, `monitoring/`, `rustdesk/`, `docs-site/` iskeleti. +- Çalışan `blueforce-install.sh` + 18 modül, `BF-<8 hane>` kimlik standardı, `bf-status` / `bf-diagnostics` / `bf-support-bundle` / `bf-hardware-inventory.sh`. +- WireGuard, SSH, xRDP, RustDesk OSS, Docker politikaları, UFW/Docker firewall, log yönetimi, recovery runbook'ları. +- **Eksik olan:** Field OS medya yaşam döngüsü (ISO gerçekten üretilmiyordu), offline paket deposu (manifestler boştu), firstboot kimlik akışı, 3 aşamalı durum modeli, enrollment sözleşmesi, Windows geçiş araçları ve doküman tutarlılığı. + +--- + +## 2. Ne değişti? + +### 2.1 ISO artık gerçekten üretiliyor (en kritik kazanım) + +Önceki durumda `build-blueforce-iso.sh` bir iskeletti: `--allow-remaster` olmadan çıktı üretmiyor, boot menüsünü entegre etmiyor, offline repo eklemiyordu. Şimdi gerçek medya üretiyor. + +**Üretilen artefakt (doğrulandı):** + +| Alan | Değer | +|---|---| +| Dosya | `dist/Blueforce-Field-OS-1.0.0-amd64.iso` | +| Boyut | 6 481 917 952 byte (6.04 GiB) | +| ISO SHA256 | `cb0cc56f0442fbd1418e0d0f35521522f4ec9aa3f203779a67e96d587d431296` | +| Base ISO | `ubuntu-26.04.1-desktop-amd64.iso`, SHA256 `601e30fbf5d97759367c632e2c33630665039b7e2158fd068403da3ccf1bda1f` (Canonical `SHA256SUMS` ile birebir) | +| Base flavour | `desktop` (bkz. §8 baseline sapması) | +| Boot | El Torito **BIOS + UEFI** korundu | +| Build | 2026-09-16T08:00:29Z | + +**Çözülen kök nedenler** (hepsi ölçülmüş hata mesajlarıyla, kalıcı referans: `ubuntu-iso-remaster` skill): + +1. `-boot_image any replay` Ubuntu ISO'larında çalışmaz — GRUB2 MBR kodu kaynak imajın ilk 16 sektöründe, EFI sistemi ise ISO dosya sistemi *dışında* appended partition olarak durur. Çözüm: boot düzenini `xorriso -report_el_torito as_mkisofs` çıktısından okuyup `-as mkisofs` ile yeniden kurmak. +2. `-as mkisofs` xorriso-native `-volume_date` opsiyonunu tanımaz → `--modification-date` kullanılır. +3. `as_mkisofs` kendi `--modification-date` satırını verir; xorriso bunu `-volume_date uuid`'ye çevirip reddeder → filtrelenir. +4. Opsiyon satırları tek argümana çöküyordu → tırnak-honor eden tokenizer (30 argüman). +5. `sed` ile yol değişimi tırnakları bozuyor ve `--interval` alanlarını kaydırıyordu → yol yeniden yazılmaz. + +### 2.2 Offline kurulum sözleşmesi + +- `provisioning/offline-repo/manifests/{base-packages,remote-access,docker}.txt` artık hangi paketlerin gerektiğini **somut** listeliyor (pin formatı `package=version`), paket sürümleri LAB'da doldurulacak. +- `build-offline-repo.sh`: `apt-ftparchive` + `dpkg-deb` zorunlu; pin semantiği doğrulanır; placeholder/eksik pin net hatayla reddedilir. +- ISO içine gömülü repo olmadığında medium `offline_repo_status: not-included` yazar ve hedef cihaz `OFFLINE BLOCKED` ile kapalı kalır — sessizce "kurulmuş gibi" yapmaz. + +### 2.3 Cihaz kimliği ve durum modeli + +- `bf-firstboot`: bayi numarası terminal wizard'ı (`^[0-9]{8}$`), hatalı girişi kabul etmez, 3 denemede net hata; non-interactive modda dosya/argüman yolu. Kimlik değişimi ayrı admin/recovery prosedürüne bağlı. +- Üç aşamalı model: **`PROVISIONED_OFFLINE` → `ENROLLED` → `READY`**. İleri yönlü, atlama ve geri dönüş yok; her geçiş atomik ve 0600. +- `bf-status` artık `Provisioning / Enrollment / Fleet State` satırlarını gösteriyor. +- **Kritik davranış:** internet/WireGuard yokluğu kurulumu FAIL etmez. `bf-check-local` internetsiz PASS verebilir; `bf-check-enrollment` ve `bf-check-ready` merkez kanıtı olmadan **fail-closed** kalır. + +### 2.4 Enrollment (tasarım + istemci) + +- Tek kullanımlık token akışı seçildi; imzalı bootstrap bundle / kalıcı ortak secret / manuel kayıt karşılaştırılıp elendi. +- **700 cihaza aynı kalıcı secret konmaz.** WireGuard **private key cihazdan çıkmaz**; yalnız public key + non-secret metadata gönderilir. +- `blueforce-enroll.service` sınırlı yeniden deneme (`RestartSec`, `StartLimitBurst`), reboot loop yok; internet gelince kendiliğinden katılır, yoksa `PENDING` kalır. +- **Merkezi endpoint bilinçli olarak yazılmadı** (prompt §18: önce tasarım). Sözleşme JSON şemalarıyla sabitlendi. + +### 2.5 Filo otomasyonu + +- Mevcut 16 playbook korundu; **5 read-only** playbook eklendi: `bf-fieldos-version` (hangi cihazda hangi sürüm), `bf-provisioning-status`, `bf-enrollment-status`, `bf-remote-channels-check`, `bf-offline-ready-check`. +- Her playbook dalga kapsam kapısı taşıyor; production ayrıca `production_override` ister. Kapı matrisi canlı test edildi (boş/hatalı dalga, onaysız koşu, dalga dışı `--limit` → rc=2). + +### 2.6 Windows geçişi ve canlı donanım kontrolü + +- Read-only PowerShell envanter/veri dışa aktarma araçları prompt kapsamına (ağ, yazılım, servisler, COM/USB, VPN varlığı, MEG izleri) çıkarıldı; credential toplama yasak. +- `bf-live-hw-check`: Windows silinmeden önce NIC/disk/CPU/RAM/serial/USB kontrolü, `SUPPORTED / WARNING / BLOCKED` sonucu, mutasyonsuz. + +### 2.7 Güvenlik ve dokümantasyon + +- Depo çapında secret taraması; ISO'ya cihaz kimliği/private key/parola gömülmediği builder tarafından **reddedilir**. +- `.gitignore`: ISO, offline repo çıktısı, build scratch ve tüm anahtar/kimlik desenleri repo dışında. +- Doküman seti 33 dosyaya çıktı; medya stratejisi, RDP modeli, durum modeli, GNOME/XFCE ölçümü ve Field OS sürüm takibi kararları tek sesli hale getirildi. + +--- + +## 3. Hangi yeni sistemler eklendi? + +| Sistem | Ne yapar | +|---|---| +| **Field OS ISO build** | Doğrulanmış base ISO'dan tek USB'lik bootable medya üretir; boot düzenini base ISO'dan okuyup yeniden kurar | +| **Assisted autoinstall** | Kurulum otomatik, **disk seçimi operatörde** (`interactive-sections: [storage]`); otomatik wipe yok | +| **Offline APT repo** | Yerel, sürüm pinli `.deb` havuzundan checksum'lı repo üretir | +| **Firstboot wizard** | Bayi numarasını konsoldan toplar, kimliği üretir, offline kurucuyu çağırır | +| **3 aşamalı durum makinesi** | `PROVISIONED_OFFLINE` / `ENROLLED` / `READY`, fail-closed kapılar | +| **Enrollment istemcisi** | Tek kullanımlık token, replay engeli, private key dışarı çıkmaz | +| **Field OS sürüm takibi** | `/etc/blueforce-release`, `bf-release`, filo çapında sürüm raporlama | +| **Windows öncesi keşif** | Salt-okunur envanter + geri dönüş için veri dışa aktarma | +| **Canlı donanım uygunluğu** | Kurulumdan önce SUPPORTED/WARNING/BLOCKED kararı | +| **Dalga kapılı filo otomasyonu** | 21 playbook, dalga + production override zorunluluğu | + +--- + +## 4. Neden eklendi? + +- **Saha interneti yokken kurulum yapılabilmeli** ama cihaz merkez kanıtı olmadan yanlışlıkla READY sayılmamalı. +- **Disk silme güvenliği:** bu ISO, üzerinde başka bir işletim sistemi bulunan gerçek saha PC'lerinde kullanılacak. Bu nedenle V1 bilinçli olarak *assisted*: teknisyen hedef diski onaylar. +- **İzlenebilirlik:** Field OS sürümü, base ISO hash'i, offline snapshot ve kurucu sürümü ayrı ayrı kayıt altında; rollback mümkün. +- **Tek medya kolaylığı:** autoinstall ile Field OS ISO rakip değil, tamamlayıcıdır — biri motor, diğeri paketleme. +- **700 cihazda serbest değişiklik yok:** dalga, onay ve rollback kapıları korunur. + +--- + +## 5. Hangi dosyalar değişti? + +| Alan | İçerik | +|---|---| +| **Provisioning** | `provisioning/{iso,autoinstall,offline-repo,firstboot,enrollment,release}/` | +| **Tanı ve durum** | `scripts/diagnostics/` — `bf-status`, `bf-check-local`, `bf-check-enrollment`, `bf-check-ready`, `bf-enroll`, `bf-enroll-now`, `bf-enrollment-status`, `bf-release`, `bf-remote-status`, `bf-live-hw-check`, `bf-support-bundle`, `bf-hardware-inventory.sh` | +| **Kurulum** | `scripts/install/` (18 modül), `scripts/install/optional/anydesk/` | +| **Geçiş** | `scripts/migration/windows/` + kökte `migration/` işaret dosyası | +| **Filo** | `ansible/{inventory,playbooks,roles}/` (21 playbook, 4 rol) | +| **Yapılandırma** | `config/` (ssh, wireguard, firewall, docker, systemd) | +| **İzleme / uzak erişim** | `monitoring/`, `rustdesk/` | +| **Testler** | `tests/` — 8 test scripti + `run-all.sh` + QEMU planı | +| **Doküman** | `docs/00`–`docs/32` (33 dosya), `README.md`, `SUMMARY.md` | +| **Site** | `docs-site/` (Docusaurus) | + +`dist/`, `.build-lab/`, `*.iso` ve anahtar/secret desenleri `.gitignore` ile repo dışında tutulur. + +--- + +## 6. Hangi testler geçti? + +`bash tests/run-all.sh` → **8/8 PASS, exit 0** (2026-09-16): + +| Test | Kapsam | +|---|---| +| `check-specs.sh` | 33 doküman gapless, §1–§13 sıralı, her dokümanda diyagram, 21 playbook sözleşmesi, secret/moving-tag yasağı | +| `check-configs.sh` | Bash sözdizimi, YAML/JSON parse, inventory dalga grupları | +| `check-provisioning-static.sh` | Autoinstall kimliksiz, otomatik disk silme yok, interaktif depolama kapısı, secret taraması | +| `check-offline-repo-static.sh` | Pin semantiği, placeholder yasağı | +| `check-migration-static.sh` | Windows araçları read-only, credential toplamaz | +| `check-ansible-static.sh` | Kapı deseni, report-only security check, moving tag/Watchtower yasağı | +| `test-firstboot-static.sh` | Bayi no doğrulaması, hatalı giriş reddi, idempotentlik | +| `test-field-os-lifecycle.sh` | Durum geçişleri, offline local gate, ENROLLED fail-closed, replay engeli | + +**ISO doğrulaması (bağımsız, iki araç):** + +| Kontrol | Sonuç | +|---|---| +| El Torito BIOS + UEFI | ikisi de korunmuş | +| Blueforce payload (6 yol) | tümü medyada | +| `interactive-sections: [storage]` | korunmuş | +| Kimlik/secret gömülü mü | **hayır** | +| `apt.fallback: offline-install` | tanımlı | +| GRUB autoinstall satırları | 2 kernel satırı | +| `casper/minimal.squashfs` (3.43 GB) bütünlüğü | **birebir aynı** (xorriso `cmp` + `7z` sha256: `b5ec27b9570e77abb62831a8b652b50eaf089d537e62046189a52231a4fa9bec`) | + +--- + +## 7. Hangi testler LAB gerektiriyor? + +`tests/qemu-field-os-test-plan.md` T1–T18 henüz **çalıştırılmadı**: + +- **Boot kabulü:** UEFI, Legacy BIOS ve **Secure Boot** altında gerçek boot. Remaster edilen medyanın boot zinciri yalnızca metadata düzeyinde doğrulandı. +- **Autoinstall:** Desktop flavour'da Subiquity'nin `/autoinstall.yaml`'ı gerçekten uyguladığı ve depolama ekranını interaktif bıraktığı. +- **Air-gapped kurulum:** gerçek offline `.deb` seti ile bağımlılık çözümü. +- **Firstboot:** gerçek konsolda wizard davranışı; systemd unit'inin boot'u kilitlememesi. +- **GNOME vs XFCE A/B ölçümü** (RAM/CPU/boot süresi/RDP/RustDesk headless/24h-72h stabilite). +- **xRDP + RustDesk headless:** fiziksel monitörsüz cihazda login ekranı ve dummy display davranışı. +- **Power-loss / otomatik kurtarma**, güç dönüşünde servis zinciri. +- **Enrollment:** gerçek merkez endpoint'i, token TTL/iptal, merkezi doğrulama kanıtları. +- **Windows geri dönüş provası:** image restore ve iş akışının geri gelmesi. + +--- + +## 8. Hangi kararlar açık? + +1. **Baseline sapması (LAB kararı gerekli):** kanonik baseline *Ubuntu Server 26.04.1+*; build host'ta doğrulanmış mevcut medya **Desktop** flavour olduğu için ISO bu tabanla üretildi. Sapma manifest'te `baseline_deviation` bloğuyla kayıtlı. Karar: LAB için Desktop kabul edilsin mi, yoksa Server ISO ile mi yeniden üretilsin? (Server yolu tek parametre uzakta.) +2. **Offline paket pinleri:** manifestler hangi paketlerin gerektiğini söylüyor, ancak **somut sürümler ve `.deb` dosyaları yok** — offline repo henüz üretilmedi. Ubuntu 26.04 build makinesi gerekir. +3. **Secure Boot:** remaster edilen medyanın Secure Boot davranışı test edilmedi. +4. **Enrollment kontrol düzlemi:** endpoint, kimlik doğrulama, imzalama, token TTL/iptal politikası ve audit saklama. +5. **Release artefakt saklama:** ISO/seed/offline snapshot retention, imza anahtarı sahibi, release kadansı, EOL. +6. **Windows imaj saklama:** süre, erişim yetkisi, imha ve geri dönüş RTO. +7. **MEG `.deb`:** paket gelmedi; acceptance checklist hazırlıklı bekliyor, hiçbir servis adı/config yolu varsayılmadı. +8. **ISO boyutu:** Desktop tabanı 6.04 GiB; Server tabanına göre (~2.9 GB) USB dağıtımını etkiler. + +--- + +## 9. İlk gerçek LAB PC'sinde sırayla ne yapacağız? + +1. Temsilci Windows PC'yi seç; bayi no ve seri no'yu iki bağımsız kayıtla doğrula. İş sahibi onayı ve kesinti penceresi al. +2. Windows üzerinde `BF-WindowsPreMigrationInventory.ps1` ve `BF-WindowsDataExport.ps1` çalıştır (salt-okunur). Hash doğrulamalı Windows geri dönüş imajı al; nerede tutulduğunu ve restore sahibini kaydet. +3. ISO SHA256'sını doğrula: `sha256sum -c dist/Blueforce-Field-OS-1.0.0-amd64.iso.sha256` → `cb0cc56f...`. +4. Medyayı USB'ye yaz ve **kurulumdan önce canlı oturumda** `bf-live-hw-check` çalıştır. `BLOCKED` çıkarsa dur. +5. Kurulumu başlat. Autoinstall çalışır ama **depolama ekranında hedef diski teknisyen onaylar** — otomatik wipe yok. +6. İlk açılışta wizard'da bayi numarasını gir (ör. `12010193` → `BF-12010193` / `bf-12010193`). `bf-status` ile **yalnız `PROVISIONED_OFFLINE`** görüldüğünü doğrula; cihazı READY olarak teslim etme. +7. Ağ olmadan dış indirme olmadığını ve local servis/boot loglarını kaydet. +8. Kontrollü ağ erişimi sağlandığında ve merkez kontrol düzlemi hazır olduğunda tek kullanımlık token ile enrollment yap (token'ı USB'ye/loga/rapora yazma). +9. `ENROLLED` sonrası WireGuard handshake, xRDP, RustDesk, MeshCentral, monitoring kanıtlarını ayrı ayrı doğrula. Tümü geçmeden READY yazma. +10. Gözlem pencereleri: **24 saat** boot/servis/ağ/uzak erişim · **72 saat** kesinti ve log/disk kararlılığı · **7 gün** saha iş akışı, güç dönüşü ve Windows restore provası. Kanıtlar release kaydına bağlanmadan pilot dalgaya geçme. + +--- + +## 10. Production'a geçmeden önce yapılacaklar + +- **Boot kabulü:** UEFI, Legacy BIOS ve Secure Boot matrisini gerçek donanımda kanıtla. +- **Gerçek offline paket seti:** Ubuntu 26.04 build makinesinde pinli `.deb` havuzunu üret, checksum/lock kaydı al, air-gapped kurulumda dene, ISO'ya gömüp yeniden build et. +- **Baseline kararı:** Desktop tabanını kabul et veya Server ISO ile yeniden üret (manifest `baseline_deviation` kaydı buna göre güncellenir). +- **Enrollment kontrol düzlemi:** endpoint + kimlik doğrulama + imzalama + token TTL/iptal + audit retention. +- **Merkez izleme/uzak erişim kanallarını** gerçek VDS üzerinde doğrula (RustDesk self-hosted hbbs/hbbr, Prometheus/Grafana/Uptime Kuma). +- **Release yönetimi:** artefakt deposu, imza anahtarı, retention/EOL, rollback prosedürü. +- **Windows veri/imaj saklama** ve geri dönüş RTO'sunu resmileştir. +- **Pilot hazırlığı:** envanter, alarm/on-call, eğitim, change approval ve dalga kapıları. Sıra: LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → production; her adım öncekinin kanıtı olmadan ilerlemez. +- **Zayıflatılmayacak kurallar:** onaysız update yasağı (apt, release upgrade, Docker image, MEG, Field OS), dalga onay kapıları, disk silme güvenliği, secret yasağı. + +--- + +## 11. Build ortamı notu + +ISO build'i bu makinede (CachyOS/Arch) **konteyner içinde** çalıştırıldı (`xorriso 1.5.6`), host'a paket kurulmadı. `apt-ftparchive`/`dpkg-deb` bu host'ta bulunmadığı için **offline `.deb` repo üretimi Ubuntu 26.04 build makinesi gerektirir** (§10). + +--- + +## 12. Delil özeti (git diff ve repo ağacı) + +- Commit edilmemiş çalışma ağacı: değiştirilmiş dokümanlar + yeni provisioning/ansible/config/monitoring/scripts/tests dosyaları. +- Üretilen artefakt: `dist/Blueforce-Field-OS-1.0.0-amd64.iso` (+ `.sha256`, `1.0.0-manifest.yaml`). +- Test sonucu: 8/8 PASS, exit 0. +- Repo ağacının tam çıktısı commit sonrası `git status` / `git diff --stat` ile alınır. diff --git a/README.md b/README.md index 8cf43c3..a57e671 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,116 @@ -# Blueforce Linux Filo — 700 Saha Cihazı +# Blueforce Field OS -> %100 ücretsiz / self-hosted Linux filo mimarisi: başka bir DevOps mühendisinin sıfırdan kurabileceği 26 Türkçe dokümanın ana reposu. +> Ubuntu LTS tabanlı, +> 700 endüstriyel saha bilgisayarı için, +> offline kurulabilen, +> merkezden yönetilebilen, +> onaysız update yapmayan, +> çoklu uzak erişim kanallı +> **saha işletim sistemi standardı**. -## Amaç +%100 ücretsiz / self-hosted Linux filo mimarisi: başka bir DevOps mühendisinin sıfırdan kurabileceği doküman setinin ve çalışan iskeletin ana reposu. Dokümanlar Türkçedir; komut, dosya, servis ve terim adları İngilizce kalır. -AdBlue saha otomasyonundaki ~700 Windows mini PC'yi, lisans maliyeti olmayan, merkezi yönetilebilir, kesintisiz çalışan bir Linux filosuna taşımak. +## Field OS Nedir? -Bu repo üç şeyi tek yerde toplar: +Blueforce Field OS, AdBlue saha otomasyonundaki ~700 Windows mini PC'yi lisans maliyeti olmayan, merkezi yönetilebilir ve kesintisiz çalışan bir Linux filosuna taşıyan **standarttır**. Üç katmanı vardır: -1. **Kararlar** — her teknik seçim için gerekçe, alternatif, risk, maliyet ve lisans notu. -2. **Kurulum ve işletme dokümanları** — `docs/` altında 26 Türkçe Markdown dosyası. -3. **Uygulanabilir iskelet** — `scripts/`, `ansible/`, `config/`, `monitoring/`, `rustdesk/` klasörleri (kod ve config Faz 3'te dolar). +1. **Zemin (OS standardı)** — Ubuntu Server 26.04.1+ LTS minimal kurulum, maintain edilen GNOME katmanı, terminale boot (`multi-user.target`), `unattended-upgrades` kapalı. +2. **Provisioning (kurulum standardı)** — tek `blueforce-install.sh` kurucu + autoinstall/NoCloud + sürümlü offline APT snapshot + firstboot; hepsi tek USB `Blueforce-Field-OS--amd64.iso` içinde paketlenebilir (assisted: operatör disk onayı verir, otomatik disk silme yoktur). +3. **İşletme (filo standardı)** — WireGuard hub-spoke, 4 uzak erişim kanalı, Docker/MEG uygulamaları, Prometheus + Grafana + Uptime Kuma izleme, Ansible + Semaphore filo yönetimi ve onay kapılı güncelleme dalgaları (`LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD(~523)`). Öncelik sırası: **kesintisiz çalışma > veri kaybı yok > uzaktan erişim > bakım kolaylığı.** +## Katman Mimarisi + +```mermaid +flowchart TB + subgraph SAHA["Saha cihazı — bf- (Blueforce Field OS)"] + L8["Fleet Mgmt — Ansible / Semaphore (push, dalga kapılı)"] + L7["Monitoring — Prometheus + Node Exporter + Grafana + Uptime Kuma"] + L6["Docker Apps — MEG ve saha uygulamaları (unless-stopped, sabit tag)"] + L5["Remote Access — xRDP(her zaman hazır) + RustDesk + MeshCentral + SSH"] + L4["Provisioning — blueforce-install.sh + autoinstall + offline APT + firstboot"] + L3["Base Config — kimlik, SSH/sudo, UFW, Docker, logging, update policy"] + L2["Ubuntu LTS (Server 26.04.1+ minimal, GNOME katmanı, terminale boot)"] + L2 --> L3 --> L4 --> L5 --> L6 --> L7 --> L8 + end + L8 -.->|"WireGuard hub-spoke"| MERKEZ["Merkez (VDS): WG hub + hbbs/hbbr + MeshCentral + Semaphore + Prometheus/Grafana + Kuma"] +``` + +| Katman | İçerik | Kilit karar | Doküman | +|---|---|---|---| +| Ubuntu LTS | Ubuntu Server 26.04.1+ minimal, GNOME, `multi-user.target` | K-01, K-02, K-03 | [`03`](docs/03-UBUNTU-BASELINE.md) | +| Base Config | `BF-`/`bf-` kimliği, SSH (anahtar zorunlu), UFW, Docker politikası, journald/log rotasyonu, otomatik update yasağı | K-10, K-11, K-12, K-13, K-14 | [`02`](docs/02-DEVICE-NAMING-AND-INVENTORY.md), [`06`](docs/06-USERS-SSH-AND-PERMISSIONS.md), [`11`](docs/11-SECURITY-HARDENING.md), [`12`](docs/12-DOCKER-OPERATIONS.md), [`15`](docs/15-LOGGING.md) | +| Provisioning | `blueforce-install.sh` (18 modül, `--check`), autoinstall + NoCloud seed, offline APT repo, firstboot, Field OS ISO | K-09, K-15, K-16, K-20 | [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md), [`05`](docs/05-ONE-CLICK-INSTALLER.md), [`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md), [`27`](docs/27-OFFLINE-PROVISIONING.md) | +| Remote Access | xRDP (her zaman hazır) + RustDesk OSS self-hosted + MeshCentral + SSH-over-WireGuard = 4 kanal | K-04, K-05, K-22 | [`07`](docs/07-REMOTE-ACCESS.md), [`08`](docs/08-WIREGUARD-AND-NETWORK.md) | +| MEG | Windows'tan taşınan MEG uygulaması ve kabul kriterleri | K-19 | [`13`](docs/13-MEG-LINUX-ACCEPTANCE.md), [`28`](docs/28-WINDOWS-TO-LINUX-MIGRATION.md) | +| Docker Apps | Saha konteynerleri: `unless-stopped`, sabit tag, rotasyonlu log, `127.0.0.1` bind | K-10 | [`12`](docs/12-DOCKER-OPERATIONS.md) | +| Monitoring | Prometheus + Node Exporter + Grafana OSS (17 metrik) + Uptime Kuma "last seen" | K-07, K-14 | [`14`](docs/14-MONITORING-AND-HEALTH.md) | +| Fleet Mgmt | Ansible CLI (birincil) + Semaphore UI Community (operatör arayüzü), 15 standart işlem | K-06, K-11 | [`09`](docs/09-FLEET-MANAGEMENT.md), [`10`](docs/10-UPDATE-AND-ROLLBACK-POLICY.md) | + +## Kurulum Akışı (özet) + +```mermaid +flowchart LR + ISO["Field OS ISO (tek USB)
veya upstream ISO + seed"] --> FB["firstboot
BLUEFORCE FIELD OS
8 haneli bayi no"] + FB --> INST["blueforce-install.sh --offline
18 modül + final-check"] + INST --> PO["PROVISIONED_OFFLINE
yerel kurulum bitti, merkez doğrulaması yok"] + PO -->|"internet gelince"| EN["ENROLLED
tek kullanımlık token + WireGuard"] + EN -->|"WG + erişim + monitoring doğrulandı"| RDY["READY
teslim edilebilir"] +``` + +1. **Medya** — F2B'den itibaren tek USB: `Blueforce-Field-OS--amd64.iso` (autoinstall + offline APT repo + firstboot içinde). Geri dönüş yolu: resmi Ubuntu Server ISO + NoCloud seed USB. +2. **Kurulum** — Autoinstall çalışır; **storage adımı operatör onayı bekler, otomatik disk silme yoktur** (`interactive-sections: [storage]`). İnternet yoksa kurulum yerel snapshot'tan tamamlanır. +3. **firstboot** — Terminal wizard 8 haneli bayi noyu alır, `blueforce-install.sh --offline` çağırır; sonuç `bf-status` ile görünür. +4. **Durum kapısı** — Kurulum sonucu `PROVISIONED_OFFLINE`'dır; `ENROLLED` tek kullanımlık token ile, `READY` ise merkezi WireGuard/erişim/monitoring doğrulamasıyla alınır. `READY` tek durum değildir, "kuruldu" demek değildir. +5. **Sürüm izi** — `bf-release` cihazın Field OS sürüm kimliğini `/etc/blueforce-release` (veya `/etc/blueforce/release-manifest.yaml`) üzerinden okur; skeleton manifest `UNAPPROVED_SKELETON` döner ve dağıtılabilir release sayılmaz. + +### Medya tabanı sapması (kayıtlı, gizlenmemiş) + +Kanonik baseline **Ubuntu Server 26.04.1+ LTS**'tir. Build host'ta SHA256'sı doğrulanmış tek kurulum medyası Ubuntu **Desktop** ISO olduğu için (build hiçbir şey indirmez), üretilen ilk ISO **Desktop tabanlıdır**: + +| Konu | Durum | +|---|---| +| Hedef işletim sistemi | Değişmedi: `ubuntu-server` + `ubuntu-desktop-minimal` + GNOME, `multi-user.target` | +| Üretilen medya tabanı | `ubuntu-26.04.1-desktop-amd64.iso`, `base_flavor: desktop` | +| Kayıt yeri | `dist/1.0.0-manifest.yaml` → `baseline_deviation` (gerekçe, etki, gereken karar, `lab_required: true`) | +| Avantaj | GNOME zaten medyada; masaüstü katmanı için ek kurulum adımı gerekmez | +| Gereken karar | LAB için Desktop tabanını kabul et **veya** üretim öncesi Server ISO ile yeniden üret (`--upstream-iso` ile tek parametre) | + +Boot kabulü (UEFI/Legacy/Secure Boot) ve air-gapped kurulum henüz doğrulanmadı; bu medya **release değil, LAB artefaktıdır** (`release_status: lab-artifact`). + ## 700 Cihaz Özeti | Konu | Özet | |---|---| | Cihaz sayısı | ~700 saha PC'si (bayi/istasyon yanında) | | Cihaz kimliği | 8 haneli bayi no → Device ID `BF-`, hostname `bf-` (ör. `BF-12010193` / `bf-12010193`). Tüm sistemlerde aynı ID kullanılır. | -| Hedef OS | Ubuntu LTS (sürüm Faz 1'de resmi kaynaktan doğrulanacak) | +| Hedef OS | Ubuntu Server 26.04.1+ LTS + GNOME katmanı (`bf-gui-on/off` yalnız **yerel fiziksel** GUI'yi kontrol eder) | +| Üretilen medya tabanı | **Desktop flavour** (bkz. aşağıdaki sapma notu) — `dist/Blueforce-Field-OS-1.0.0-amd64.iso`, manifest `baseline_deviation` bloğu | | Ağ | Turkcell modem arkası, CGNAT/NAT varsayımı; merkezle bağlantı WireGuard tüneli üzerinden | -| Uzaktan erişim | RustDesk OSS self-hosted (hbbs/hbbr) ana yol; SSH + WireGuard yedek yol; RDP/GUI ihtiyaca göre | -| Filo yönetimi | Ansible + Semaphore UI Community (aday; Faz 2'de karar) | -| İzleme | Prometheus/Node Exporter/Grafana OSS veya Uptime Kuma (Faz 2'de karşılaştırılacak) | +| Uzaktan erişim | 4 kanal: RustDesk OSS self-hosted (hbbs/hbbr), SSH-over-WireGuard, xRDP + GNOME (WG arkasında, her zaman hazır), MeshCentral | +| Kurulum medyası | Tek USB Field OS ISO (assisted; otomatik disk silme yok); yedek yol upstream ISO + NoCloud seed USB | +| Durum modeli | `PROVISIONED_OFFLINE → ENROLLED → READY` (READY = merkezi kanıt) | +| Filo yönetimi | Ansible CLI + Semaphore UI Community (MIT, $0) | +| İzleme | Prometheus/Node Exporter/Grafana OSS + Uptime Kuma | | Güncelleme politikası | **Onaysız update yasak** (apt, release upgrade, Docker image, MEG dahil). Rollout: LAB(2) → PILOT-1(5) → PILOT-2(20) → WAVE-1(50) → WAVE-2(100) → PRODUCTION | +| Masaüstü kararı | GNOME baseline (K-02); LAB'da GNOME vs XFCE A/B ölçümü yapılır, revizyon yalnız ölçüm verisiyle (K-23) | | Doküman dili | Türkçe; komut, dosya, servis ve terim adları İngilizce kalır | -## Docs Haritası (26 dosya) +## Doküman Haritası (33 numaralı doküman + şablon) -Tüm dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonuna uyar. +Tüm numaralı dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonuna uyar (her dokümanda §1–§13 sırası ve en az bir Mermaid diyagramı vardır). | No | Dosya | Konu | |---|---|---| | 00 | `00-MASTER-PLAN.md` | Genel plan, fazlar, kapsam | | 01 | `01-ARCHITECTURE.md` | Uçtan uca mimari | | 02 | `02-DEVICE-NAMING-AND-INVENTORY.md` | `BF-` / `bf-` isimlendirme ve envanter | -| 03 | `03-UBUNTU-BASELINE.md` | Ubuntu taban kurulum | +| 03 | `03-UBUNTU-BASELINE.md` | Ubuntu taban kurulum, GNOME, `bf-gui-*` | | 04 | `04-GOLDEN-IMAGE-AND-PROVISIONING.md` | Golden image ve ilk kurulum | -| 05 | `05-ONE-CLICK-INSTALLER.md` | Tek-tık kurucu | +| 05 | `05-ONE-CLICK-INSTALLER.md` | Tek-tık kurucu (`blueforce-install.sh`) | | 06 | `06-USERS-SSH-AND-PERMISSIONS.md` | Kullanıcılar, SSH, yetkiler | -| 07 | `07-REMOTE-ACCESS.md` | RustDesk + SSH + RDP erişim yolları | +| 07 | `07-REMOTE-ACCESS.md` | RustDesk + SSH + RDP + MeshCentral erişim yolları | | 08 | `08-WIREGUARD-AND-NETWORK.md` | WireGuard ve ağ | | 09 | `09-FLEET-MANAGEMENT.md` | Ansible/Semaphore filo yönetimi | | 10 | `10-UPDATE-AND-ROLLBACK-POLICY.md` | Güncelleme ve geri alma politikası | @@ -56,61 +125,115 @@ Tüm dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonuna uyar. | 19 | `19-TROUBLESHOOTING.md` | Sorun giderme | | 20 | `20-FIELD-TECHNICIAN-RUNBOOK.md` | Saha teknisyeni runbook | | 21 | `21-CENTRAL-ADMIN-RUNBOOK.md` | Merkezi yönetici runbook | -| 22 | `22-DOCUMENTATION-PLATFORM.md` | Doküman platformu (Docusaurus adayı) | +| 22 | `22-DOCUMENTATION-PLATFORM.md` | Doküman platformu (Docusaurus) | | 23 | `23-SECURITY-AND-LICENSE-AUDIT.md` | Güvenlik ve lisans denetimi | -| 24 | `24-DECISION-LOG.md` | Karar günlüğü (kaynak URL + sürüm + tarih ile) | -| 25 | `25-IMPLEMENTATION-ROADMAP.md` | Uygulama yol haritası | +| 24 | `24-DECISION-LOG.md` | Karar günlüğü — **kilit kaynak** (K-01…K-24, kaynak URL + sürüm + tarih) | +| 25 | `25-IMPLEMENTATION-ROADMAP.md` | Uygulama yol haritası (F0…F6, F2A/F2B) | +| 26 | `26-BLUEFORCE-FIELD-OS-ISO.md` | Field OS ISO ve kurulum medyası | +| 27 | `27-OFFLINE-PROVISIONING.md` | Çevrimdışı provision ve durum kapısı | +| 28 | `28-WINDOWS-TO-LINUX-MIGRATION.md` | Windows yedekli geçiş ve geri dönüş | +| 29 | `29-DEVICE-ENROLLMENT.md` | Cihaz enrollment ve READY doğrulaması | +| 30 | `30-FIELD-OS-RELEASE-MANAGEMENT.md` | Field OS release, manifest ve rollback | +| 31 | `31-LEARNING-GUIDE.md` | Field OS öğrenme rehberi (LAB pratiği) | +| 32 | `32-REPOSITORY-AUDIT-AND-CONFLICTS.md` | Depo denetimi, çelişki raporu ve giderim durumu | -Şablon: `docs/_TEMPLATE.md`. Karar günlüğü: `docs/24-DECISION-LOG.md`. +Şablon: `docs/_TEMPLATE.md` (siteye alınmaz). Karar günlüğü: `docs/24-DECISION-LOG.md`. Özet ve soru-cevap: `SUMMARY.md`. -## Maliyet Kuralı +## Repo Yapısı + +``` +. +├── README.md +├── SUMMARY.md # Yönetici özeti, 37 soru-cevap, FREE bileşen listesi +├── IMPLEMENTATION-REPORT.md # Uygulama raporu (ne yapıldı / hangi testler geçti / LAB bekleyenler) +├── docs/ # 33 numaralı doküman (00–32) + _TEMPLATE.md +├── provisioning/ # Field OS medya üretimi +│ ├── autoinstall/ # autoinstall.yaml + user-data + meta-data (interactive storage) +│ ├── iso/ # ISO doğrulama + build-blueforce-iso.sh (+ test-iso.sh) +│ ├── offline-repo/ # offline APT repo üretici + paket manifest/pinleri +│ ├── firstboot/ # bf-firstboot (+ systemd unit) +│ ├── enrollment/ # enrollment request/response JSON şemaları +│ └── release/ # immutable release manifesti +├── scripts/ +│ ├── install/ # blueforce-install.sh + installer/modules/01..18 +│ ├── diagnostics/ # bf-status, bf-diagnostics, bf-support-bundle, bf-enroll, bf-check-*, bf-release, bf-remote-status +│ ├── maintenance/ # bf-gui-on / bf-gui-off +│ ├── migration/windows/ # Windows envanter ve veri dışa aktarma (PowerShell) +│ └── recovery/ # kurtarma scriptleri +├── ansible/ # inventory, 16 playbook, 4 rol (dalga grupları pilot_1/wave_1) +├── config/ # ssh, wireguard, firewall, docker, systemd, ufw +├── monitoring/ # Prometheus + Grafana + Uptime Kuma config ve panoları +├── rustdesk/ # hbbs/hbbr self-hosted config +├── docs-site/ # Docusaurus iskeleti (numaralı dokümanları senkronlar) +├── tests/ # 7 doğrulama scripti + QEMU/LAB test planı +└── research/ # kaynak doğrulama notları (URL + sürüm + tarih) +``` + +## Geliştirici ve Bakımcı + +Kurulum komutları (geliştirme ortamı): + +```bash +# 1) Doğrulama testleri (repo kökünde, hepsi mock/statik; gerçek cihaz gerekmez) +tests/run-all.sh # hepsini sırayla koşar, çıkış kodlarını toplar (önerilen) +tests/check-specs.sh # doküman seti, bölüm sırası, Mermaid, playbook/rol ve güvenlik sözleşmeleri +tests/check-provisioning-static.sh # autoinstall/seed/ISO statik denetimleri +tests/check-offline-repo-static.sh # offline APT pin/biçim sözleşmesi (UNPINNED sentinel yasağı) +tests/check-migration-static.sh # Windows geçiş araçları: mutasyon/credential yok denetimi +tests/check-ansible-static.sh # playbook dalga/kapsam kapıları, report-only update denetimi +tests/check-configs.sh # YAML/JSON/bash sözdizimi +tests/test-field-os-lifecycle.sh # durum makinesi + enrollment + release kapıları (mock) +tests/test-firstboot-static.sh # firstboot wizard + installer bayrak sözleşmesi + +# 2026-09-16 anlık görüntüsü: `tests/run-all.sh` → 8/8 PASS, exit code 0. +# Bu testler statiktir/mock'tur; gerçek ISO boot, gerçek Windows cihaz veya merkezi +# endpoint gerektirmez. LAB gerektiren maddeler IMPLEMENTATION-REPORT.md §7'dedir. + +# ISO build (konteynerde çalıştırılabilir; xorriso gerekir) +# ./provisioning/iso/build-blueforce-iso.sh \ +# --upstream-iso /path/ubuntu-26.04.1-desktop-amd64.iso \ +# --checksum-file provisioning/iso/ubuntu-26.04.1-SHA256SUMS + +# 2) Docusaurus sitesini yerelde derle +docs-site/sync-docs.sh && (cd docs-site && npm ci && npm run build) + +# 3) Kurucu kuru koşu (mutasyonsuz) +sudo scripts/install/blueforce-install.sh --dealer-id 12010193 --check +``` + +Katkı kuralları (bu repo için bağlayıcı): + +- **Doküman dili Türkçe**; komut/dosya/servis adları İngilizce. Yeni doküman eklerken `docs/_TEMPLATE.md` yapısını koru: `## 1.` … `## 13.` sırası zorunlu, ek bölümler `## Ek:` başlığıyla; her numaralı dokümanda en az bir Mermaid bloğu olmalı (bunları `tests/check-specs.sh` denetler). +- **Numaralandırma:** `docs/[0-9][0-9]-*.md`; mevcut numara yeniden kullanılmaz. Karar günlüğünde kararlar **append-only**'dir (K-01…K-24); var olan karar silinmez, yeni numara eklenir, değişiklik `24-DECISION-LOG.md` PR'ından yapılır. +- **Yetki sınırı:** Dokümanlar `docs/` içinden yazılır; `docs-site/` elle düzenlenmez (senkron scripti üretir, `_TEMPLATE.md` siteye alınmaz). +- **Secret yasağı:** Repoda, seed/ISO/manifest/support bundle içinde parola, private key, token veya cihaz-spesifik kimlik bulunmaz. Cihaz kimliği (`bf-`) imajda değil, ilk açılışta enjekte edilir. +- **Güvenlik kilitleri (kod ve test seviyesinde):** `interactive-sections: [storage]` (otomatik disk silme yok), `unattended-upgrades` kapalı, `latest`/Watchtower yasak, `unless-stopped` zorunlu, Docker portları `127.0.0.1`'e bağlı, `bf-gui-off` xRDP'i asla durdurmaz. Bu sözleşmeleri bozan değişiklik testlerden geçemez. +- **Offline disiplini:** `blueforce-install.sh --offline` yalnız yerel `file:` APT kaynağını kullanır; uzak indirme yapmaz. Offline repo manifestlerinde somut `package=version` pinleri yoksa build kasıtlı olarak durur (`OFFLINE BLOCKED`) — pinleri onaylanmış paketlerle doldurmadan offline kurulum "tamam" sayılmaz. +- **Commit disiplini:** Bu çalışma ağacı bilinçli olarak commit edilmemiştir; değişiklikler review + release manifesti (K-18/K-24) üzerinden ilerler. Onaysız update yasağı yalnız cihazlar için değil, release hattı için de geçerlidir. + +Maliyet kuralı: - Hedef **%100 ücretsiz / self-hosted**'dir. - Ücretli veya lisans kısıtlı her araç dokümanda açıkça işaretlenir ve **ücretsiz alternatifi** ile birlikte verilir. - Varsayıma dayalı lisans kararı verilmez; resmi kaynak (resmi docs, GitHub, fiyat sayfası) URL + sürüm + tarih ile kaydedilir. - Örnek: AnyDesk kararı resmi kaynaktan doğrulanmadan yazılmaz. -## İsimlendirme Örneği +İsimlendirme örneği — bayi no `12010193` için: -Bayi no `12010193` için: - -- Device ID: `BF-12010193` -- Hostname: `bf-12010193` -- WireGuard peer adı, RustDesk ID etiketi, Ansible inventory adı, monitoring etiketi: hepsi aynı ID'yi kullanır. - -## Klasör Yapısı - -``` -. -├── README.md -├── SUMMARY.md # Faz 4'te üretilecek -├── docs/ # 26 doküman (00–25) + _TEMPLATE.md -├── scripts/ -│ ├── install/ # ilk kurulum scriptleri (Faz 3'te sözleşme/imza) -│ ├── maintenance/ # bakım scriptleri -│ ├── diagnostics/ # tanı scriptleri -│ └── recovery/ # kurtarma scriptleri -├── ansible/ -│ ├── inventory/ -│ ├── playbooks/ -│ └── roles/ -├── config/ -│ ├── ssh/ -│ ├── wireguard/ -│ ├── firewall/ -│ ├── docker/ -│ └── systemd/ -├── monitoring/ # izleme config ve panoları -├── rustdesk/ # hbbs/hbbr self-hosted config -├── docs-site/ # doküman sitesi iskeleti (Docusaurus adayı) -├── tests/ # doğrulama testleri -└── research/ # Faz 1 kaynak doğrulama notları -``` +- Device ID: `BF-12010193`, hostname: `bf-12010193` +- WireGuard peer adı, RustDesk ID etiketi, Ansible inventory adı, monitoring etiketi: hepsi aynı ID. ## Durum -- [x] Faz 0: repo iskeleti + şablon -- [ ] Faz 1: kaynak doğrulama (resmi docs/GitHub, 10 kritik konu) -- [ ] Faz 2: mimari kararlar -- [ ] Faz 3: 26 dokümanın parti parti yazımı -- [ ] Faz 4: QA, tutarlılık denetimi, SUMMARY.md +| FAZ | İçerik | Durum | +|---|---|---| +| F0 | Windows + donanım keşfi | [ ] | +| F1 | Merkez altyapı | [ ] | +| F2A | Blueforce bootstrap stabilizasyon | [ ] | +| F2B | Assisted Offline Field ISO | [ ] | +| F3 | LAB (2 cihaz) | [ ] | +| F4 | P1-P2-W1-W2 dalgaları | [ ] | +| F5 | Production rollout (~523) | [ ] | +| F6 | Gerçek zero-touch PXE (ayrı FAZ) | [ ] | + +Repo iskeleti ve uygulama katmanı hazırdır: 33 numaralı doküman, `blueforce-install.sh` + 18 modül, provisioning katmanı (ISO builder, autoinstall, offline-repo sözleşmesi, firstboot, enrollment, release), 21 Ansible playbook + 4 rol, config/monitoring/rustdesk dosyaları ve 8 doğrulama testi bu repodadır. **Field OS kurulum medyası üretilmiştir:** `dist/Blueforce-Field-OS-1.0.0-amd64.iso` (6 481 917 952 byte, SHA256 `cb0cc56f0442fbd1418e0d0f35521522f4ec9aa3f203779a67e96d587d431296`), El Torito BIOS+UEFI korunmuş, `interactive-sections: [storage]` ile operatör disk onayı zorunlu, kimlik/secret gömülü değil. Henüz **hiçbir şey sahaya sürülmedi**: gerçek offline paket deposu yok (pinler `UNPINNED`, `.deb` havuzu boş), merkezi enrollment endpoint'i yok, boot kabulü (UEFI/Legacy/Secure Boot), air-gapped kurulum ve LAB kanıtları alınmadı. Güncel durum, madde madde giderim kaydı ve açık işler [`docs/32`](docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md) ve [`IMPLEMENTATION-REPORT.md`](IMPLEMENTATION-REPORT.md) içindedir. Sıradaki adımlar [`docs/25`](docs/25-IMPLEMENTATION-ROADMAP.md) yol haritasındadır; sahaya çıkış için önce F2B (offline paket seti + boot kabulü) ve F3 (LAB) kapıları kapanmalıdır. diff --git a/SUMMARY.md b/SUMMARY.md index bc345b0..6712968 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -1,33 +1,58 @@ -# Blueforce Linux — Yönetici Özeti (SUMMARY) +# Blueforce Field OS — Ubuntu LTS-based offline-installable centrally managed, no unapproved updates, multi remote-channel industrial field OS standard +> 700 saha cihazı için Ubuntu LTS tabanlı, çevrimdışı kurulabilen, merkezi yönetilen ve onaysız güncelleme yapmayan endüstriyel Field OS standardı. > 700 saha cihazlık filonun kesin kararları, en büyük 10 operasyonel riski ve ücretsiz-bileşen listesi. > Ayrıntı `docs/` dosyalarındadır; her cevap ilgili dokümana linklenir. Kararların kilit kaynağı -> [`docs/24-DECISION-LOG.md`](docs/24-DECISION-LOG.md) (K-01…K-14), izlenebilirlik tablosu Ek B'dedir (S-01…S-27). -> Mimari tek bakış: [`docs/01-ARCHITECTURE.md`](docs/01-ARCHITECTURE.md). Commit yok, FREE sürümler baz. +> [`docs/24-DECISION-LOG.md`](docs/24-DECISION-LOG.md) (K-01…K-24), izlenebilirlik tablosu Ek B + Ek B-2'dedir (S-01…S-37). +> Mimari tek bakış: [`docs/01-ARCHITECTURE.md`](docs/01-ARCHITECTURE.md). Faz sırası: [`docs/25`](docs/25-IMPLEMENTATION-ROADMAP.md) (F0…F6). Commit yok, FREE sürümler baz. --- -## 1. Şartnamedeki 27 Sorunun Kesin Cevabı +## 1. Field OS için Kısa Soru-Cevap (2026-09-16 turu) + +- **Blueforce Field OS nedir?** — Ubuntu Server 26.04.1+ LTS üzerinde maintain edilen GNOME katmanı bulunan, çevrimdışı kurulabilen, merkezden yönetilen; onaysız güncelleme yapmayan ve 4 uzak erişim kanalı kullanan **endüstriyel saha işletim sistemi standardıdır**. Üç parçası vardır: zemin (Ubuntu LTS + GNOME + terminale boot), provisioning (tek kurucu + autoinstall/offline APT/firstboot + tek USB Field OS ISO) ve işletme (WireGuard + 4 kanal + Docker/MEG + monitoring + Ansible/Semaphore). **Medya tabanı sapması:** kanonik baseline Ubuntu **Server**'dır; build host'ta checksum'ı doğrulanmış tek medya Ubuntu **Desktop** ISO olduğu için üretilen ilk ISO Desktop tabanlıdır (manifest `baseline_deviation` bloğu). Hedef işletim sistemi yine Server paket setidir (`ubuntu-server` + `ubuntu-desktop-minimal` + GNOME, terminale boot); sapma yalnız medya tabanındadır ve LAB kararı bekler. → [`00`](docs/00-MASTER-PLAN.md), [`01`](docs/01-ARCHITECTURE.md), [`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md), [`31`](docs/31-LEARNING-GUIDE.md) (K-01, K-02, K-20) +- **ISO nasıl build edilir?** — Autoinstall kurulum **motorudur**, Blueforce Field OS ISO ise **paketleme/dağıtım** yöntemidir; ikisi rakip değil, birlikte kullanılır. `provisioning/iso/build-blueforce-iso.sh --upstream-iso --checksum-file ` çalıştırılır; builder base ISO'yu doğrular, ağacı çıkarır, autoinstall + firstboot + offline-repo araçlarını ekler, GRUB satırlarına autoinstall yazar ve boot düzenini base ISO'nun kendi `as_mkisofs` reçetesinden yeniden kurar. **ISO üretilmiştir:** `dist/Blueforce-Field-OS-1.0.0-amd64.iso` (6 481 917 952 byte, SHA256 `cb0cc56f…d431296`), El Torito BIOS+UEFI korunmuş, `/autoinstall.yaml` medya kökünde. Boot kabulü (UEFI/Legacy/Secure Boot) ve air-gapped kurulum hâlâ LAB işidir; resmi Ubuntu ISO + NoCloud seed USB yedek/geri dönüş yoludur. → [`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md), [`32`](docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md) (K-15, K-20) +- **Offline kurulum nasıl çalışır?** — Sürümlü medya + offline APT snapshot kullanılır; `blueforce-install.sh --offline` yalnız yerel `file:` APT kaynağından paket kurar (uzak indirme, key download, `apt download` yok) ve kurulum `PROVISIONED_OFFLINE` durumuna gelir. Subiquity'nin `apt.fallback` varsayılanı zaten `offline-install`'dır. Medya manifestlerinde somut `package=version` pinleri yoksa kurucu `OFFLINE BLOCKED` ile durur — "offline olur" demek için paket seti gerçekten dolu olmalıdır. → [`27`](docs/27-OFFLINE-PROVISIONING.md), [`31`](docs/31-LEARNING-GUIDE.md) (K-16, K-20, K-21) +- **Device state'leri nedir?** — Üç durum, tek sıra: `PROVISIONED_OFFLINE` → `ENROLLED` → `READY`. Tek kaynak `/var/lib/blueforce/state.json`, okuma komutu `bf-enrollment-status`. `READY` **tek durum değildir**: "kuruldu" READY değildir, "token alındı" READY değildir; yalnız WireGuard + merkezi remote + monitoring kanıtı doğrulanınca READY yazılır ve `bf-check-ready` bu kanıtı her seferinde yeniden denetler. → [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-16, K-17, K-21) +- **Internet olmadan ne olur?** — Yerel kurulum tamamlanır: paketler offline snapshot'tan gelir, cihaz `PROVISIONED_OFFLINE` olur. Cihaz çalışır ama merkezce doğrulanmamıştır: uzaktan erişim/izleme kanalları ve READY kapısı kapalı kalır. Yerel GUI kapalıyken bile xRDP servisi ayaktadır; ancak WireGuard tüneli olmadığı için merkezden erişilemez. → [`27`](docs/27-OFFLINE-PROVISIONING.md) (K-16, K-21, K-22) +- **Internet gelince ne olur?** — Merkezi admin, `BF-`'ya bağlı kısa ömürlü ve tek kullanımlık token üretir; teknisyen tokenı yalnız cihaz ekranına girer (`bf-enroll --token-stdin`). Token tüketilince WireGuard yapılandırması atomik kurulur ve durum `ENROLLED` olur. Ardından güncel WG handshake, xRDP/RustDesk yapılandırması ve merkezi `monitoring`/`remote`/`management` kanıtı doğrulanınca `READY` yazılır; kanıt `pending` ya da eksikse cihaz ENROLLED'da kalır (fail-closed). → [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-17, K-21) +- **Enrollment nasıl olur?** — Tek kullanımlık, TTL'li ve `BF-`'ya bağlı token ile; token medyada/logda/support bundle'da tutulmaz, ikinci kullanım reddedilir ve audit kaydı üretilir. Enrollment bir "bağlantı kanıtı" değil, **kimlik bağlama** adımıdır; bu yüzden READY ayrı bir kapıdır. → [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-17) +- **Windows'tan geçmeden önce ne yedeklenir?** — Üç kayıt: (1) donanım/bayi/seri no + çevre birimi + kritik iş akışı envanteri, (2) PowerShell araçlarıyla (`BF-WindowsPreMigrationInventory.ps1`, `BF-WindowsDataExport.ps1`) veri/uygulama dışa aktarımı, (3) hash'i doğrulanmış **Windows geri dönüş imajı** (restore sahibi ve saklama kaydıyla). Field OS temiz kurulumu bu imajı otomatik silmez; kabul başarısızsa imaj geri yüklenir. → [`28`](docs/28-WINDOWS-TO-LINUX-MIGRATION.md) (K-19) +- **Rollback nasıl olur?** — Üç seviye: (1) paket/config seviyesinde önceki **onaylı release manifestine** dönüş (`bf-release` ile sürüm kimliği doğrulanır), (2) imaj seviyesinde sorun varsa resmi Ubuntu ISO + önceki onaylı seed ile yeniden kurulum, (3) kabul başarısızsa Windows geri dönüş imajının restore edilmesi. Dalga içi başarısızlıkta yayılım durur ve etkilenen dalga geri alınır. → [`30`](docs/30-FIELD-OS-RELEASE-MANAGEMENT.md), [`10`](docs/10-UPDATE-AND-ROLLBACK-POLICY.md), [`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md) (K-18, K-20) +- **Field OS version nasıl takip edilir?** — Cihazda `bf-release` komutu okunur; kaynak sırası `BF_RELEASE_MANIFEST` → `/etc/blueforce/release-manifest.yaml` → legacy `/etc/blueforce-release` → repo manifesti. Manifest `status: skeleton` ise komut `UNAPPROVED_SKELETON` yazar ve **exit≠0** verir: iskelet medya dağıtılabilir release sayılmaz. Gerçek sürüm kimliği immutable release manifestiyle (ISO/seed/offline APT snapshot/installer/Ansible commit + checksum) eşleşir. → [`30`](docs/30-FIELD-OS-RELEASE-MANAGEMENT.md) (K-18, K-24) +- **Operatör hangi rehberden öğrenir?** — Linux, ağ, otomasyon, ISO, enrollment ve rollback pratikleri yalnız LAB'da [`31`](docs/31-LEARNING-GUIDE.md) ile çalışılır; komutlar örnektir ve LAB doğrulaması gerektirir. + +Bu 10 soru + öğrenme rehberi sorusu, karar günlüğünde S-28…S-37 olarak izlenir ([Ek B-2](docs/24-DECISION-LOG.md)). + +--- + +## 2. Şartnamedeki 27 Sorunun Kesin Cevabı 1. **Hangi Ubuntu? Server mı Desktop mı?** — Ubuntu **Server 26.04.1+ LTS**, GUI'siz minimal kurulum. Desktop ISO'sunun 6 GB RAM şartı saha bütçesini zorlar; GUI sonradan eklenir. → [`03`](docs/03-UBUNTU-BASELINE.md) (K-01) -2. **GNOME mu XFCE mi?** — Cevap **GNOME** (Ubuntu Desktop standardı, kullanıcı kararı); XFCE elendi. - → [`03`](docs/03-UBUNTU-BASELINE.md) (K-02) +2. **GNOME mu XFCE mi?** — Saha standardı **GNOME** (Ubuntu Desktop standardı, kullanıcı kararı); XFCE saha standardı değildir. + Ancak K-23 gereği LAB'da GNOME vs XFCE **A/B ölçümü** yapılır (RAM, CPU, boot süresi, RDP güvenilirliği, RustDesk reboot sonrası, + login screen, dummy display, 24/72 saat stabilite) ve karar ölçüm verisine göre revize edilebilir; RustDesk headless ZORUNLU + şartı her iki kolda da korunur. → [`03`](docs/03-UBUNTU-BASELINE.md) (K-02, K-23) 3. **Normal boot GUI mi terminal mi?** — Cihazlar **terminale** (`multi-user.target`) boot eder; kapalı GUI = düşük RAM, düşük saldırı yüzeyi, öngörülebilir boot. → [`03`](docs/03-UBUNTU-BASELINE.md), [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) (K-03) -4. **GUI nasıl açılıp kapatılacak?** — `sudo bf-gui-on` / `sudo bf-gui-off` ile systemd üzerinden - (display-manager + `xrdp`/`xrdp-sesman`); varsayılan kapalıdır. → [`03`](docs/03-UBUNTU-BASELINE.md), [`07`](docs/07-REMOTE-ACCESS.md) (K-03) +4. **GUI nasıl açılıp kapatılacak?** — `sudo bf-gui-on` / `sudo bf-gui-off`, yalnız **yerel fiziksel** grafik katmanını + (display-manager: gdm3/gdm/sddm/lightdm + default target) systemd üzerinden yönetir; varsayılan kapalıdır. `bf-gui-off` + xRDP servislerini **asla** durdurmaz: `xrdp`/`xrdp-sesman` her zaman enable+active kalır, yani **GUI kapalı ≠ RDP kapalı**. + → [`03`](docs/03-UBUNTU-BASELINE.md), [`07`](docs/07-REMOTE-ACCESS.md) (K-03, K-22) 5. **Cihaz isimlendirme standardı?** — 8 haneli bayi no → insan arayüzünde `BF-` (büyük), makine adında `bf-` (küçük); 9 sistemde (hostname, WireGuard peer, RustDesk, MeshCentral, Ansible, Prometheus, log, envanter, runbook) join anahtarıdır. Örn. `BF-12010193` / `bf-12010193`. → [`02`](docs/02-DEVICE-NAMING-AND-INVENTORY.md) (K-12) 6. **İlk PC hazırlığı nasıl?** — İmaj dondurulmadan **önce** `bf-hardware-inventory.sh` ile donanım - taraması (CPU/RAM/disk/NIC/GPU profilleri); profilsiz imaj sahada rastgele kırılır. → [`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md), [`02`](docs/02-DEVICE-NAMING-AND-INVENTORY.md) (K-09) -7. **Yeni PC kurulumu kaç adım?** — Teknisyen için **4 adım**: USB tak → Ubuntu kur → - `blueforce-install.sh` + 8 haneli bayi no → `bf-status` ile READY gör, çık (<15 dk aktif iş). → [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md), [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md) (K-09) -8. **Installer nasıl çalışacak?** — Tek giriş `blueforce-install.sh --dealer-id <8hane>`; **18 idempotent - modül** (precheck → dealer-id → paketler → GNOME → xRDP → SSH → WireGuard → Docker → MEG → RustDesk → - MeshCentral → monitoring → firewall → envanter → logging → boot → bootstrap → final-check), - çıkış kodu 0 = READY, format kapısı + onay ekranlı. → [`05`](docs/05-ONE-CLICK-INSTALLER.md) (K-09) + taraması (CPU/RAM/disk/NIC/GPU profilleri) ve Windows keşfi (F0); profilsiz imaj sahada rastgele kırılır. → [`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md), [`28`](docs/28-WINDOWS-TO-LINUX-MIGRATION.md) (K-09, K-19) +7. **Yeni PC kurulumu kaç adım?** — Teknisyen için akış: USB medyasıyla Ubuntu kur → + `blueforce-install.sh --offline` + 8 haneli bayi no → `bf-status` ile `PROVISIONED_OFFLINE` gör → + internet geldiğinde enrollment ve merkezi kontrolleri tamamla; yalnız sonra `READY` teslim edilir. → + [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md), [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md) (K-16, K-17, K-21) +8. **Installer nasıl çalışacak?** — Tek giriş `blueforce-install.sh --dealer-id <8hane>`; çevrimdışı yol + `--offline` ile yerel medya/snapshot kullanır ve yalnız `PROVISIONED_OFFLINE` yazar. `READY`, installer + çıkış kodu değil enrollment sonrası merkezi WireGuard, erişim ve monitoring doğrulamasının sonucudur. → + [`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-21) 9. **WireGuard mimarisi?** — **Hub-spoke**: merkez hub (`10.8.0.1`), her cihaz spoke (peer adı `bf-`, sabit `/32`); istemcide `PersistentKeepalive = 25` + `wg-quick@wg0` enable; SSH/RDP yalnızca tünel içinden, dış dünyaya kapalı. → [`08`](docs/08-WIREGUARD-AND-NETWORK.md) (K-05) @@ -35,10 +60,13 @@ root SSH kapalı; 700 cihaza tek private key **yayılmaz** — admin başına anahtar, Ansible ile merkezi `authorized_keys` dağıtımı, uzun vadede SSH CA hedefi. → [`06`](docs/06-USERS-SSH-AND-PERMISSIONS.md) (K-13) 11. **RDP nasıl?** — **xRDP + GNOME**, yalnızca WireGuard üzerinden; internete açık RDP portu yoktur. - Grafik katman kapalıyken RDP açılmaz (`bf-gui-on` gerekir). → [`07`](docs/07-REMOTE-ACCESS.md) (K-03/K-05) + xRDP ve xrdp-sesman multi-user seviyesinde **her zaman enable+active** olduğu için RDP `bf-gui-on` + beklemez; `bf-gui-*` yalnız yerel fiziksel GUI'yi açar/kapatır. → [`07`](docs/07-REMOTE-ACCESS.md), [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) (K-05, K-22) 12. **RustDesk self-host neyi kapsar?** — **RustDesk OSS** istemci + `hbbs`/`hbbr` kendi VDS'imizde; sınırsız istemci, E2E şifreli oturum, dosya transferi, katılımsız erişim — hepsi ücretsiz. İstemci VDS - genel adresine doğrudan çıktığı için **WireGuard çökse bile** kanal ayaktadır. → [`07`](docs/07-REMOTE-ACCESS.md) (K-04) + genel adresine doğrudan çıktığı için **WireGuard çökse bile** kanal ayaktadır. Ubuntu 26.04.1 üzerinde + headless/katılımsız davranış LAB'da doğrulanmadan dondurulmaz (zorunlu şart: reboot sonrası katılımsız çalışma). + → [`07`](docs/07-REMOTE-ACCESS.md) (K-04, K-23) 13. **AnyDesk ücretsiz kullanılabilir mi?** — **HAYIR.** Free sürüm yalnızca kişisel kullanımı kapsar; 700 kurumsal cihaz ticari kullanımdır, lisanssız kullanım sözleşme ihlalidir. Mimari dışıdır; yalnızca müşteride zaten ticari lisans varsa opsiyonel `--with-anydesk` modülü. → [`07`](docs/07-REMOTE-ACCESS.md), [`23`](docs/23-SECURITY-AND-LICENSE-AUDIT.md) (K-04) @@ -70,7 +98,7 @@ cihaz kuralıyla disk taşması mimari düzeyde engellenir. → [`15`](docs/15-LOGGING.md), [`12`](docs/12-DOCKER-OPERATIONS.md) (K-14/K-10) 22. **Elektrik kesintisi ve dönüşü?** — BIOS `Restore on AC Power Loss = Power On` ile cihaz kendiliğinden açılır; systemd sıralı boot zinciri (network → `wg-quick` → docker → MEG → agent'lar) + `unless-stopped` - ile her katman kendiliğinden toparlanır, ek kurtarma servisi yoktur. → [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) (K-03/K-10) + ile her katman kendiliğinden toparlanır, ek kurtarma servisi yoktur. xRDP de bu zincirde her zaman hazır kalır. → [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) (K-03/K-10/K-22) 23. **İnternet paketi bitimi ve dönüşü?** — Kota bitince tünel sessizce ölür (arıza değil, "çevrimdışı" görünür; Kuma "last seen" alarmı + bayiye bildirim); kota dönüşünde `wg-quick` + keepalive **kendiliğinden** yeniden handshake eder, ek işlem gerekmez. Kota-dostu keepalive (25 sn, PİLOT verisiyle ayarlanır). → [`08`](docs/08-WIREGUARD-AND-NETWORK.md) (K-05) @@ -78,14 +106,16 @@ L8 USB ile sıfırdan imaj → L9 merkez inşası); cihaz "değiştirilebilir" sayılır, kimlik + config merkezden yeniden basılır, cihaz-başı veri yedeği yoktur. Çoğu arıza 4 kanaldan biriyle uzaktan çözülür, sahaya gidilmez. → [`17`](docs/17-BACKUP-RECOVERY-AND-REINSTALL.md), [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md) -25. **Golden image yöntemi ne?** — Kimliksiz zemin (Server 26.04.1+ minimal + update kapalı + kurucu) USB/autoinstall - ile kurulur; cihaz-spesifik kimlik (`bf-`) imajda **değil ilk açılışta** enjekte edilir. Clonezilla - bit-kopya yalnızca yedek yöntemdir (donanım-fragil). → [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md), [`05`](docs/05-ONE-CLICK-INSTALLER.md) (K-09) +25. **Golden image yöntemi ne?** — Kimliksiz zemin (Server 26.04.1+ minimal + update kapalı + kurucu), tek USB + Field OS ISO (autoinstall + offline APT + firstboot) ile kurulur; cihaz-spesifik kimlik (`bf-`) imajda + **değil ilk açılışta** enjekte edilir. Assisted kurulumda storage adımı operatör onayı bekler (otomatik disk + silme yok). Clonezilla bit-kopya yalnızca yedek yöntemdir (donanım-fragil). → [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md), [`05`](docs/05-ONE-CLICK-INSTALLER.md), [`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md) (K-09, K-20) 26. **700 cihaz rollout nasıl?** — Üstel dalgalar: LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD(~523); hiçbir dalga önceki çıkış kriterini sağlamadan başlamaz; halt kuralları (≥2 kritik arıza / >%10 çevrimdışı / - 2× kurulum süresi) yayılımı durdurur. → [`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md) (K-11) -27. **Docs platformu ne?** — **Docusaurus**; kaynak `docs/*.md` tek-kaynaktır, site CI'da derlenir, siteye - elle yazma yasaktır. Yedek: MkDocs + Material. → [`22`](docs/22-DOCUMENTATION-PLATFORM.md) (K-08) + 2× kurulum süresi) yayılımı durdurur. Faz karşılığı: LAB = F3, P1-P2-W1-W2 = F4, PROD = F5. → [`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md), [`25`](docs/25-IMPLEMENTATION-ROADMAP.md) (K-11) +27. **Docs platformu ne?** — **Docusaurus**; kaynak yalnız `docs/[0-9][0-9]-*.md` numaralı dosyalardır, + site CI'da derlenir, `docs/_TEMPLATE.md` siteye alınmaz ve siteye elle yazma yasaktır. Yedek: MkDocs + Material. → + [`22`](docs/22-DOCUMENTATION-PLATFORM.md) --- @@ -113,10 +143,11 @@ | Bileşen | Sürüm/katman | Lisans | Kaynak | |---|---|---|---| | Ubuntu Server LTS | 26.04.1+ (ESM'e bel bağlanmaz) | Açık kaynak | [`03`](docs/03-UBUNTU-BASELINE.md) | -| GNOME | saha standardı (Ubuntu Desktop) | GPL/LGPL bileşenler | [`03`](docs/03-UBUNTU-BASELINE.md) | -| xRDP | v0.10.6.1 | Apache-2.0 | [`03`](docs/03-UBUNTU-BASELINE.md) | +| GNOME | saha standardı (Ubuntu Desktop); XFCE yalnız LAB A/B ölçüm adayı | GPL/LGPL bileşenler | [`03`](docs/03-UBUNTU-BASELINE.md) | +| xRDP | v0.10.6.1 | Apache-2.0 | [`03`](docs/03-UBUNTU-BASELINE.md), [`07`](docs/07-REMOTE-ACCESS.md) | | WireGuard + wg-quick | wireguard-tools 1.0.20250521-1ubuntu1 | OSS | [`08`](docs/08-WIREGUARD-AND-NETWORK.md) | | OpenSSH / UFW | sistem paketleri | BSD / GPL bileşenler | [`06`](docs/06-USERS-SSH-AND-PERMISSIONS.md), [`11`](docs/11-SECURITY-HARDENING.md) | +| Autoinstall / cloud-init NoCloud | Subiquity + cloud-init | Açık kaynak (Ubuntu lisans seti) | [`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md), [`27`](docs/27-OFFLINE-PROVISIONING.md) | | Docker CE | (Business/Scout mimaride yok) | Ücretsiz | [`12`](docs/12-DOCKER-OPERATIONS.md) | | RustDesk istemci + sunucu (hbbs/hbbr) | 1.4.9 / 1.1.16, OSS | AGPL-3.0 | [`07`](docs/07-REMOTE-ACCESS.md) | | MeshCentral | 1.2.5 | Apache-2.0 | [`07`](docs/07-REMOTE-ACCESS.md) | @@ -135,12 +166,21 @@ kaynak URL'ler [`24 Ek A`](docs/24-DECISION-LOG.md). --- -## Ek: QA Doğrulama (2026-09-15) +## Ek: QA Doğrulama (2026-09-16 güncellemesi) -- **26 dosya mevcut:** `docs/00` … `docs/25` tamamı var (+ `_TEMPLATE.md`). -- **13 başlık:** 26 dosyanın tamamında §1–§13 sıralı ve eksiksiz. -- **Mermaid:** 26/26 dosyada ≥1 diyagram (03/19/21/23/24'e bu özetle birlikte eklendi). -- **BF-/bf- tutarlılığı:** insan arayüzü `BF-`, makine adı `bf-`; aykırı kullanım yok. +- **33 numaralı doküman mevcut:** `docs/00` … `docs/32` (+ `docs/_TEMPLATE.md`); kanonik set `00`–`31`, `32` denetim/çelişki raporudur. +- **13 başlık:** 33 dosyanın tamamında §1–§13 sıralı ve eksiksiz (bu turda `docs/32` §1–§13 yapısına getirildi). +- **Mermaid:** 33/33 dosyada ≥1 diyagram; toplam 35 blok (30 dosyada 1, birkaçında 2+). +- **BF-/bf- tutarlılığı:** insan arayüzü `BF-`, makine adı `bf-`; aykırı kullanım yok (kalan `BF-xxxx`/`BF-no` ifadeleri şablon yer tutucudur). - **Ücretli sızıntı:** yok — ücretli geçen her satır elendi/hüküm/denetim bağlamında. - **Onaysız-update kapısı:** yok — `unattended-upgrades`/`latest`/Watchtower yasakları 03/10/12'de ve K-11'de kilitli; acil yama bile onay + rollback testinden geçer. +- **Durum tutarlılığı (bu turun konusu):** README, SUMMARY, 24, 25 ve 32 aynı şeyi söyler: medya (autoinstall + Field OS ISO), + 3 durumlu model, RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI, GNOME baseline + LAB A/B, offline-assisted F2B vs + zero-touch F6. Giderilemeyen artıklar ve açık maddeler [`docs/32`](docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md) §"Giderim Durumu" içindedir. +- **Test durumu (2026-09-16 10:52 anlık görüntüsü):** `check-configs.sh`, `check-migration-static.sh`, `check-offline-repo-static.sh`, + `check-provisioning-static.sh`, `test-firstboot-static.sh` **GEÇTİ**; `check-specs.sh` iki **bayat sabit** nedeniyle düşer + (kanonik doküman sayısı `32`→`33`, Mermaid toplamı `34`→`35`); `test-field-os-lifecycle.sh` **kırmızı** (READY/durum sözleşmesi). + Düzeltmeler `tests/` ve ilgili provisioning akışı kapsamındadır — ayrıntı [`docs/32 §10`](docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md). +- **Üretim artefaktı:** henüz `*.iso` yok, `*.deb` havuzu yok, offline pinler `UNPINNED` sentinel, `packages.lock.tsv` yok, + merkezi enrollment endpoint'i yok. Yani "offline kurulum çalışıyor" bugün **kanıtlanmış değildir**; F2A/F2B kapıları açıktır. diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000..b60906e --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,22 @@ +# Shared variables for all Blueforce fleet hosts. +# Fill placeholders before first production run. +--- +# WireGuard subnet (placeholder - must match docs/08 hub topology). +wg_subnet: "10.42.0.0/16" +wg_interface: wg0 +wg_hub_endpoint: "vpn.example.com:51820" # PLACEHOLDER: real hub endpoint + +# Monitoring endpoint (placeholder - VictoriaMetrics/Prometheus push or pull target). +monitoring_endpoint: "http://monitoring.example.com:8428" # PLACEHOLDER +monitoring_job: blueforce + +# Update repository / mirror (placeholder - local mirror recommended for 700 devices). +update_repo_url: "http://mirror.example.com/ubuntu" # PLACEHOLDER +update_repo_suite: jammy +meg_image_tag: "1.0.0" # pinned tag, never 'latest' (docs/10, docs/12) + +# Operation policy defaults. Every fleet-changing play must bind to one approved wave. +operation_wave: "" +operation_confirmed: false +reboot_grace_seconds: 300 +support_bundle_dir: /var/lib/blueforce/support-bundles diff --git a/ansible/inventory/hosts.example.yml b/ansible/inventory/hosts.example.yml new file mode 100644 index 0000000..2ab5077 --- /dev/null +++ b/ansible/inventory/hosts.example.yml @@ -0,0 +1,54 @@ +# Blueforce fleet inventory (EXAMPLE - copy to hosts.yml and fill real IDs). +# Naming: bf-<8-digit-dealer-no> per docs/02-DEVICE-NAMING-AND-INVENTORY.md. +# Targeting levels per docs/09-FLEET-MANAGEMENT.md: single host -> group -> pilot wave -> full fleet. +# Waves per docs/10-UPDATE-AND-ROLLBACK-POLICY.md: lab(2)/pilot_1(5)/pilot_2(20)/wave_1(50)/wave_2(100)/production. +all: + children: + blueforce: + children: + lab: + hosts: + bf-12010101: + ansible_host: 10.42.0.11 + bf-12010102: + ansible_host: 10.42.0.12 + pilot_1: + hosts: + bf-12010201: + ansible_host: 10.42.1.11 + bf-12010202: + ansible_host: 10.42.1.12 + bf-12010203: + ansible_host: 10.42.1.13 + bf-12010204: + ansible_host: 10.42.1.14 + bf-12010205: + ansible_host: 10.42.1.15 + pilot_2: + hosts: + bf-12010301: + ansible_host: 10.42.2.11 + bf-12010302: + ansible_host: 10.42.2.12 + wave_1: + hosts: + bf-12010401: + ansible_host: 10.42.3.11 + bf-12010402: + ansible_host: 10.42.3.12 + wave_2: + hosts: + bf-12010501: + ansible_host: 10.42.4.11 + bf-12010502: + ansible_host: 10.42.4.12 + production: + hosts: + bf-12010601: + ansible_host: 10.42.5.11 + bf-12010602: + ansible_host: 10.42.5.12 + vars: + ansible_user: blueforce + ansible_ssh_private_key_file: ~/.ssh/blueforce_id_ed25519 # PLACEHOLDER: set per-admin key path (see docs/06) + ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/playbooks/bf-collect-logs.yml b/ansible/playbooks/bf-collect-logs.yml new file mode 100644 index 0000000..3b7b867 --- /dev/null +++ b/ansible/playbooks/bf-collect-logs.yml @@ -0,0 +1,69 @@ +--- +# Collect timestamped journald logs and a support snapshot from one approved wave. +- name: Collect support logs + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + collect_logs_dir: "{{ support_bundle_dir | default('/var/lib/blueforce/support-bundles') }}" + collect_log_lines: "{{ log_lines | default(500) }}" + fetch_logs: false + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require a safe support bundle directory + ansible.builtin.assert: + that: + - collect_logs_dir is match('^/var/lib/blueforce/support-bundles(?:/[A-Za-z0-9][A-Za-z0-9._-]*)*\Z') + fail_msg: "support_bundle_dir must be /var/lib/blueforce/support-bundles or an approved alphanumeric subpath; shell metacharacters are refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Generate unique bundle timestamp + ansible.builtin.command: date -u +%Y%m%dT%H%M%SZ + register: bundle_timestamp + changed_when: false + + - name: Ensure bundle directory exists + ansible.builtin.file: + path: "{{ collect_logs_dir }}" + state: directory + owner: root + group: root + mode: "0750" + + - name: Export recent journal to timestamped bundle + ansible.builtin.shell: >- + journalctl -n {{ collect_log_lines | int }} --no-pager > + {{ (collect_logs_dir ~ '/journal-' ~ inventory_hostname ~ '-' ~ bundle_timestamp.stdout ~ '.log') | quote }} + changed_when: true + + - name: Record timestamped kernel and disk snapshot + ansible.builtin.shell: >- + { uname -a; printf '%s\\n' '---'; df -h; printf '%s\\n' '---'; uptime; } > + {{ (collect_logs_dir ~ '/snapshot-' ~ inventory_hostname ~ '-' ~ bundle_timestamp.stdout ~ '.txt') | quote }} + changed_when: true + + - name: Fetch timestamped journal bundle to controller + ansible.builtin.fetch: + src: "{{ collect_logs_dir }}/journal-{{ inventory_hostname }}-{{ bundle_timestamp.stdout }}.log" + dest: "fetched-logs/{{ inventory_hostname }}/" + flat: true + when: fetch_logs | bool \ No newline at end of file diff --git a/ansible/playbooks/bf-deploy-update.yml b/ansible/playbooks/bf-deploy-update.yml new file mode 100644 index 0000000..d1d32a5 --- /dev/null +++ b/ansible/playbooks/bf-deploy-update.yml @@ -0,0 +1,86 @@ +--- +# Approved package update with mandatory wave scope and per-batch health gates. +# Example: ansible-playbook -i inventory/hosts.yml playbooks/bf-deploy-update.yml -e release_wave=lab -e wave_gate_confirmed=true -e update_packages='["package=version"]' +- name: Deploy approved package update + hosts: "{{ release_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + serial: 5 + max_fail_percentage: 0 + any_errors_fatal: true + vars: + release_wave: "" + wave_gate_confirmed: false + production_override: false + halt_on_fail: true + update_packages: [] + update_cache_time: 3600 + approved_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + pre_tasks: + - name: Require a valid rollout wave + ansible.builtin.assert: + that: + - release_wave in approved_waves + - release_wave in groups + - groups[release_wave] | length > 0 + fail_msg: "release_wave must name a non-empty approved inventory group; refusing an ungated rollout." + run_once: true + + - name: Require exact or subset host selection for release wave + ansible.builtin.assert: + that: + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[release_wave]) | length == 0 + fail_msg: "Selected hosts must equal or be a subset of groups[release_wave]; broader fleet scope is refused." + run_once: true + + - name: Require central wave-gate confirmation + ansible.builtin.assert: + that: + - wave_gate_confirmed | bool + fail_msg: "Set wave_gate_confirmed=true only after the prior wave health gate and central approval pass." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - release_wave != 'production' or production_override | bool + fail_msg: "Production rollout requires production_override=true in addition to wave_gate_confirmed=true." + run_once: true + + - name: Require halt-on-failure protection + ansible.builtin.assert: + that: + - halt_on_fail | bool + fail_msg: "halt_on_fail must remain true for every rollout." + run_once: true + + - name: Require pinned package versions + ansible.builtin.assert: + that: + - update_packages | length > 0 + - update_packages | select('match', '^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$') | list | length == update_packages | length + fail_msg: "update_packages must be a non-empty list of exact package=version pins." + run_once: true + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: "{{ update_cache_time }}" + + - name: Install approved pinned package versions + ansible.builtin.apt: + name: "{{ update_packages }}" + state: present + allow_downgrade: true + post_tasks: + - name: Run mandatory post-batch health gate + ansible.builtin.command: /usr/local/bin/bf-status + register: update_health + changed_when: false + failed_when: update_health.rc != 0 + + - name: Report passed post-batch health gate + ansible.builtin.debug: + msg: "Update batch health gate passed for {{ ansible_play_batch }}" + run_once: true \ No newline at end of file diff --git a/ansible/playbooks/bf-disk-check.yml b/ansible/playbooks/bf-disk-check.yml new file mode 100644 index 0000000..d00f9c6 --- /dev/null +++ b/ansible/playbooks/bf-disk-check.yml @@ -0,0 +1,29 @@ +--- +# Check disk usage (read-only) and warn above threshold. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-disk-check.yml --limit lab +# Threshold overridable: -e disk_warn_pct=80. +# No Semaphore Pro feature is used. +- name: Check disk usage + hosts: blueforce + gather_facts: false + vars: + disk_warning_percent: "{{ disk_warn_pct | default(80) }}" + tasks: + - name: Get disk usage + ansible.builtin.command: df -h --output=source,pcent,target + register: df_out + changed_when: false + + - name: Show disk usage + ansible.builtin.debug: + msg: "{{ inventory_hostname }}:\n{{ df_out.stdout }}" + + - name: Get root filesystem usage percentage + ansible.builtin.shell: "df --output=pcent / | tail -n 1 | tr -dc '0-9'" + register: root_pct + changed_when: false + + - name: Flag hosts above threshold + ansible.builtin.debug: + msg: "WARNING {{ inventory_hostname }} root disk {{ root_pct.stdout }}% exceeds {{ disk_warning_percent }}%" + when: root_pct.stdout | int >= (disk_warning_percent | int) diff --git a/ansible/playbooks/bf-docker-status.yml b/ansible/playbooks/bf-docker-status.yml new file mode 100644 index 0000000..6b805d0 --- /dev/null +++ b/ansible/playbooks/bf-docker-status.yml @@ -0,0 +1,24 @@ +--- +# Report Docker daemon and container status (read-only). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-docker-status.yml --limit lab +# No changes are made by this playbook. No Semaphore Pro feature is used. +- name: Check Docker status + hosts: blueforce + gather_facts: false + become: true + tasks: + - name: Check Docker service state + ansible.builtin.systemd: + name: docker + register: docker_svc + changed_when: false + + - name: List running containers + ansible.builtin.command: docker ps --format '{{{{.Names}}}} {{{{.Image}}}} {{{{.Status}}}}' + register: docker_ps + changed_when: false + failed_when: false + + - name: Show Docker status + ansible.builtin.debug: + msg: "{{ inventory_hostname }} docker={{ docker_svc.status.ActiveState | default('unknown') }} containers={{ docker_ps.stdout_lines | default([]) }}" diff --git a/ansible/playbooks/bf-enrollment-status.yml b/ansible/playbooks/bf-enrollment-status.yml new file mode 100644 index 0000000..08de0ee --- /dev/null +++ b/ansible/playbooks/bf-enrollment-status.yml @@ -0,0 +1,208 @@ +--- +# Blueforce enrollment durum raporu -- READ-ONLY, dalga kapsamlı. +# Ne yapar: her cihaz için enrollment_status + fleet_status + faz yazar; ENROLLED/READY iddiası +# olup merkezi doğrulama kanıtı (central_verification: status + verification_id + +# monitoring/remote/management kanalları) eksik olan cihazları listeler. +# Sözleşme: docs/29 (merkezi doğrulama yanıtı), docs/14 (bf-status). +# Kanıt: /var/lib/blueforce/state.json ve kanonik /usr/local/bin/bf-enrollment-status çıktısı. +# Hiçbir görev durum değiştirmez: yalnız stat/slurp ve read-only CLI çıktısı okunur; tüm +# command görevleri changed_when: false taşır. +# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-enrollment-status.yml -e operation_wave=lab -e operation_confirmed=true --check +# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-enrollment-status.yml -e operation_wave=lab -e operation_confirmed=true +# Semaphore Pro bağımlılığı yok. +# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, +# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). +- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) + hosts: "{{ ansible_limit | default('localhost') }}" + gather_facts: false + become: false + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" + tasks: + - name: Require an approved confirmed operation wave before any host is touched + ansible.builtin.assert: + that: + - operation_wave_gate in approved_operation_waves + - operation_confirmed | default(false) | bool + - operation_wave_gate in groups + - groups[operation_wave_gate] | length > 0 + - (operation_wave_gate != 'production') or (production_override | default(false) | bool) + fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." + run_once: true + + - name: Require the --limit selection to intersect the declared wave + ansible.builtin.assert: + that: + - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) + or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) + fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." + when: + - (ansible_limit | default('') | length) > 0 + - not (ansible_limit | default('') is search('[][*?]')) + run_once: true + +- name: Enrollment ve merkez kanıtı durumunu raporla (read-only) + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true # Yalnız root sahipli kanıt dosyalarını (state.json 0600) OKUMAK için; mutasyon yok. + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + enrollment_state_file: /var/lib/blueforce/state.json + enrollment_status_bin: /usr/local/bin/bf-enrollment-status + enrollment_central_channels: [monitoring, remote, management] + enrollment_central_required_for: [ENROLLED, READY] + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - groups[operation_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Enrollment kanıt yollarını denetle (read-only) + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ enrollment_state_file }}" + - "{{ enrollment_status_bin }}" + register: enrollment_evidence + + - name: Kanıt durumlarını yol ile indeksle + ansible.builtin.set_fact: + enrollment_evidence_by_path: "{{ enrollment_evidence.results | items2dict(key_name='item', value_name='stat') }}" + + - name: Durum dosyasını oku (read-only) + ansible.builtin.slurp: + src: "{{ enrollment_state_file }}" + register: enrollment_state_raw + when: + - enrollment_evidence_by_path[enrollment_state_file].exists | default(false) + - enrollment_evidence_by_path[enrollment_state_file].size | default(0) | int > 0 + + - name: Kanonik enrollment durumunu oku (bf-enrollment-status, read-only) + ansible.builtin.command: "{{ enrollment_status_bin }}" + register: enrollment_status_cli + changed_when: false + failed_when: false + when: enrollment_evidence_by_path[enrollment_status_bin].exists | default(false) + + - name: Durum dosyası içeriğini çöz (bozuk dosyada rapor PENDING kalır, koşu durmaz) + ansible.builtin.set_fact: + enrollment_state_parsed: "{{ enrollment_state_raw.content | b64decode | from_json }}" + when: enrollment_state_raw.content is defined + ignore_errors: true + + - name: Enrollment alanlarını ve merkez kanıtını belirle + ansible.builtin.set_fact: + bf_enr_device_id: "{{ (enrollment_state_parsed | default({}, true)).get('device_id', inventory_hostname | upper) }}" + bf_enr_phase: >- + {{ (enrollment_state_parsed | default({}, true)).get('phase', '') | default('', true) + if (enrollment_state_parsed | default({}, true)).get('phase', '') in ['PROVISIONED_OFFLINE', 'ENROLLED', 'READY'] + else 'PENDING' }} + bf_enr_enrollment_status: "{{ (enrollment_state_parsed | default({}, true)).get('enrollment_status', 'PENDING') | default('PENDING', true) }}" + bf_enr_fleet_status: "{{ (enrollment_state_parsed | default({}, true)).get('fleet_status', 'PENDING') | default('PENDING', true) }}" + bf_enr_central: "{{ (enrollment_state_parsed | default({}, true)).get('central_verification', {}) | default({}, true) }}" + bf_enr_cli_lifecycle: "{{ enrollment_status_cli.stdout | default('') | regex_findall('(?m)^Lifecycle: (.+)$') | first | default('', true) }}" + bf_enr_cli_enrollment: "{{ enrollment_status_cli.stdout | default('') | regex_findall('(?m)^Enrollment: (.+)$') | first | default('', true) }}" + + - name: Merkez doğrulama kanallarını çıkar + ansible.builtin.set_fact: + bf_enr_central_status: "{{ bf_enr_central.get('status', '') | default('', true) }}" + bf_enr_central_id: "{{ bf_enr_central.get('verification_id', '') | default('', true) }}" + bf_enr_central_channel_states: "{{ dict(enrollment_central_channels | zip(enrollment_central_channels | map('extract', bf_enr_central, 'status') | map('default', 'pending', true) | list)) }}" + bf_enr_central_channel_ids: "{{ dict(enrollment_central_channels | zip(enrollment_central_channels | map('extract', bf_enr_central, 'verification_id') | map('default', '', true) | list)) }}" + + - name: Merkez kanıtı tam mı hesapla + ansible.builtin.set_fact: + bf_enr_central_ok: >- + {{ (bf_enr_central_status == 'verified') + and (bf_enr_central_id | length > 0) + and (bf_enr_central_channel_states.values() | select('equalto', 'verified') | list | length == bf_enr_central_channel_states | length) + and (bf_enr_central_channel_ids.values() | select('equalto', '') | list | length == 0) }} + bf_enr_cli_mismatch: "{{ 'MISMATCH' if (bf_enr_cli_lifecycle | length > 0) and (bf_enr_cli_lifecycle != bf_enr_phase) else 'OK' }}" + + - name: Merkez kanıtı durumunu etiketle + ansible.builtin.set_fact: + bf_enr_central_state: >- + {{ 'not-required' if (bf_enr_enrollment_status != 'ENROLLED' and bf_enr_phase not in enrollment_central_required_for) + else ('verified' if bf_enr_central_ok | bool else 'MISSING') }} + bf_enr_missing_central: >- + {{ (bf_enr_enrollment_status == 'ENROLLED' or bf_enr_phase in enrollment_central_required_for) + and not (bf_enr_central_ok | bool) }} + + - name: Cihaz enrollment satırını raporla + ansible.builtin.debug: + msg: >- + {{ bf_enr_device_id }} phase={{ bf_enr_phase }} enrollment={{ bf_enr_enrollment_status }} + fleet={{ bf_enr_fleet_status }} merkez_kaniti={{ bf_enr_central_state }} + kanallar={{ bf_enr_central_channel_states }} kanit_id={{ 'SET' if bf_enr_central_id | length > 0 else 'ABSENT' }} + + - name: Eksik merkez kanıtı olan cihazı işaretle + ansible.builtin.debug: + msg: "UYARI {{ bf_enr_device_id }}: enrollment/faz iddiası var ama merkez kanıtı eksik ({{ bf_enr_central_state }}); READY verilemez (docs/29)." + when: bf_enr_missing_central | bool + + - name: Merkez kanıtı eksik kanalları ayrıntılı yaz + ansible.builtin.debug: + msg: "UYARI {{ bf_enr_device_id }}: kanal kanıtı eksik -> {{ bf_enr_incomplete_channels }}" + vars: + bf_enr_incomplete_channels: "{{ bf_enr_central_channel_states | dict2items | rejectattr('value', 'equalto', 'verified') | map(attribute='key') | list }}" + when: bf_enr_missing_central | bool + + - name: Kalıcı faz ile kanonik CLI çıktısı çelişen cihazı işaretle + ansible.builtin.debug: + msg: "UYARI {{ bf_enr_device_id }}: state.json phase={{ bf_enr_phase }} ama bf-enrollment-status Lifecycle={{ bf_enr_cli_lifecycle }}." + when: bf_enr_cli_mismatch == 'MISMATCH' + + - name: Enrollment durum dağılımını hesapla + ansible.builtin.set_fact: + bf_enr_status_histogram: "{{ bf_enr_status_histogram | default({}) | combine({ item: bf_enr_wave_states | select('equalto', item) | list | length }) }}" + loop: "{{ bf_enr_wave_states | unique | sort }}" + vars: + bf_enr_wave_states: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_enr_enrollment_status') | map('default', 'PENDING', true) | list }}" + run_once: true + + - name: Fleet durum dağılımını hesapla + ansible.builtin.set_fact: + bf_enr_fleet_histogram: "{{ bf_enr_fleet_histogram | default({}) | combine({ item: bf_enr_wave_fleet | select('equalto', item) | list | length }) }}" + loop: "{{ bf_enr_wave_fleet | unique | sort }}" + vars: + bf_enr_wave_fleet: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_enr_fleet_status') | map('default', 'PENDING', true) | list }}" + run_once: true + + - name: Dalga enrollment özetini yaz + ansible.builtin.debug: + msg: + - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" + - "enrollment_status: {{ bf_enr_status_histogram | default({}) }}" + - "fleet_status: {{ bf_enr_fleet_histogram | default({}) }}" + run_once: true + + - name: Eksik merkez kanıtı olan cihazların dalga listesini yaz + ansible.builtin.debug: + msg: "Merkez kanıtı eksik cihazlar: {{ bf_enr_missing_central_hosts }}" + vars: + bf_enr_missing_central_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_enr_central_state') | map('default', 'MISSING', true) | list)) | dict2items | selectattr('value', 'equalto', 'MISSING') | map(attribute='key') | list }}" + run_once: true + + - name: Kanonik CLI ile çelişen cihazların dalga listesini yaz + ansible.builtin.debug: + msg: "state.json ve bf-enrollment-status çelişkili cihazlar: {{ bf_enr_cli_mismatch_hosts }}" + vars: + bf_enr_cli_mismatch_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_enr_cli_mismatch') | map('default', 'OK', true) | list)) | dict2items | selectattr('value', 'equalto', 'MISMATCH') | map(attribute='key') | list }}" + run_once: true diff --git a/ansible/playbooks/bf-fieldos-version.yml b/ansible/playbooks/bf-fieldos-version.yml new file mode 100644 index 0000000..93c0cb1 --- /dev/null +++ b/ansible/playbooks/bf-fieldos-version.yml @@ -0,0 +1,208 @@ +--- +# Blueforce Field OS sürüm raporu -- READ-ONLY, dalga kapsamlı. +# Ne yapar: bir dalgadaki cihazların Field OS sürümünü kanıt dosyalarından toplar, cihaz başına +# tek satır yazar ve sürüm başına cihaz sayısını (toplu özet) üretir. +# Kanıt sırası: /usr/local/bin/bf-release (kanonik) -> /etc/blueforce/release-manifest.yaml +# -> eski /etc/blueforce-release (KEY=VALUE) -> /var/lib/blueforce/state.json. +# Hiçbir görev durum değiştirmez: yalnız stat/slurp ve read-only CLI çıktısı okunur; tüm +# command görevleri changed_when: false taşır. +# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-fieldos-version.yml -e operation_wave=lab -e operation_confirmed=true --check +# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-fieldos-version.yml -e operation_wave=lab -e operation_confirmed=true +# Opsiyonel drift denetimi: -e fieldos_expected_version=1.0.0 +# Semaphore Pro bağımlılığı yok. +# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, +# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). +- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) + hosts: "{{ ansible_limit | default('localhost') }}" + gather_facts: false + become: false + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" + tasks: + - name: Require an approved confirmed operation wave before any host is touched + ansible.builtin.assert: + that: + - operation_wave_gate in approved_operation_waves + - operation_confirmed | default(false) | bool + - operation_wave_gate in groups + - groups[operation_wave_gate] | length > 0 + - (operation_wave_gate != 'production') or (production_override | default(false) | bool) + fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." + run_once: true + + - name: Require the --limit selection to intersect the declared wave + ansible.builtin.assert: + that: + - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) + or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) + fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." + when: + - (ansible_limit | default('') | length) > 0 + - not (ansible_limit | default('') is search('[][*?]')) + run_once: true + +- name: Field OS sürümünü toplu raporla (read-only) + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true # Yalnız root sahipli kanıt dosyalarını (state.json 0600) OKUMAK için; mutasyon yok. + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + fieldos_release_bin: /usr/local/bin/bf-release + fieldos_manifest_file: /etc/blueforce/release-manifest.yaml + fieldos_legacy_file: /etc/blueforce-release + fieldos_state_file: /var/lib/blueforce/state.json + fieldos_expected_version: "" + fieldos_unknown_label: UNKNOWN + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - groups[operation_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Kanıt yollarının varlığını denetle (read-only) + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ fieldos_release_bin }}" + - "{{ fieldos_manifest_file }}" + - "{{ fieldos_legacy_file }}" + - "{{ fieldos_state_file }}" + register: fieldos_evidence + + - name: Kanıt durumlarını yol ile indeksle + ansible.builtin.set_fact: + fieldos_evidence_by_path: "{{ fieldos_evidence.results | items2dict(key_name='item', value_name='stat') }}" + + - name: Kanonik sürüm raporunu oku (bf-release, read-only) + ansible.builtin.command: "{{ fieldos_release_bin }}" + register: fieldos_release_out + changed_when: false + failed_when: fieldos_release_out.rc not in [0, 1] + when: fieldos_evidence_by_path[fieldos_release_bin].exists | default(false) + + - name: Release manifestini oku (read-only) + ansible.builtin.slurp: + src: "{{ fieldos_manifest_file }}" + register: fieldos_manifest_raw + when: + - fieldos_evidence_by_path[fieldos_manifest_file].exists | default(false) + - fieldos_evidence_by_path[fieldos_manifest_file].size | default(0) | int > 0 + + - name: Eski /etc/blueforce-release dosyasını oku (read-only) + ansible.builtin.slurp: + src: "{{ fieldos_legacy_file }}" + register: fieldos_legacy_raw + when: + - fieldos_evidence_by_path[fieldos_legacy_file].exists | default(false) + - fieldos_evidence_by_path[fieldos_legacy_file].size | default(0) | int > 0 + + - name: Field OS durum dosyasını oku (read-only) + ansible.builtin.slurp: + src: "{{ fieldos_state_file }}" + register: fieldos_state_raw + when: + - fieldos_evidence_by_path[fieldos_state_file].exists | default(false) + - fieldos_evidence_by_path[fieldos_state_file].size | default(0) | int > 0 + + - name: Durum dosyası içeriğini çöz (bozuk dosyada rapor UNKNOWN kalır, koşu durmaz) + ansible.builtin.set_fact: + fieldos_state_parsed: "{{ fieldos_state_raw.content | b64decode | from_json }}" + when: fieldos_state_raw.content is defined + ignore_errors: true + + - name: Kanıt metinlerini çözümle + ansible.builtin.set_fact: + fieldos_manifest_text: "{{ (fieldos_manifest_raw.content | b64decode) if fieldos_manifest_raw.content is defined else '' }}" + fieldos_legacy_text: "{{ (fieldos_legacy_raw.content | b64decode) if fieldos_legacy_raw.content is defined else '' }}" + fieldos_state_ok: "{{ fieldos_state_parsed is defined and fieldos_state_parsed is mapping }}" + + - name: Sürüm alanlarını kanıtlardan çıkar + ansible.builtin.set_fact: + fieldos_release_line: "{{ fieldos_release_out.stdout_lines | default([]) | select('search', '^Field OS: ') | list | first | default('', true) }}" + fieldos_manifest_name_raw: "{{ fieldos_manifest_text | regex_findall('(?m)^[ ]+name: ([^ ]+)') | first | default('', true) }}" + fieldos_manifest_version_raw: "{{ fieldos_manifest_text | regex_findall('(?m)^[ ]+version: ([^ ]+)') | first | default('', true) }}" + fieldos_manifest_status_raw: "{{ fieldos_manifest_text | regex_findall('(?m)^[ ]+status: ([^ ]+)') | first | default('', true) }}" + fieldos_legacy_version_raw: "{{ fieldos_legacy_text | regex_findall('(?mi)^version=(.+)$') | first | default('', true) }}" + fieldos_state_version_raw: "{{ (fieldos_state_parsed | default({}, true)).get('field_os_version', (fieldos_state_parsed | default({}, true)).get('release_version', '')) | default('', true) }}" + fieldos_device_id: "{{ (fieldos_state_parsed | default({}, true)).get('device_id', inventory_hostname | upper) }}" + + - name: Sürüm değerlerini normalize et (unknown/boş değer geçersiz sayılır) + ansible.builtin.set_fact: + fieldos_release_version: "{{ fieldos_release_line | trim | regex_replace('(?i)^unknown$', '') }}" + fieldos_manifest_version: "{{ fieldos_manifest_version_raw | trim | regex_replace('(?i)^(unknown|skeleton)$', '') }}" + fieldos_legacy_version: "{{ fieldos_legacy_version_raw | trim | regex_replace('(?i)^unknown$', '') }}" + fieldos_state_version: "{{ fieldos_state_version_raw | trim | regex_replace('(?i)^unknown$', '') }}" + + - name: Field OS sürümünü ve kanıt kaynağını belirle + ansible.builtin.set_fact: + fieldos_version: "{{ [fieldos_release_version, fieldos_manifest_version, fieldos_legacy_version, fieldos_state_version] | reject('equalto', '') | list | first | default(fieldos_unknown_label, true) }}" + fieldos_version_source: >- + {{ 'bf-release' if fieldos_release_version | length > 0 + else ('release-manifest' if fieldos_manifest_version | length > 0 + else ('blueforce-release' if fieldos_legacy_version | length > 0 + else ('state.json' if fieldos_state_version | length > 0 else 'none'))) }} + fieldos_release_status: >- + {{ 'UNAPPROVED_SKELETON' if fieldos_manifest_status_raw | trim | lower == 'skeleton' + else (fieldos_manifest_status_raw | trim | upper if fieldos_manifest_status_raw | trim | length > 0 else 'UNKNOWN') }} + fieldos_release_name: "{{ fieldos_manifest_name_raw | trim | default('', true) }}" + + - name: Cihaz sürüm satırını raporla + ansible.builtin.debug: + msg: "{{ fieldos_device_id }} -> {{ fieldos_version }} (kaynak={{ fieldos_version_source }}, release={{ fieldos_release_status }})" + + - name: Sürümü doğrulanamayan cihazı işaretle + ansible.builtin.debug: + msg: "UYARI {{ fieldos_device_id }}: Field OS sürüm kanıtı yok (kaynak=none); cihaz inceleme/yeniden provision gerektirir." + when: fieldos_version == fieldos_unknown_label + + - name: Onaysız iskelet release taşıyan cihazı işaretle + ansible.builtin.debug: + msg: "UYARI {{ fieldos_device_id }}: release status UNAPPROVED_SKELETON; saha cihazı olarak kabul edilmez." + when: fieldos_release_status == 'UNAPPROVED_SKELETON' + + - name: Beklenen sürümden sapan cihazı işaretle + ansible.builtin.debug: + msg: "UYARI {{ fieldos_device_id }}: sürüm {{ fieldos_version }}, beklenen {{ fieldos_expected_version }}." + when: + - fieldos_expected_version | length > 0 + - fieldos_version != fieldos_expected_version + + - name: Dalga sürüm dağılımını hesapla (sürüm -> cihaz sayısı) + ansible.builtin.set_fact: + fieldos_version_histogram: "{{ fieldos_version_histogram | default({}) | combine({ item: fieldos_wave_versions | select('equalto', item) | list | length }) }}" + loop: "{{ fieldos_wave_versions | unique | sort }}" + vars: + fieldos_wave_versions: "{{ ansible_play_hosts_all | map('extract', hostvars, 'fieldos_version') | map('default', fieldos_unknown_label, true) | list }}" + run_once: true + + - name: Dalga sürüm özetini yaz + ansible.builtin.debug: + msg: + - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" + - "Sürüm dağılımı: {{ fieldos_version_histogram | default({}) }}" + - "Beklenen sürüm: {{ fieldos_expected_version if fieldos_expected_version | length > 0 else '(verilmedi, drift denetimi atlandi)' }}" + run_once: true + + - name: Sürüm kanıtı olmayan cihazların dalga listesini yaz + ansible.builtin.debug: + msg: "Sürüm kanıtı olmayan cihazlar: {{ fieldos_missing_version_hosts }}" + vars: + fieldos_missing_version_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'fieldos_version') | map('default', fieldos_unknown_label, true) | list)) | dict2items | selectattr('value', 'equalto', fieldos_unknown_label) | map(attribute='key') | list }}" + run_once: true diff --git a/ansible/playbooks/bf-gui-off.yml b/ansible/playbooks/bf-gui-off.yml new file mode 100644 index 0000000..3daa05c --- /dev/null +++ b/ansible/playbooks/bf-gui-off.yml @@ -0,0 +1,42 @@ +--- +# Disable the GUI for a confirmed maintenance wave (kiosk/headless mode). +# Dry run: ansible-playbook -i inventory/hosts.yml playbooks/bf-gui-off.yml -e operation_wave=lab -e operation_confirmed=true --check +- name: Disable graphical target + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Read current default target + ansible.builtin.command: systemctl get-default + register: current_default_target + changed_when: false + + - name: Set default target to multi-user + ansible.builtin.command: systemctl set-default multi-user.target + when: current_default_target.stdout != 'multi-user.target' + + - name: Stop graphical target now + ansible.builtin.systemd: + name: graphical.target + state: stopped diff --git a/ansible/playbooks/bf-gui-on.yml b/ansible/playbooks/bf-gui-on.yml new file mode 100644 index 0000000..2ca2e41 --- /dev/null +++ b/ansible/playbooks/bf-gui-on.yml @@ -0,0 +1,38 @@ +--- +# Enable the GUI for a confirmed maintenance wave without changing the boot default. +- name: Enable graphical maintenance session + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Start GUI and wait for RDP + ansible.builtin.command: /usr/local/bin/bf-gui-on + register: gui_on + changed_when: true + + - name: Verify terminal boot default remains configured + ansible.builtin.command: systemctl get-default + register: current_default_target + changed_when: false + failed_when: current_default_target.stdout != 'multi-user.target' \ No newline at end of file diff --git a/ansible/playbooks/bf-offline-ready-check.yml b/ansible/playbooks/bf-offline-ready-check.yml new file mode 100644 index 0000000..3d25ad6 --- /dev/null +++ b/ansible/playbooks/bf-offline-ready-check.yml @@ -0,0 +1,272 @@ +--- +# Blueforce offline hazırlık raporu -- READ-ONLY, dalga kapsamlı. +# Ne yapar: internet olmadan PROVISIONED_OFFLINE durumuna ulaşmayı sağlayan yerel ön koşulları +# kanıtlarıyla raporlar: +# 1) offline APT repo mevcut mu (BF_OFFLINE_REPO, varsayılan /opt/blueforce/offline-repo, +# zorunlu Packages/Packages.gz/packages.lock.tsv/SHA256SUMS dosyaları), +# 2) yerel paketler doğrulanmış mı (sha256sum -c SHA256SUMS, paket kilidi şeması, +# havuzda pinlenen .deb dosyaları), +# 3) firstboot tamamlanma damgası var mı (/var/lib/blueforce/firstboot-complete), +# 4) onaysız update kapısı aktif mi (20auto-upgrades değerleri 0 + apt-daily timer +# maskeleme + apt/dpkg kilidi meşgul değil). +# Sözleşme: docs/27 (offline provisioning), docs/10 (update kapısı), docs/05 modül 14. +# Hiçbir görev durum değiştirmez: yalnız stat/slurp ve read-only CLI çıktısı okunur; tüm command +# görevleri changed_when: false taşır. +# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-offline-ready-check.yml -e operation_wave=lab -e operation_confirmed=true --check +# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-offline-ready-check.yml -e operation_wave=lab -e operation_confirmed=true +# SHA256 doğrulamasını atlamak için: -e bf_offline_verify_hashes=false +# Semaphore Pro bağımlılığı yok. +# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, +# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). +- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) + hosts: "{{ ansible_limit | default('localhost') }}" + gather_facts: false + become: false + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" + tasks: + - name: Require an approved confirmed operation wave before any host is touched + ansible.builtin.assert: + that: + - operation_wave_gate in approved_operation_waves + - operation_confirmed | default(false) | bool + - operation_wave_gate in groups + - groups[operation_wave_gate] | length > 0 + - (operation_wave_gate != 'production') or (production_override | default(false) | bool) + fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." + run_once: true + + - name: Require the --limit selection to intersect the declared wave + ansible.builtin.assert: + that: + - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) + or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) + fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." + when: + - (ansible_limit | default('') | length) > 0 + - not (ansible_limit | default('') is search('[][*?]')) + run_once: true + +- name: Offline hazırlık ön koşullarını raporla (read-only) + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true # Yalnız root sahipli offline repo/durum dosyalarını OKUMAK için; mutasyon yok. + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + bf_offline_repo: "{{ bf_offline_repo_path | default('/opt/blueforce/offline-repo') }}" + bf_offline_verify_hashes: true + offline_firstboot_marker: /var/lib/blueforce/firstboot-complete + offline_list_file: /var/lib/blueforce/offline.list + offline_installer: /usr/local/sbin/blueforce-install.sh + offline_dealer_id_file: /etc/blueforce/dealer-id + offline_apt_periodic_conf: /etc/apt/apt.conf.d/20auto-upgrades + offline_required_repo_files: [Packages, Packages.gz, packages.lock.tsv, SHA256SUMS] + offline_update_timers: [apt-daily.timer, apt-daily-upgrade.timer] + offline_pkg_processes: [apt-get, apt, dpkg] + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - groups[operation_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Offline kanıt yollarını denetle (read-only) + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ bf_offline_repo }}" + - "{{ offline_firstboot_marker }}" + - "{{ offline_list_file }}" + - "{{ offline_installer }}" + - "{{ offline_dealer_id_file }}" + - "{{ offline_apt_periodic_conf }}" + register: offline_evidence + + - name: Zorunlu offline repo dosyalarını denetle (read-only) + ansible.builtin.stat: + path: "{{ bf_offline_repo }}/{{ item }}" + loop: "{{ offline_required_repo_files }}" + register: offline_repo_file_stats + + - name: Kanıt durumlarını yol ile indeksle + ansible.builtin.set_fact: + offline_evidence_by_path: "{{ offline_evidence.results | items2dict(key_name='item', value_name='stat') }}" + bf_off_repo_files: "{{ dict(offline_repo_file_stats.results | map(attribute='item') | list | zip(offline_repo_file_stats.results | map(attribute='stat') | list)) }}" + bf_off_missing_files: >- + {{ (offline_repo_file_stats.results | selectattr('stat', 'defined') | rejectattr('stat.exists') | map(attribute='item') | list) + + (offline_repo_file_stats.results | selectattr('stat', 'defined') | selectattr('stat.exists') | selectattr('stat.size', 'equalto', 0) | map(attribute='item') | list) }} + + - name: Paket kilidi satırlarını doğrula (awk, read-only) + ansible.builtin.command: + argv: + - awk + - 'NR==1 {hdr = ($1=="package" && $2=="version" && $3=="architecture" && $4=="sha256" && $5=="source") ? 1 : 0; next} /^[[:space:]]*#/ || NF==0 {next} {rows++; if (NF==5 && $1 ~ /^[a-z0-9][a-z0-9+.-]*$/ && $4 ~ /^[a-f0-9]{64}$/ && $2!="" && $3!="" && $5!="") {ok++}} END {printf "header=%d rows=%d valid=%d\n", hdr, rows, ok}' + - "{{ bf_offline_repo }}/packages.lock.tsv" + register: offline_lock_probe + changed_when: false + failed_when: false + when: + - bf_off_repo_files['packages.lock.tsv'].exists | default(false) + - bf_off_repo_files['packages.lock.tsv'].size | default(0) | int > 0 + + - name: Kilitli paket kaynak yollarını listele (awk, read-only) + ansible.builtin.command: + argv: + - awk + - 'NR>1 && NF>0 && $0 !~ /^[[:space:]]*#/ {print $5}' + - "{{ bf_offline_repo }}/packages.lock.tsv" + register: offline_lock_sources + changed_when: false + failed_when: false + when: + - bf_off_repo_files['packages.lock.tsv'].exists | default(false) + - bf_off_repo_files['packages.lock.tsv'].size | default(0) | int > 0 + + - name: Pinlenen paketlerin havuz dosyalarını denetle (read-only) + ansible.builtin.stat: + path: "{{ bf_offline_repo }}/pool/{{ item }}" + loop: "{{ offline_lock_sources.stdout_lines | default([]) }}" + register: offline_pool_files + + - name: Yerel APT indekslerinin SHA256 doğrulamasını yap (read-only) + ansible.builtin.command: + argv: + - sha256sum + - -c + - --quiet + - SHA256SUMS + chdir: "{{ bf_offline_repo }}" + register: offline_checksums + changed_when: false + failed_when: false + when: + - bf_offline_verify_hashes | bool + - bf_off_repo_files['SHA256SUMS'].exists | default(false) + - bf_off_repo_files['SHA256SUMS'].size | default(0) | int > 0 + + - name: Update kapısı yapılandırmasını oku (read-only) + ansible.builtin.slurp: + src: "{{ offline_apt_periodic_conf }}" + register: offline_apt_periodic_raw + when: + - offline_evidence_by_path[offline_apt_periodic_conf].exists | default(false) + - offline_evidence_by_path[offline_apt_periodic_conf].size | default(0) | int > 0 + + - name: Otomatik güncelleme timer maskelemesini oku (read-only) + ansible.builtin.command: + argv: + - systemctl + - is-enabled + - "{{ item }}" + loop: "{{ offline_update_timers }}" + register: offline_timer_states + changed_when: false + failed_when: false + + - name: Paket işlemi çalışıyor mu denetle (read-only) + ansible.builtin.command: + argv: + - pgrep + - -x + - "{{ item }}" + loop: "{{ offline_pkg_processes }}" + register: offline_pkg_procs + changed_when: false + failed_when: false + + - name: Offline hazırlık ölçümlerini hesapla + ansible.builtin.set_fact: + bf_off_repo_present: "{{ offline_evidence_by_path[bf_offline_repo].exists | default(false) and offline_evidence_by_path[bf_offline_repo].isdir | default(false) }}" + bf_off_missing_count: "{{ bf_off_missing_files | length }}" + bf_off_lock_header_ok: "{{ offline_lock_probe.stdout | default('') | regex_findall('header=([0-9]+)') | first | default('0', true) == '1' }}" + bf_off_lock_rows: "{{ offline_lock_probe.stdout | default('') | regex_findall('rows=([0-9]+)') | first | default('0', true) | int }}" + bf_off_lock_valid: "{{ offline_lock_probe.stdout | default('') | regex_findall('valid=([0-9]+)') | first | default('0', true) | int }}" + bf_off_pool_checked: "{{ offline_pool_files.results | default([]) | selectattr('stat', 'defined') | list | length }}" + bf_off_pool_missing: >- + {{ offline_pool_files.results | default([]) | selectattr('stat', 'defined') | map(attribute='stat') + | selectattr('exists', 'defined') | rejectattr('exists') | list | length }} + bf_off_firstboot_marker: "{{ offline_evidence_by_path[offline_firstboot_marker].exists | default(false) }}" + bf_off_installer_present: "{{ (offline_evidence_by_path[offline_installer].exists | default(false)) and (offline_evidence_by_path[offline_installer].executable | default(false)) }}" + bf_off_dealer_id_present: "{{ offline_evidence_by_path[offline_dealer_id_file].exists | default(false) }}" + bf_off_offline_list_present: "{{ offline_evidence_by_path[offline_list_file].exists | default(false) }}" + bf_off_apt_periodic: "{{ dict(bf_off_apt_periodic_values | map('first') | map('lower') | list | zip(bf_off_apt_periodic_values | map('last') | list)) }}" + bf_off_update_timers_masked: "{{ offline_timer_states.results | default([]) | map(attribute='stdout') | map('trim') | reject('equalto', 'masked') | list | length == 0 }}" + bf_off_pkg_lock_busy: "{{ offline_pkg_procs.results | default([]) | selectattr('rc', 'equalto', 0) | list | length > 0 }}" + vars: + bf_off_apt_periodic_values: >- + {{ offline_apt_periodic_raw.content | default('', true) | b64decode + | regex_findall('(?m)^APT::Periodic::([A-Za-z-]+)[ =]+[^0-9]*([0-9]+)') }} + + - name: Yerel paket doğrulama ve update kapısı durumunu etiketle + ansible.builtin.set_fact: + bf_off_checksum_state: >- + {{ 'SKIPPED' if not (bf_offline_verify_hashes | bool) + else ('VERIFIED' if offline_checksums.rc | default(1) == 0 + else ('MISSING' if not (bf_off_repo_files['SHA256SUMS'].exists | default(false)) else 'FAILED')) }} + bf_off_update_gate: >- + {{ 'ACTIVE' if (bf_off_apt_periodic.get('unattended-upgrade', '1') == '0' + and bf_off_apt_periodic.get('enable', '1') == '0' + and bf_off_update_timers_masked | bool) + else 'INACTIVE' }} + + - name: Offline hazırlık engellerini topla + ansible.builtin.set_fact: + bf_off_blockers: >- + {{ (['offline repo yok veya dizin değil: ' ~ bf_offline_repo] if not (bf_off_repo_present | bool) else []) + + ([('zorunlu offline dosyası eksik/boş: ' ~ (bf_off_missing_files | join(', ')))] if (bf_off_missing_count | int) > 0 else []) + + (['paket kilidi başlığı geçersiz (package/version/architecture/sha256/source)'] if not (bf_off_lock_header_ok | bool) else []) + + ([('paket kilidi geçersiz veya boş satırlar: ' ~ (bf_off_lock_valid | string) ~ '/' ~ (bf_off_lock_rows | string))] if ((bf_off_lock_rows | int) == 0 or (bf_off_lock_valid | int) != (bf_off_lock_rows | int)) else []) + + ([('havuzda eksik paket dosyası: ' ~ (bf_off_pool_missing | string))] if (bf_off_pool_missing | int) > 0 else []) + + (['yerel APT indeksi SHA256 doğrulaması BAŞARISIZ'] if bf_off_checksum_state == 'FAILED' else []) + + (['onaysız update kapısı kapalı (unattended-upgrades veya apt-daily timer açık)'] if bf_off_update_gate != 'ACTIVE' else []) + + (['başka bir paket işlemi koşuyor (apt/dpkg kilidi meşgul)'] if bf_off_pkg_lock_busy | bool else []) }} + + - name: Offline hazırlık kararını yaz + ansible.builtin.set_fact: + bf_off_verdict: "{{ 'PROVISIONED_OFFLINE-READY' if (bf_off_blockers | length) == 0 else 'BLOCKED' }}" + + - name: Cihaz offline hazırlık satırını raporla + ansible.builtin.debug: + msg: + - "{{ inventory_hostname | upper }} offline_repo={{ 'VAR' if bf_off_repo_present | bool else 'YOK' }} eksik_dosya={{ bf_off_missing_count }} paket_kilidi={{ bf_off_lock_valid }}/{{ bf_off_lock_rows }} havuz={{ bf_off_pool_checked }} eksik_havuz={{ bf_off_pool_missing }}" + - "{{ inventory_hostname | upper }} sha256={{ bf_off_checksum_state }} firstboot_damgasi={{ 'VAR' if bf_off_firstboot_marker | bool else 'YOK' }} update_kapisi={{ bf_off_update_gate }} paket_islemi_mesgul={{ bf_off_pkg_lock_busy }}" + - "{{ inventory_hostname | upper }} kurucu={{ 'VAR' if bf_off_installer_present | bool else 'YOK' }} bayi_kimligi={{ 'VAR' if bf_off_dealer_id_present | bool else 'YOK' }} offline.list={{ 'VAR' if bf_off_offline_list_present | bool else 'YOK' }} karar={{ bf_off_verdict }}" + + - name: Offline ön koşulu eksik cihaz için engelleri yaz + ansible.builtin.debug: + msg: "ENGELLER {{ inventory_hostname | upper }}: {{ bf_off_blockers }}" + when: bf_off_blockers | length > 0 + + - name: Offline hazırlık karar dağılımını hesapla + ansible.builtin.set_fact: + bf_off_verdict_histogram: "{{ bf_off_verdict_histogram | default({}) | combine({ item: bf_off_wave_verdicts | select('equalto', item) | list | length }) }}" + loop: "{{ bf_off_wave_verdicts | unique | sort }}" + vars: + bf_off_wave_verdicts: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_off_verdict') | map('default', 'BLOCKED', true) | list }}" + run_once: true + + - name: Dalga offline hazırlık özetini yaz + ansible.builtin.debug: + msg: + - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" + - "Karar dağılımı: {{ bf_off_verdict_histogram | default({}) }}" + - "Offline ön koşulu eksik cihazlar: {{ bf_off_blocked_hosts }}" + vars: + bf_off_blocked_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_off_verdict') | map('default', 'BLOCKED', true) | list)) | dict2items | selectattr('value', 'equalto', 'BLOCKED') | map(attribute='key') | list }}" + run_once: true diff --git a/ansible/playbooks/bf-package-check.yml b/ansible/playbooks/bf-package-check.yml new file mode 100644 index 0000000..4ce4255 --- /dev/null +++ b/ansible/playbooks/bf-package-check.yml @@ -0,0 +1,24 @@ +--- +# Report installed versions of pinned packages (read-only). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-package-check.yml --limit lab +# Package list overridable: -e "check_packages=['docker-ce','wireguard']". +# No Semaphore Pro feature is used. +- name: Check installed package versions + hosts: blueforce + gather_facts: false + become: true + vars: + check_packages: + - docker-ce + - wireguard + - rustdesk + tasks: + - name: Query package versions + ansible.builtin.shell: "dpkg-query -W -f='${Package} ${Version}\n' {{ check_packages | join(' ') }}" + register: pkg_out + changed_when: false + failed_when: false + + - name: Show package versions + ansible.builtin.debug: + msg: "{{ inventory_hostname }}:\n{{ pkg_out.stdout | default('(query failed)') }}" diff --git a/ansible/playbooks/bf-ping.yml b/ansible/playbooks/bf-ping.yml new file mode 100644 index 0000000..3e26db4 --- /dev/null +++ b/ansible/playbooks/bf-ping.yml @@ -0,0 +1,12 @@ +--- +# Connectivity check for the Blueforce fleet. +# Targeting: single host -> group -> pilot wave -> full fleet (--limit). +# Dry run first: ansible-playbook -i inventory/hosts.yml playbooks/bf-ping.yml --limit lab --check +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-ping.yml --limit lab +# No changes are made by this playbook. No Semaphore Pro feature is used. +- name: Ping fleet hosts + hosts: blueforce + gather_facts: false + tasks: + - name: Check SSH connectivity + ansible.builtin.ping: diff --git a/ansible/playbooks/bf-provisioning-status.yml b/ansible/playbooks/bf-provisioning-status.yml new file mode 100644 index 0000000..af4c651 --- /dev/null +++ b/ansible/playbooks/bf-provisioning-status.yml @@ -0,0 +1,183 @@ +--- +# Blueforce provisioning faz raporu -- READ-ONLY, dalga kapsamlı. +# Ne yapar: /var/lib/blueforce/state.json kanıtından cihazların provisioning fazını çıkarır +# (PROVISIONED_OFFLINE / ENROLLED / READY / PENDING / INVALID), faz dağılımını ve +# faz ile yerel kanıtlar arasındaki çelişkileri (offline cihazda wg0.conf, damgasız +# firstboot) toplu raporlar. Durum makinesi sözleşmesi: docs/27 ve docs/29. +# Hiçbir görev durum değiştirmez: yalnız stat/slurp ve read-only CLI çıktısı okunur; tüm +# command görevleri changed_when: false taşır. +# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-provisioning-status.yml -e operation_wave=lab -e operation_confirmed=true --check +# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-provisioning-status.yml -e operation_wave=lab -e operation_confirmed=true +# Semaphore Pro bağımlılığı yok. +# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, +# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). +- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) + hosts: "{{ ansible_limit | default('localhost') }}" + gather_facts: false + become: false + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" + tasks: + - name: Require an approved confirmed operation wave before any host is touched + ansible.builtin.assert: + that: + - operation_wave_gate in approved_operation_waves + - operation_confirmed | default(false) | bool + - operation_wave_gate in groups + - groups[operation_wave_gate] | length > 0 + - (operation_wave_gate != 'production') or (production_override | default(false) | bool) + fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." + run_once: true + + - name: Require the --limit selection to intersect the declared wave + ansible.builtin.assert: + that: + - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) + or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) + fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." + when: + - (ansible_limit | default('') | length) > 0 + - not (ansible_limit | default('') is search('[][*?]')) + run_once: true + +- name: Provisioning faz dağılımını raporla (read-only) + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true # Yalnız root sahipli kanıt dosyalarını (state.json 0600) OKUMAK için; mutasyon yok. + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + provisioning_state_file: /var/lib/blueforce/state.json + provisioning_firstboot_marker: /var/lib/blueforce/firstboot-complete + provisioning_wg_conf: /etc/wireguard/wg0.conf + provisioning_canonical_phases: [PROVISIONED_OFFLINE, ENROLLED, READY] + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - groups[operation_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Provisioning kanıt yollarını denetle (read-only) + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ provisioning_state_file }}" + - "{{ provisioning_firstboot_marker }}" + - "{{ provisioning_wg_conf }}" + register: provisioning_evidence + + - name: Kanıt durumlarını yol ile indeksle + ansible.builtin.set_fact: + provisioning_evidence_by_path: "{{ provisioning_evidence.results | items2dict(key_name='item', value_name='stat') }}" + + - name: Durum dosyasını oku (read-only) + ansible.builtin.slurp: + src: "{{ provisioning_state_file }}" + register: provisioning_state_raw + when: + - provisioning_evidence_by_path[provisioning_state_file].exists | default(false) + - provisioning_evidence_by_path[provisioning_state_file].size | default(0) | int > 0 + + - name: Durum dosyası içeriğini çöz (bozuk dosyada rapor INVALID kalır, koşu durmaz) + ansible.builtin.set_fact: + provisioning_state_parsed: "{{ provisioning_state_raw.content | b64decode | from_json }}" + when: provisioning_state_raw.content is defined + ignore_errors: true + + - name: Provisioning fazını ve yerel kanıt durumunu belirle + ansible.builtin.set_fact: + bf_state_present: "{{ provisioning_state_raw.content is defined }}" + bf_state_parsed: "{{ provisioning_state_parsed | default({}, true) if provisioning_state_parsed is defined else {} }}" + bf_firstboot_marker_exists: "{{ provisioning_evidence_by_path[provisioning_firstboot_marker].exists | default(false) }}" + bf_offline_wg_conf_exists: "{{ provisioning_evidence_by_path[provisioning_wg_conf].exists | default(false) }}" + bf_provisioning_id_present: "{{ (provisioning_state_parsed | default({}, true)).get('provisioning_id', '') | length > 0 }}" + bf_device_id: "{{ (provisioning_state_parsed | default({}, true)).get('device_id', inventory_hostname | upper) }}" + + - name: Provisioning fazını sınıflandır + ansible.builtin.set_fact: + bf_phase_raw: "{{ (provisioning_state_parsed | default({}, true)).get('phase', '') | default('', true) }}" + bf_enrollment_status: "{{ (provisioning_state_parsed | default({}, true)).get('enrollment_status', 'PENDING') | default('PENDING', true) }}" + bf_fleet_status: "{{ (provisioning_state_parsed | default({}, true)).get('fleet_status', 'PENDING') | default('PENDING', true) }}" + + - name: Faz etiketini ve durum dosyası sağlığını belirle + ansible.builtin.set_fact: + bf_phase: >- + {{ 'PENDING' if not bf_state_present + else (bf_phase_raw if bf_phase_raw in provisioning_canonical_phases else 'INVALID') }} + bf_state_status: >- + {{ 'MISSING' if not bf_state_present + else ('OK' if bf_phase_raw in provisioning_canonical_phases else 'INVALID') }} + + - name: Yerel kanıt çelişkilerini belirle + ansible.builtin.set_fact: + bf_offline_wg_conflict: "{{ bf_phase == 'PROVISIONED_OFFLINE' and bf_offline_wg_conf_exists }}" + bf_firstboot_conflict: "{{ bf_phase == 'PENDING' and bf_firstboot_marker_exists }}" + + - name: Yerel kanıt çelişkisini tek etikete indir + ansible.builtin.set_fact: + bf_phase_conflict_line: >- + {{ 'OFFLINE_WG_CONFLICT' if bf_offline_wg_conflict | bool + else ('FIRSTBOOT_CONFLICT' if bf_firstboot_conflict | bool else 'OK') }} + + - name: Cihaz provisioning satırını raporla + ansible.builtin.debug: + msg: >- + {{ bf_device_id }} phase={{ bf_phase }} enrollment={{ bf_enrollment_status }} fleet={{ bf_fleet_status }} + state.json={{ bf_state_status }} provisioning_id={{ 'SET' if bf_provisioning_id_present else 'ABSENT' }} + firstboot_damgasi={{ 'VAR' if bf_firstboot_marker_exists else 'YOK' }} + + - name: Henüz provision edilmemiş cihazı işaretle + ansible.builtin.debug: + msg: "UYARI {{ bf_device_id }}: provisioning fazı {{ bf_phase }}; cihaz kurulum/enrollment bekliyor." + when: bf_phase in ['PENDING', 'INVALID'] + + - name: PROVISIONED_OFFLINE cihazda WireGuard hub yapılandırması çelişkisini işaretle + ansible.builtin.debug: + msg: "UYARI {{ bf_device_id }}: faz PROVISIONED_OFFLINE ama /etc/wireguard/wg0.conf var; offline provisioning hub config yazmamalıdır (docs/27)." + when: bf_offline_wg_conflict | bool + + - name: Damgasız firstboot çelişkisini işaretle + ansible.builtin.debug: + msg: "UYARI {{ bf_device_id }}: state.json yok ama firstboot damgası var; kurulum yarım kalmış olabilir (bkz. docs/05 modül 18)." + when: bf_firstboot_conflict | bool + + - name: Faz dağılımını hesapla (faz -> cihaz sayısı) + ansible.builtin.set_fact: + bf_phase_histogram: "{{ bf_phase_histogram | default({}) | combine({ item: bf_wave_phases | select('equalto', item) | list | length }) }}" + loop: "{{ bf_wave_phases | unique | sort }}" + vars: + bf_wave_phases: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_phase') | map('default', 'INVALID', true) | list }}" + run_once: true + + - name: Dalga provisioning özetini yaz + ansible.builtin.debug: + msg: + - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" + - "Faz dağılımı: {{ bf_phase_histogram | default({}) }}" + - "Kanıtsız/geçersiz cihazlar: {{ bf_unprovisioned_hosts }}" + vars: + bf_unprovisioned_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_phase') | map('default', 'INVALID', true) | list)) | dict2items | rejectattr('value', 'in', provisioning_canonical_phases) | map(attribute='key') | list }}" + run_once: true + + - name: Yerel kanıt çelişkisi olan cihazların dalga listesini yaz + ansible.builtin.debug: + msg: "Çelişkili cihazlar (offline wg0.conf / damgasız firstboot): {{ bf_conflict_hosts }}" + vars: + bf_conflict_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_phase_conflict_line') | map('default', 'OK', true) | list)) | dict2items | rejectattr('value', 'equalto', 'OK') | map(attribute='key') | list }}" + run_once: true diff --git a/ansible/playbooks/bf-rdp-restart.yml b/ansible/playbooks/bf-rdp-restart.yml new file mode 100644 index 0000000..df6829f --- /dev/null +++ b/ansible/playbooks/bf-rdp-restart.yml @@ -0,0 +1,25 @@ +--- +# Restart the RDP service (xrdp). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rdp-restart.yml --limit lab +# Service name is overridable via rdp_service_name. +# No Semaphore Pro feature is used. +- name: Restart RDP service + hosts: blueforce + gather_facts: false + become: true + vars: + rdp_restart_service: "{{ rdp_service_name | default('xrdp') }}" + tasks: + - name: Restart RDP + ansible.builtin.systemd: + name: "{{ rdp_restart_service }}" + state: restarted + + - name: Verify RDP is active + ansible.builtin.command: "systemctl is-active {{ rdp_restart_service }}" + register: rdp_state + changed_when: false + + - name: Show RDP state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} rdp={{ rdp_state.stdout }}" diff --git a/ansible/playbooks/bf-reboot.yml b/ansible/playbooks/bf-reboot.yml new file mode 100644 index 0000000..2dfdee4 --- /dev/null +++ b/ansible/playbooks/bf-reboot.yml @@ -0,0 +1,40 @@ +--- +# Controlled reboots require an approved wave and explicit confirmation. +- name: Controlled fleet reboot + hosts: blueforce + gather_facts: false + become: true + serial: 2 + max_fail_percentage: 0 + any_errors_fatal: true + vars: + release_wave: "" + reboot_confirmed: false + allow_production_reboot: false + reboot_delay_seconds: "{{ reboot_grace_seconds | default(300) }}" + pre_tasks: + - name: Require an approved non-production release wave + ansible.builtin.assert: + that: + - release_wave in ['lab', 'pilot_1', 'pilot_2', 'wave_1', 'wave_2', 'production'] + - reboot_confirmed | bool + - release_wave != 'production' or allow_production_reboot | bool + fail_msg: "Set approved release_wave and reboot_confirmed=true. Production additionally requires allow_production_reboot=true." + + - name: Refuse full-fleet scope without explicit production override + ansible.builtin.assert: + that: + - release_wave == 'production' or ansible_limit | default('') | regex_search('(^|[:,])' ~ release_wave ~ '($|[:,])') + fail_msg: "Limit this reboot to the approved release_wave group; full fleet is refused." + tasks: + - name: Reboot host with grace period + ansible.builtin.reboot: + reboot_timeout: 600 + pre_reboot_delay: "{{ reboot_delay_seconds }}" + msg: Blueforce scheduled reboot via Ansible + + - name: Verify host health after reboot batch + ansible.builtin.command: /usr/local/bin/bf-status --check + register: reboot_health + changed_when: false + failed_when: reboot_health.rc != 0 \ No newline at end of file diff --git a/ansible/playbooks/bf-remote-channels-check.yml b/ansible/playbooks/bf-remote-channels-check.yml new file mode 100644 index 0000000..f249942 --- /dev/null +++ b/ansible/playbooks/bf-remote-channels-check.yml @@ -0,0 +1,267 @@ +--- +# Blueforce uzak erişim kanalı raporu -- READ-ONLY, dalga kapsamlı. +# Ne yapar: SSH, xRDP, RustDesk, MeshCentral ve WireGuard kanallarının birim (unit) durumunu +# raporlar; MeshCentral ve AnyDesk OPSİYONELDİR (AnyDesk kurulu değilse açıkça +# "NOT INSTALLED (OPTIONAL)" yazılır). Kanonik /usr/local/bin/bf-remote-status --check +# çıktısı bağımsız doğrulama olarak eklenir (docs/07, docs/11 K-04). +# Hiçbir görev durum değiştirmez: yalnız stat ve read-only CLI çıktısı okunur; tüm command +# görevleri changed_when: false taşır. +# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-remote-channels-check.yml -e operation_wave=lab -e operation_confirmed=true --check +# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-remote-channels-check.yml -e operation_wave=lab -e operation_confirmed=true +# Semaphore Pro bağımlılığı yok. +# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, +# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). +- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) + hosts: "{{ ansible_limit | default('localhost') }}" + gather_facts: false + become: false + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" + tasks: + - name: Require an approved confirmed operation wave before any host is touched + ansible.builtin.assert: + that: + - operation_wave_gate in approved_operation_waves + - operation_confirmed | default(false) | bool + - operation_wave_gate in groups + - groups[operation_wave_gate] | length > 0 + - (operation_wave_gate != 'production') or (production_override | default(false) | bool) + fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." + run_once: true + + - name: Require the --limit selection to intersect the declared wave + ansible.builtin.assert: + that: + - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) + or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) + fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." + when: + - (ansible_limit | default('') | length) > 0 + - not (ansible_limit | default('') is search('[][*?]')) + run_once: true + +- name: Uzak erişim kanallarının durumunu raporla (read-only) + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true # Yalnız wg show ve root okuma gerektiren kanıtı (birim/state) OKUMAK için; mutasyon yok. + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + remote_channel_units: [ssh, sshd, xrdp, xrdp-sesman, rustdesk, meshagent, wg-quick@wg0] + remote_channel_props: [is-enabled, is-active] + remote_wg_interface: "{{ wg_interface | default('wg0') }}" + remote_wg_handshake_max_age: "{{ wg_handshake_max_age_seconds | default(180) }}" + remote_rustdesk_system_config: /etc/rustdesk/RustDesk2.toml + remote_rustdesk_user_config: /home/blueforce/.config/RustDesk/RustDesk2.toml + remote_mesh_config: /etc/meshagent/meshagent.json + remote_status_bin: /usr/local/bin/bf-remote-status + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - groups[operation_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Kanal yapılandırma dosyalarını denetle (read-only) + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ remote_rustdesk_system_config }}" + - "{{ remote_rustdesk_user_config }}" + - "{{ remote_mesh_config }}" + - "{{ remote_status_bin }}" + register: remote_channel_files + + - name: Yapılandırma durumlarını yol ile indeksle + ansible.builtin.set_fact: + remote_channel_files_by_path: "{{ remote_channel_files.results | items2dict(key_name='item', value_name='stat') }}" + + - name: Birim etkin/aktif durumlarını oku (systemctl, read-only) + ansible.builtin.command: + argv: + - systemctl + - "{{ item[1] }}" + - --quiet + - "{{ item[0] }}" + loop: "{{ remote_channel_units | product(remote_channel_props) | list }}" + register: remote_channel_probe + changed_when: false + failed_when: false + + - name: Birim durumlarını tek sözlükte topla + ansible.builtin.set_fact: + bf_unit_states: "{{ bf_unit_states | default({}) | combine({ item.item[0] ~ ':' ~ item.item[1]: (item.rc == 0) }) }}" + loop: "{{ remote_channel_probe.results }}" + + - name: RustDesk paket durumunu oku (read-only) + ansible.builtin.command: + argv: + - dpkg-query + - -W + - -f=${Status} + - rustdesk + register: remote_rustdesk_pkg + changed_when: false + failed_when: false + + - name: AnyDesk (opsiyonel) paket durumunu oku (read-only) + ansible.builtin.command: + argv: + - dpkg-query + - -W + - -f=${Status} + - anydesk + register: remote_anydesk_pkg + changed_when: false + failed_when: false + + - name: WireGuard son handshake kaydını oku (read-only) + ansible.builtin.command: + argv: + - wg + - show + - "{{ remote_wg_interface }}" + - latest-handshakes + register: remote_wg_handshakes + changed_when: false + failed_when: false + + - name: Kontrol saatinin epoch değerini oku (read-only) + ansible.builtin.command: date +%s + register: remote_now_epoch + changed_when: false + + - name: Kanonik uzak erişim doğrulamasını çalıştır (bf-remote-status --check, read-only) + ansible.builtin.command: "{{ remote_status_bin }} --check" + register: remote_status_cli + changed_when: false + failed_when: false + when: remote_channel_files_by_path[remote_status_bin].exists | default(false) + + - name: Kanal hükümlerini hesapla + ansible.builtin.set_fact: + bf_ch_ssh: >- + {{ 'enabled-active' + if ((bf_unit_states.get('ssh:is-enabled', false) | bool and bf_unit_states.get('ssh:is-active', false) | bool) + or (bf_unit_states.get('sshd:is-enabled', false) | bool and bf_unit_states.get('sshd:is-active', false) | bool)) + else 'not-ready' }} + bf_ch_xrdp: >- + {{ 'enabled-active' + if (bf_unit_states.get('xrdp:is-enabled', false) | bool and bf_unit_states.get('xrdp:is-active', false) | bool + and bf_unit_states.get('xrdp-sesman:is-enabled', false) | bool and bf_unit_states.get('xrdp-sesman:is-active', false) | bool) + else 'not-ready' }} + bf_ch_rustdesk: >- + {{ 'installed-active' + if (remote_rustdesk_pkg.rc | default(1) == 0 and bf_unit_states.get('rustdesk:is-active', false) | bool) + else ('not-installed' if remote_rustdesk_pkg.rc | default(1) != 0 else 'not-ready') }} + bf_ch_meshcentral: >- + {{ 'not-configured' if not (remote_channel_files_by_path[remote_mesh_config].exists | default(false)) + else ('configured-active' + if (bf_unit_states.get('meshagent:is-enabled', false) | bool and bf_unit_states.get('meshagent:is-active', false) | bool) + else 'not-ready') }} + bf_ch_anydesk: "{{ 'installed (optional)' if remote_anydesk_pkg.rc | default(1) == 0 else 'NOT INSTALLED (OPTIONAL)' }}" + bf_ch_rustdesk_config: >- + {{ 'configured' if (remote_channel_files_by_path[remote_rustdesk_system_config].exists | default(false) + and remote_channel_files_by_path[remote_rustdesk_user_config].exists | default(false)) + else 'not-configured' }} + bf_ch_canonical_ready: "{{ 'ALL-READY' if remote_status_cli.rc | default(1) == 0 else 'NOT-READY' }}" + + - name: WireGuard handshake epoch listesini çıkar (read-only kanıt) + ansible.builtin.set_fact: + bf_wg_epochs: "{{ remote_wg_handshakes.stdout | default('') | regex_findall('(?m)([0-9]+)$') | map('int') | list }}" + + - name: WireGuard en yeni handshake kaydını belirle + ansible.builtin.set_fact: + bf_wg_latest_epoch: "{{ (bf_wg_epochs | max | int) if bf_wg_epochs | length > 0 else 0 }}" + + - name: WireGuard handshake yaşını hesapla + ansible.builtin.set_fact: + bf_wg_handshake_age: "{{ (remote_now_epoch.stdout | default('0') | int) - (bf_wg_latest_epoch | int) }}" + + - name: WireGuard kanal hükmünü yaz + ansible.builtin.set_fact: + bf_ch_wireguard: >- + {{ 'no-handshake' if bf_wg_latest_epoch | int <= 0 + else ('fresh-handshake' if bf_wg_handshake_age | int <= remote_wg_handshake_max_age | int else 'stale-handshake') }} + + - name: Zorunlu kanal hazır/ready satırını yaz + ansible.builtin.set_fact: + bf_required_channel_line: >- + ssh:{{ 'READY' if bf_ch_ssh == 'enabled-active' else 'NOT-READY' }} + xrdp:{{ 'READY' if bf_ch_xrdp == 'enabled-active' else 'NOT-READY' }} + rustdesk:{{ 'READY' if bf_ch_rustdesk == 'installed-active' else 'NOT-READY' }} + wireguard:{{ 'READY' if bf_ch_wireguard == 'fresh-handshake' else 'NOT-READY' }} + + - name: Zorunlu kanalların hazır durumunu ve sayısını belirle + ansible.builtin.set_fact: + bf_required_channel_ready: "{{ [bf_ch_ssh == 'enabled-active', bf_ch_xrdp == 'enabled-active', bf_ch_rustdesk == 'installed-active', bf_ch_wireguard == 'fresh-handshake'] | select | list | length }}" + + - name: Kanal satırlarını raporla + ansible.builtin.debug: + msg: + - "{{ bf_device_label }} ssh={{ bf_ch_ssh }} xrdp={{ bf_ch_xrdp }} rustdesk={{ bf_ch_rustdesk }} wireguard={{ bf_ch_wireguard }}" + - "{{ bf_device_label }} meshcentral={{ bf_ch_meshcentral }} (opsiyonel) anydesk={{ bf_ch_anydesk }} rustdesk_config={{ bf_ch_rustdesk_config }}" + - "{{ bf_device_label }} hazır zorunlu kanal={{ bf_required_channel_ready }}/4 kanonik_bf_remote_status={{ bf_ch_canonical_ready }}" + vars: + bf_device_label: "{{ inventory_hostname | upper }}" + + - name: Kanonik doğrulama çıktısını yaz (bağımsız kanıt) + ansible.builtin.debug: + msg: "bf-remote-status --check rc={{ remote_status_cli.rc | default(1) }} cikti={{ remote_status_cli.stdout_lines | default([]) }}" + when: remote_channel_files_by_path[remote_status_bin].exists | default(false) + + - name: Hazır olmayan kanalı işaretle (opsiyoneller hariç) + ansible.builtin.debug: + msg: "UYARI {{ inventory_hostname | upper }}: kanal hazır değil -> {{ bf_not_ready_channels }}" + vars: + bf_not_ready_channels: >- + {{ (['ssh'] if bf_ch_ssh != 'enabled-active' else []) + + (['xrdp'] if bf_ch_xrdp != 'enabled-active' else []) + + (['rustdesk'] if bf_ch_rustdesk != 'installed-active' else []) + + (['wireguard'] if bf_ch_wireguard != 'fresh-handshake' else []) }} + when: bf_not_ready_channels | length > 0 + + - name: MeshCentral (opsiyonel) kanalının kapalı olduğunu bilgi olarak yaz + ansible.builtin.debug: + msg: "BİLGİ {{ inventory_hostname | upper }}: MeshCentral durumu={{ bf_ch_meshcentral }}; MeshCentral/AnyDesk opsiyoneldir, READY kapısının parçası değildir." + when: bf_ch_meshcentral != 'configured-active' + + - name: Kanal başına hazır cihaz sayısını hesapla + ansible.builtin.set_fact: + bf_channel_ready_counts: "{{ bf_channel_ready_counts | default({}) | combine({ item: bf_channel_lines | select('search', item ~ ':READY') | list | length }) }}" + loop: [ssh, xrdp, rustdesk, wireguard] + vars: + bf_channel_lines: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_required_channel_line') | map('default', '', true) | list }}" + run_once: true + + - name: Dalga kanal özetini yaz + ansible.builtin.debug: + msg: + - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" + - "Hazır kanal başına cihaz sayısı: {{ bf_channel_ready_counts | default({}) }}" + - "İki kanaldan az erişimi olan cihazlar: {{ bf_single_channel_hosts }}" + vars: + bf_single_channel_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_required_channel_ready') | map('default', 0) | map('int') | list)) | dict2items | selectattr('value', 'lt', 2) | map(attribute='key') | list }}" + run_once: true + + - name: Kanonik doğrulamada hazır olmayan cihazların dalga listesini yaz + ansible.builtin.debug: + msg: "bf-remote-status --check başarısız (merkez kanıtı dahil): {{ bf_canonical_not_ready_hosts }}" + vars: + bf_canonical_not_ready_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_ch_canonical_ready') | map('default', 'NOT-READY', true) | list)) | dict2items | selectattr('value', 'equalto', 'NOT-READY') | map(attribute='key') | list }}" + run_once: true diff --git a/ansible/playbooks/bf-run-script.yml b/ansible/playbooks/bf-run-script.yml new file mode 100644 index 0000000..3bdfc08 --- /dev/null +++ b/ansible/playbooks/bf-run-script.yml @@ -0,0 +1,76 @@ +--- +# Run an approved script already present on a confirmed maintenance wave. +- name: Run operator script + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + script_path: "" + script_args: "" + script_allowed_dirs: + - /opt/blueforce/bin + - /usr/local/sbin + script_allowlist: [] + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Canonicalize requested script path + ansible.builtin.command: + argv: [readlink, -f, --, "{{ script_path }}"] + register: canonical_script + changed_when: false + failed_when: canonical_script.rc != 0 + + - name: Canonicalize approved directory prefixes + ansible.builtin.command: + argv: [readlink, -f, --, "{{ item }}"] + loop: "{{ script_allowed_dirs }}" + register: canonical_dirs + changed_when: false + + - name: Validate exact approved path boundary or explicit allowlist + ansible.builtin.assert: + that: + - canonical_script.stdout is match('^/.+') + - canonical_script.stdout in script_allowlist or (canonical_dirs.results | map(attribute='stdout') | select('match', '^/.+') | map('regex_replace', '$', '/') | select('in', canonical_script.stdout + '/') | list | length > 0) + fail_msg: "Refused root execution of '{{ script_path }}': canonical path '{{ canonical_script.stdout }}' is not in an approved directory or explicit allowlist." + + - name: Check canonical script is a regular executable file + ansible.builtin.stat: + path: "{{ canonical_script.stdout }}" + register: script_stat + + - name: Validate canonical script metadata + ansible.builtin.assert: + that: + - script_stat.stat.exists + - script_stat.stat.isreg + - script_stat.stat.executable + + - name: Execute approved canonical script + ansible.builtin.command: + argv: "{{ [canonical_script.stdout] + (script_args | default('') | split()) }}" + register: script_out + changed_when: true + + - name: Show script output + ansible.builtin.debug: + msg: "{{ inventory_hostname }} exit={{ script_out.rc }}\n{{ script_out.stdout | default('') }}{{ script_out.stderr | default('') }}" \ No newline at end of file diff --git a/ansible/playbooks/bf-rustdesk-restart.yml b/ansible/playbooks/bf-rustdesk-restart.yml new file mode 100644 index 0000000..caae229 --- /dev/null +++ b/ansible/playbooks/bf-rustdesk-restart.yml @@ -0,0 +1,25 @@ +--- +# Restart the RustDesk remote-access service. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rustdesk-restart.yml --limit lab +# Service name is overridable via rustdesk_service_name. +# No Semaphore Pro feature is used. +- name: Restart RustDesk service + hosts: blueforce + gather_facts: false + become: true + vars: + rustdesk_restart_service: "{{ rustdesk_service_name | default('rustdesk') }}" + tasks: + - name: Restart RustDesk + ansible.builtin.systemd: + name: "{{ rustdesk_restart_service }}" + state: restarted + + - name: Verify RustDesk is active + ansible.builtin.command: "systemctl is-active {{ rustdesk_restart_service }}" + register: rustdesk_state + changed_when: false + + - name: Show RustDesk state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} rustdesk={{ rustdesk_state.stdout }}" diff --git a/ansible/playbooks/bf-security-updates-check.yml b/ansible/playbooks/bf-security-updates-check.yml new file mode 100644 index 0000000..530f776 --- /dev/null +++ b/ansible/playbooks/bf-security-updates-check.yml @@ -0,0 +1,32 @@ +--- +# Report pending security updates only. NEVER installs anything. +# Policy docs/10: unattended upgrades are disabled; every change needs an approval wave. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-security-updates-check.yml --limit lab +# No Semaphore Pro feature is used. +- name: Report pending security updates (no install) + hosts: blueforce + gather_facts: false + become: true + tasks: + - name: Refresh apt metadata (cache only, no upgrade) + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: List upgradable packages + ansible.builtin.shell: "apt list --upgradable 2>/dev/null" + register: upgradable + changed_when: false + + - name: List security updates only + ansible.builtin.shell: "apt-get -s dist-upgrade -V | grep -i security || true" + register: sec_updates + changed_when: false + + - name: Show report + ansible.builtin.debug: + msg: "{{ inventory_hostname }} upgradable:\n{{ upgradable.stdout }}\nsecurity:\n{{ sec_updates.stdout | default('(none)') }}" + + - name: Remind operator that install is out of scope + ansible.builtin.debug: + msg: "REPORT ONLY - use bf-deploy-update.yml with approval waves to install anything." diff --git a/ansible/playbooks/bf-service-restart.yml b/ansible/playbooks/bf-service-restart.yml new file mode 100644 index 0000000..4f4322e --- /dev/null +++ b/ansible/playbooks/bf-service-restart.yml @@ -0,0 +1,35 @@ +--- +# Restart a single service by name (parametric, allowlisted). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-service-restart.yml --limit lab -e service_name=blueforce-agent +# Only names in service_allowlist are accepted; everything else fails closed. +# No Semaphore Pro feature is used. +- name: Restart allowlisted service + hosts: blueforce + gather_facts: false + become: true + vars: + service_name: "" + service_allowlist: + - blueforce-agent + - docker + - sshd + - ssh + - systemd-journald + - cron + tasks: + - name: Validate service_name is provided + ansible.builtin.assert: + that: + - service_name | length > 0 + fail_msg: "service_name is required, e.g. -e service_name=blueforce-agent" + + - name: Validate service_name is allowlisted + ansible.builtin.assert: + that: + - service_name in service_allowlist + fail_msg: "service_name '{{ service_name }}' is not in the allowlist. Refusing to restart arbitrary services." + + - name: Restart service + ansible.builtin.systemd: + name: "{{ service_name }}" + state: restarted diff --git a/ansible/playbooks/bf-uptime.yml b/ansible/playbooks/bf-uptime.yml new file mode 100644 index 0000000..74cfe37 --- /dev/null +++ b/ansible/playbooks/bf-uptime.yml @@ -0,0 +1,16 @@ +--- +# Collect uptime from fleet hosts (read-only). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-uptime.yml --limit pilot_1 +# No changes are made by this playbook. No Semaphore Pro feature is used. +- name: Report host uptime + hosts: blueforce + gather_facts: false + tasks: + - name: Get uptime + ansible.builtin.command: uptime + register: uptime_out + changed_when: false + + - name: Show uptime + ansible.builtin.debug: + msg: "{{ inventory_hostname }}: {{ uptime_out.stdout }}" diff --git a/ansible/playbooks/bf-wireguard-restart.yml b/ansible/playbooks/bf-wireguard-restart.yml new file mode 100644 index 0000000..45561e1 --- /dev/null +++ b/ansible/playbooks/bf-wireguard-restart.yml @@ -0,0 +1,25 @@ +--- +# Restart the WireGuard tunnel interface. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-wireguard-restart.yml --limit lab +# Uses wg_interface from group_vars and defaults to wg0. +# No Semaphore Pro feature is used. +- name: Restart WireGuard tunnel + hosts: blueforce + gather_facts: false + become: true + vars: + wireguard_restart_interface: "{{ wg_interface | default('wg0') }}" + tasks: + - name: Restart WireGuard service + ansible.builtin.systemd: + name: "wg-quick@{{ wireguard_restart_interface }}" + state: restarted + + - name: Verify tunnel state + ansible.builtin.command: "wg show {{ wireguard_restart_interface }}" + register: wg_out + changed_when: false + + - name: Show WireGuard state + ansible.builtin.debug: + msg: "{{ inventory_hostname }}: {{ wg_out.stdout_lines | default([]) }}" diff --git a/ansible/roles/common/README.md b/ansible/roles/common/README.md new file mode 100644 index 0000000..255e83b --- /dev/null +++ b/ansible/roles/common/README.md @@ -0,0 +1,20 @@ +# common role + +Baseline for every Blueforce host: base packages, timezone, and the docs/10 +requirement that `unattended-upgrades` stays OFF with `apt-daily*.timer` masked. + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `common_timezone` | `Europe/Istanbul` | System timezone | +| `common_admin_user` | `blueforce` | Admin login (SSH key managed per docs/06) | + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - common +``` diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000..fe8a27b --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,4 @@ +--- +# Defaults for the common role. Override in group_vars/host_vars, never hardcode secrets here. +common_timezone: Europe/Istanbul +common_admin_user: blueforce diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..b646c03 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,37 @@ +--- +# Common baseline: users, time, unattended-upgrades OFF (docs/10), base packages. +# No Semaphore Pro feature is used. +- name: Ensure base packages are present + ansible.builtin.apt: + name: + - openssh-server + - python3 + - curl + - htop + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Disable automatic upgrades (policy docs/10) + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + content: | + APT::Periodic::Update-Package-Lists "0"; + APT::Periodic::Unattended-Upgrade "0"; + +- name: Mask apt-daily timers (policy docs/10) + ansible.builtin.systemd: + name: "{{ item }}" + masked: true + state: stopped + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + failed_when: false + +- name: Set timezone + ansible.builtin.timezone: + name: "{{ common_timezone }}" diff --git a/ansible/roles/docker/README.md b/ansible/roles/docker/README.md new file mode 100644 index 0000000..1afa5b9 --- /dev/null +++ b/ansible/roles/docker/README.md @@ -0,0 +1,22 @@ +# docker role + +Installs the pinned Docker engine, enables the daemon, and reports the MEG +container state. Image and package tags are always pinned; `latest` and +Watchtower-style auto-updaters are forbidden (docs/10, docs/12). + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `docker_packages` | pinned `docker-ce=...` | Exact engine packages to install | +| `meg_container_name` | `meg` | Container name to report on | +| `meg_image_tag` | `1.0.0` | Pinned MEG image tag | + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - docker +``` diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000..6782a95 --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,12 @@ +--- +# Docker is installed only when an operator supplies a verified repository and exact version. +# Supported Ubuntu releases include 24.04 noble and 26.04 resolute; no release is hard-coded. +docker_supported_codenames: + - noble + - resolute +docker_apt_codename: "{{ ansible_distribution_release | default('') }}" +docker_apt_repository: "" # Required: approved, already key-verified Docker APT repository. +docker_engine_version: "" # Required exact version, e.g. 5:29.0.0-1~ubuntu.26.04~resolute. +docker_packages: [] +meg_container_name: meg +meg_image_tag: 1.0.0 \ No newline at end of file diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..01d5836 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,50 @@ +--- +# Docker engine setup with explicit, approved repository and version only. +- name: Refuse unsupported Ubuntu codename or unapproved Docker inputs + ansible.builtin.assert: + that: + - docker_apt_codename in docker_supported_codenames + - docker_apt_repository | length > 0 + - docker_engine_version | length > 0 + - docker_engine_version is not search('latest') + - docker_packages | length > 0 + - docker_packages | select('search', '(^|=).*latest($|[^[:alnum:]])') | list | length == 0 + fail_msg: >- + Docker installation is refused until this host has a supported codename and an explicitly approved + repository, exact engine version, and exact package list. No automatic Docker update is permitted. + +- name: Verify approved Docker repository is configured + ansible.builtin.command: "apt-cache policy docker-ce" + register: docker_policy + changed_when: false + failed_when: docker_apt_repository not in docker_policy.stdout + +- name: Verify requested Docker version is available from approved repository + ansible.builtin.command: "apt-cache madison docker-ce" + register: docker_versions + changed_when: false + failed_when: docker_engine_version not in docker_versions.stdout + +- name: Install only the approved exact Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: false + +- name: Enable and start Docker daemon + ansible.builtin.systemd: + name: docker + enabled: true + state: started + +- name: Ensure MEG container is running at pinned tag + ansible.builtin.command: "docker ps --filter name={{ meg_container_name }} --format '{{{{.Names}}}}'" + register: meg_ps + changed_when: false + failed_when: false + when: meg_container_name | length > 0 + +- name: Report MEG container state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} meg={{ meg_ps.stdout | default('(skipped)') }}" + when: meg_container_name | length > 0 \ No newline at end of file diff --git a/ansible/roles/monitoring/README.md b/ansible/roles/monitoring/README.md new file mode 100644 index 0000000..bd42bf5 --- /dev/null +++ b/ansible/roles/monitoring/README.md @@ -0,0 +1,22 @@ +# monitoring role + +Installs and starts the node exporter and verifies the docs/10 requirement +that automatic upgrades stay disabled. + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `monitoring_packages` | `prometheus-node-exporter` | Packages to install | +| `monitoring_service` | `prometheus-node-exporter` | Service to enable | +| `monitoring_endpoint` | `http://monitoring.example.com:8428` | Metrics endpoint (placeholder) | +| `monitoring_job` | `blueforce` | Job label | + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - monitoring +``` diff --git a/ansible/roles/monitoring/defaults/main.yml b/ansible/roles/monitoring/defaults/main.yml new file mode 100644 index 0000000..a8ab613 --- /dev/null +++ b/ansible/roles/monitoring/defaults/main.yml @@ -0,0 +1,7 @@ +--- +# Defaults for the monitoring role. +monitoring_packages: + - prometheus-node-exporter +monitoring_service: prometheus-node-exporter +monitoring_endpoint: http://monitoring.example.com:8428 +monitoring_job: blueforce diff --git a/ansible/roles/monitoring/tasks/main.yml b/ansible/roles/monitoring/tasks/main.yml new file mode 100644 index 0000000..d3954ef --- /dev/null +++ b/ansible/roles/monitoring/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# Monitoring baseline: node exporter install + health signal files. +# No Semaphore Pro feature is used. +- name: Ensure monitoring packages are present + ansible.builtin.apt: + name: "{{ monitoring_packages }}" + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Enable and start node exporter + ansible.builtin.systemd: + name: "{{ monitoring_service }}" + enabled: true + state: started + +- name: Verify unattended-upgrades stay disabled (policy docs/10) + ansible.builtin.shell: 'grep -E ''Unattended-Upgrade "0"'' /etc/apt/apt.conf.d/20auto-upgrades' + register: unattended_check + changed_when: false + failed_when: false + +- name: Report monitoring state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} exporter={{ monitoring_service }} unattended_off={{ unattended_check.rc == 0 }}" diff --git a/ansible/roles/wireguard/README.md b/ansible/roles/wireguard/README.md new file mode 100644 index 0000000..3e36482 --- /dev/null +++ b/ansible/roles/wireguard/README.md @@ -0,0 +1,32 @@ +# wireguard role + +Installs WireGuard, writes `/etc/wireguard/.conf` from variables, and +starts `wg-quick@`. Private keys are secrets: supply them through the +Semaphore Community encrypted Key Store or another controller-side secret +source; never commit them to Git. + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `wg_interface` | `wg0` | Interface name | +| `wg_address` | _(empty)_ | Required client tunnel address, e.g. `10.42.3.11/16` | +| `wg_dns` | _(empty)_ | Optional DNS | +| `wg_private_key` | _(empty)_ | Required client private key (secret) | +| `wg_hub_public_key` | _(empty)_ | Required hub public key | +| `wg_hub_endpoint` | `vpn.example.com:51820` | Required hub endpoint | +| `wg_allowed_ips` | `10.42.0.0/16` | Required allowed IPs | +| `wg_keepalive` | `25` | Persistent keepalive seconds | + +The role fails closed when any required tunnel value is empty. + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - role: wireguard + vars: + wg_address: 10.42.3.11/16 +``` diff --git a/ansible/roles/wireguard/defaults/main.yml b/ansible/roles/wireguard/defaults/main.yml new file mode 100644 index 0000000..8fffed7 --- /dev/null +++ b/ansible/roles/wireguard/defaults/main.yml @@ -0,0 +1,10 @@ +--- +# Defaults for the wireguard role. Secrets MUST be overridden via Key Store / vault. +wg_interface: wg0 +wg_address: "" +wg_dns: "" +wg_private_key: "" +wg_hub_public_key: "" +wg_hub_endpoint: "" +wg_allowed_ips: "10.42.0.0/16" +wg_keepalive: 25 diff --git a/ansible/roles/wireguard/handlers/main.yml b/ansible/roles/wireguard/handlers/main.yml new file mode 100644 index 0000000..fb0292c --- /dev/null +++ b/ansible/roles/wireguard/handlers/main.yml @@ -0,0 +1,6 @@ +--- +# Handlers for the wireguard role. +- name: Restart WireGuard + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + state: restarted diff --git a/ansible/roles/wireguard/tasks/main.yml b/ansible/roles/wireguard/tasks/main.yml new file mode 100644 index 0000000..94575c4 --- /dev/null +++ b/ansible/roles/wireguard/tasks/main.yml @@ -0,0 +1,48 @@ +--- +# WireGuard client setup: package, config from template variables, tunnel up. +# Private keys are supplied through the Semaphore Community Key Store, never committed. +# No Semaphore Pro feature is used. +- name: Validate required WireGuard configuration + ansible.builtin.assert: + that: + - wg_address | length > 0 + - wg_private_key | length > 0 + - wg_hub_public_key | length > 0 + - wg_hub_endpoint | length > 0 + - wg_allowed_ips | length > 0 + fail_msg: WireGuard address, keys, hub endpoint, and allowed IPs are required. + no_log: true + +- name: Ensure WireGuard package is present + ansible.builtin.apt: + name: wireguard + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Write WireGuard client config + ansible.builtin.copy: + dest: "/etc/wireguard/{{ wg_interface }}.conf" + owner: root + group: root + mode: "0600" + content: | + [Interface] + PrivateKey = {{ wg_private_key }} + Address = {{ wg_address }} + {% if wg_dns | default('') | length > 0 %} + DNS = {{ wg_dns }} + {% endif %} + [Peer] + PublicKey = {{ wg_hub_public_key }} + Endpoint = {{ wg_hub_endpoint }} + AllowedIPs = {{ wg_allowed_ips }} + PersistentKeepalive = {{ wg_keepalive }} + no_log: true + notify: Restart WireGuard + +- name: Enable and start WireGuard tunnel + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + enabled: true + state: started diff --git a/config/docker/daemon.json b/config/docker/daemon.json new file mode 100644 index 0000000..dcd4ee6 --- /dev/null +++ b/config/docker/daemon.json @@ -0,0 +1,14 @@ +{ + "_note": [ + "Purpose: Cap Docker log disk use and keep containers alive across daemon restarts.", + "Docker manages firewall rules; the installer applies the mandatory DOCKER-USER deny policy.", + "Published application ports require explicit reviewed rules in /etc/blueforce/docker-published-port-allowlist.conf." + ], + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "live-restore": true, + "iptables": true +} \ No newline at end of file diff --git a/config/firewall/blueforce-docker-user-restore.sh b/config/firewall/blueforce-docker-user-restore.sh new file mode 100755 index 0000000..a5aeea1 --- /dev/null +++ b/config/firewall/blueforce-docker-user-restore.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Apply Blueforce's reviewed Docker published-port policy to DOCKER-USER. +set -euo pipefail + +ALLOWLIST="${DOCKER_ALLOWLIST:-/etc/blueforce/docker-published-port-allowlist.conf}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 + +fail() { printf 'blueforce-docker-firewall: %s\n' "$*" >&2; exit 1; } +parse_allowlist() { + local callback="$1" raw line protocol port extra + [[ -r "$ALLOWLIST" ]] || fail "allowlist is missing or unreadable: $ALLOWLIST" + while IFS= read -r raw || [[ -n "$raw" ]]; do + line="${raw%%#*}" + line="$(printf '%s' "$line" | xargs)" + [[ -z "$line" ]] && continue + read -r protocol port extra <<< "$line" + [[ -z "${extra:-}" && "$protocol" =~ ^(tcp|udp)$ && "$port" =~ ^[0-9]{1,5}$ ]] || fail "invalid allowlist entry '$raw'; use 'tcp PORT' or 'udp PORT'" + ((10#$port >= 1 && 10#$port <= 65535)) || fail "port out of range in '$raw'" + "$callback" "$protocol" "$port" + done < "$ALLOWLIST" +} + +validate_allowlist() { parse_allowlist :; } +install_allow_rule() { + local protocol="$1" port="$2" + # Docker DNAT precedes DOCKER-USER; match the original reviewed published port. + iptables -I DOCKER-USER -p "$protocol" -m conntrack --ctorigdstport "$port" -j ACCEPT +} +verify_allow_rule() { + local protocol="$1" port="$2" + iptables -C DOCKER-USER -p "$protocol" -m conntrack --ctorigdstport "$port" -j ACCEPT || fail "allowlist rule missing: $protocol $port" +} + +validate_allowlist +command -v iptables >/dev/null 2>&1 || fail "iptables is required" +iptables -S DOCKER-USER >/dev/null 2>&1 || fail "DOCKER-USER chain is unavailable; start Docker first" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + iptables -C DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT || fail "established/related rule missing" + iptables -C DOCKER-USER -i docker0 -j RETURN || fail "docker0 outbound return rule missing" + iptables -C DOCKER-USER -i br+ -j RETURN || fail "user-defined bridge outbound return rule missing" + parse_allowlist verify_allow_rule + iptables -C DOCKER-USER -j DROP || fail "default published-port deny rule missing" + exit 0 +fi + +# Blueforce owns DOCKER-USER so an old permissive rule cannot bypass the deny. +iptables -F DOCKER-USER +iptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +# Container-to-Internet packets enter FORWARD from Docker bridges; inbound published-port +# packets enter from an external interface, so these returns do not bypass the deny below. +iptables -A DOCKER-USER -i docker0 -j RETURN +iptables -A DOCKER-USER -i br+ -j RETURN +# Insert every parsed allowlist exception before the final default drop. +parse_allowlist install_allow_rule +iptables -A DOCKER-USER -j DROP diff --git a/config/firewall/ufw-blueforce.sh b/config/firewall/ufw-blueforce.sh new file mode 100755 index 0000000..58c2dbd --- /dev/null +++ b/config/firewall/ufw-blueforce.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Apply field-device UFW baseline. SSH/RDP are accepted only on wg0. +set -euo pipefail +WG_IF="wg0" +WG_PORT="51820" +DOCKER_ALLOWLIST="/etc/blueforce/docker-published-port-allowlist.conf" +DOCKER_POLICY_BIN="/usr/local/sbin/blueforce-docker-firewall" +DOCKER_POLICY_UNIT="blueforce-docker-firewall.service" +rule_exists() { ufw status | grep -Fq "$1"; } +ensure_rule() { local match="$1"; shift; rule_exists "$match" && echo "keep: $match" || ufw "$@"; } + +install_docker_policy() { + local root + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + install -d -m 755 /etc/blueforce /etc/iptables + if [[ ! -f "$DOCKER_ALLOWLIST" ]]; then + printf '%s\n' '# Explicit reviewed Docker published-port exceptions: tcp PORT or udp PORT.' '# Default: deny every externally published Docker port.' > "$DOCKER_ALLOWLIST" + fi + chown root:root "$DOCKER_ALLOWLIST" + chmod 640 "$DOCKER_ALLOWLIST" + install -o root -g root -m 700 "$root/config/firewall/blueforce-docker-user-restore.sh" "$DOCKER_POLICY_BIN" + install -o root -g root -m 644 "$root/config/systemd/blueforce-docker-firewall.service" "/etc/systemd/system/$DOCKER_POLICY_UNIT" + systemctl daemon-reload + systemctl enable "$DOCKER_POLICY_UNIT" + systemctl start docker + "$DOCKER_POLICY_BIN" +} +cmd_apply() { + ufw default deny incoming + ufw default allow outgoing + ensure_rule "${WG_PORT}/udp" allow "${WG_PORT}/udp" comment 'WireGuard hub' + ensure_rule "22/tcp on ${WG_IF}" allow in on "${WG_IF}" to any port 22 proto tcp comment 'SSH over WireGuard' + ensure_rule "3389/tcp on ${WG_IF}" allow in on "${WG_IF}" to any port 3389 proto tcp comment 'RDP over WireGuard' + install_docker_policy + ufw --force enable + ufw status verbose +} +cmd_reset() { ufw --force reset; ufw default deny incoming; ufw default allow outgoing; ufw --force enable; ufw status verbose; } +cmd_status() { ufw status verbose; "$DOCKER_POLICY_BIN" --check; iptables -S DOCKER-USER; } +case "${1:-apply}" in apply) cmd_apply ;; reset) cmd_reset ;; status) cmd_status ;; *) echo "Usage: $0 {apply|reset|status}" >&2; exit 1 ;; esac \ No newline at end of file diff --git a/config/ssh/sshd_config.d/blueforce.conf b/config/ssh/sshd_config.d/blueforce.conf new file mode 100644 index 0000000..0d27242 --- /dev/null +++ b/config/ssh/sshd_config.d/blueforce.conf @@ -0,0 +1,23 @@ +# Purpose: Harden field-device SSH to key-only maintenance access. +# Scope: Applies to the blueforce user; root and password logins stay disabled. +# Safety: Drop-in for sshd_config.d; validate with sshd -t before reload. +# Ref: docs/06-USERS-SSH-AND-PERMISSIONS.md, K-13. + +# No direct root login; privilege is gained only via audited sudo. +PermitRootLogin no + +# Key-only auth: passwords and interactive challenges are disabled. +PasswordAuthentication no +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes + +# Only the maintenance user may open SSH sessions. +AllowUsers blueforce + +# Reduce idle-session and brute-force exposure. +X11Forwarding no +MaxAuthTries 3 +LoginGraceTime 60 +ClientAliveInterval 300 +ClientAliveCountMax 2 diff --git a/config/systemd/bf-gui-off.service b/config/systemd/bf-gui-off.service new file mode 100644 index 0000000..2450ca4 --- /dev/null +++ b/config/systemd/bf-gui-off.service @@ -0,0 +1,10 @@ +# Purpose: Manually return a field device to terminal mode after maintenance. +# This unit is deliberately not enabled by any boot target. +[Unit] +Description=Blueforce GUI off (manual terminal maintenance switch) +After=graphical.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bf-gui-off +RemainAfterExit=no diff --git a/config/systemd/bf-gui-on.service b/config/systemd/bf-gui-on.service new file mode 100644 index 0000000..7b989e1 --- /dev/null +++ b/config/systemd/bf-gui-on.service @@ -0,0 +1,10 @@ +# Purpose: Manually switch a field device to graphical mode for maintenance. +# This unit is deliberately not enabled by any boot target. +[Unit] +Description=Blueforce GUI on (manual graphical maintenance switch) +After=multi-user.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bf-gui-on +RemainAfterExit=no diff --git a/config/systemd/blueforce-docker-firewall.service b/config/systemd/blueforce-docker-firewall.service new file mode 100644 index 0000000..262d3fa --- /dev/null +++ b/config/systemd/blueforce-docker-firewall.service @@ -0,0 +1,13 @@ +[Unit] +Description=Blueforce persistent Docker published-port firewall +BindsTo=docker.service +PartOf=docker.service +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/blueforce-docker-firewall +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/config/systemd/blueforce-enroll.service b/config/systemd/blueforce-enroll.service new file mode 100644 index 0000000..ff4d2bb --- /dev/null +++ b/config/systemd/blueforce-enroll.service @@ -0,0 +1,45 @@ +# Blueforce enrollment controlled attempt: PROVISIONED_OFFLINE -> ENROLLED. +# +# Behaviour (docs/29-DEVICE-ENROLLMENT.md, provisioning/enrollment/README.md): +# * One bounded attempt per start. The unit never reboots the device and never +# loops: a device without internet or without a staged token prints PENDING, +# exits 0 and stays PROVISIONED_OFFLINE. +# * When a one-time token has been staged (bf-enroll --token-stdin --stage, +# mode 0600, TTL bounded) the attempt enrolls by itself as soon as central is +# reachable. RestartSec plus the StartLimit below cap that at six attempts +# per hour and then leave the cadence to blueforce-enroll.timer, so a device +# that stays offline for weeks is never hammered. +# * No credential lives in this unit: bf-enroll reads the staged root-only +# token once and deletes it on success or on a definitive rejection. +# * Restart= is on-failure only, so a successful run, a READY device and an +# unprovisioned device are never retried. +# * Trigger: blueforce-enroll.timer (OnBootSec=10min, then daily). An operator +# can also run `bf-enroll-now` interactively at any time. +[Unit] +Description=Blueforce enrollment controlled attempt (bounded retries, never reboots) +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=1h +StartLimitBurst=6 + +[Service] +Type=exec +# Central control plane endpoint and timing, delivered by central configuration. +# Optional on purpose: the attempt reports PENDING when it is not configured yet. +EnvironmentFile=-/etc/blueforce/enrollment.env +ExecStart=/usr/local/bin/bf-enroll --systemd +# Bounded retry: enough to join automatically when connectivity returns, never +# aggressive while the device is offline (systemd holds the next attempt for +# RestartSec instead of spinning). +Restart=on-failure +RestartSec=5min +TimeoutStartSec=180 +User=root +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +# Writable paths only: lifecycle state plus staged token, WireGuard configuration +# (including the wg-quick enable symlink) and central configuration. +ReadWritePaths=/var/lib/blueforce /etc/wireguard /etc/blueforce /etc/systemd/system diff --git a/config/systemd/blueforce-enroll.timer b/config/systemd/blueforce-enroll.timer new file mode 100644 index 0000000..805cf91 --- /dev/null +++ b/config/systemd/blueforce-enroll.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Blueforce enrollment pending reminder + +[Timer] +OnBootSec=10min +OnUnitActiveSec=24h +Persistent=true +Unit=blueforce-enroll.service + +[Install] +WantedBy=timers.target diff --git a/config/systemd/docker.service.d/blueforce-docker-firewall.conf b/config/systemd/docker.service.d/blueforce-docker-firewall.conf new file mode 100644 index 0000000..258eb7c --- /dev/null +++ b/config/systemd/docker.service.d/blueforce-docker-firewall.conf @@ -0,0 +1,3 @@ +# Start the firewall after every Docker start; the firewall unit orders itself after Docker. +[Unit] +Wants=blueforce-docker-firewall.service diff --git a/config/systemd/journald-blueforce.conf b/config/systemd/journald-blueforce.conf new file mode 100644 index 0000000..613e251 --- /dev/null +++ b/config/systemd/journald-blueforce.conf @@ -0,0 +1,9 @@ +# Purpose: Bound journald disk use on field devices while keeping history. +# Scope: Persistent storage with caps; install to /etc/systemd/journald.conf.d/. +# Safety: Reboot-safe; apply with systemctl restart systemd-journald. +# Ref: docs/15-LOGGING.md, K-14. +[Journal] +Storage=persistent +SystemMaxUse=500M +RuntimeMaxUse=100M +MaxFileSec=14day diff --git a/config/systemd/logrotate-blueforce b/config/systemd/logrotate-blueforce new file mode 100644 index 0000000..13b6d50 --- /dev/null +++ b/config/systemd/logrotate-blueforce @@ -0,0 +1,20 @@ +# Purpose: Rotate MEG and Blueforce application logs to cap disk use. +# Scope: /var/log/blueforce and /var/log/meg file logs; install to /etc/logrotate.d/. +# Safety: Idempotent under logrotate; validate with logrotate -d before deploying. +# Ref: docs/15-LOGGING.md, K-14. +/var/log/blueforce/*.log /var/log/meg/*.log { + weekly + rotate 7 + size 10M + maxage 30 + compress + delaycompress + missingok + notifempty + create 0640 root adm + sharedscripts + postrotate + /usr/bin/systemctl kill --signal=SIGHUP rsyslog.service >/dev/null 2>&1 || true + endscript +} +# NOTE: install.log and update-history.log stay append-only and are excluded here. diff --git a/config/systemd/wg-quick@wg0.service.d/override.conf b/config/systemd/wg-quick@wg0.service.d/override.conf new file mode 100644 index 0000000..2fca6e0 --- /dev/null +++ b/config/systemd/wg-quick@wg0.service.d/override.conf @@ -0,0 +1,7 @@ +# Purpose: Keep the WireGuard tunnel up across failures without manual action. +# Scope: Drop-in for wg-quick@wg0; restarts the tunnel with a short backoff. +# Safety: Reboot-safe once the unit is enabled; confirm on the LAB 26.04.1 image. +# Ref: docs/08-WIREGUARD-AND-NETWORK.md and docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md, K-05. +[Service] +Restart=always +RestartSec=5 diff --git a/config/wireguard/README.md b/config/wireguard/README.md new file mode 100644 index 0000000..eebe80e --- /dev/null +++ b/config/wireguard/README.md @@ -0,0 +1,34 @@ + + + +# WireGuard spoke reference + +Ref: `docs/08-WIREGUARD-AND-NETWORK.md` (K-05), naming `K-12`. + +## Layout + +- Hub-spoke: the central server is the hub (`10.8.0.1`), each field device is a spoke. +- Peer name equals the device identity: `bf-<8digits>` (hostname, inventory, monitoring label). +- Each spoke gets one fixed tunnel IP (`10.8.0.x/32`) from inventory. + +## Filling the example + +1. Copy `wg0.conf.example` to `/etc/wireguard/wg0.conf` (mode `600`). +2. Replace `Address` with the device tunnel IP from inventory. +3. Replace `` with the device private key. +4. Replace `` and `` with hub values. +5. Keep `PersistentKeepalive = 25` (official reasonable default for NAT peers). +6. Keep `PostUp`/`PostDown`: they scope SSH (22) and RDP (3389) rules to `wg0`. + +## Bring-up + +```bash +sudo install -m 600 wg0.conf /etc/wireguard/wg0.conf +sudo systemctl enable --now wg-quick@wg0 +sudo wg show wg0 latest-handshakes +``` + +## Notes + +- Spokes behind CGNAT must always initiate outward; the hub only listens on `51820/udp`. +- Placeholders marked `` must never hold real keys in this repo. diff --git a/config/wireguard/wg0.conf.example b/config/wireguard/wg0.conf.example new file mode 100644 index 0000000..fa220a4 --- /dev/null +++ b/config/wireguard/wg0.conf.example @@ -0,0 +1,24 @@ +# Purpose: Reference spoke config for field devices joining the hub. +# Scope: Peer name bf- with fixed tunnel IP; hub values come from inventory. +# Safety: Example only; replace every placeholder before installing to /etc/wireguard/. +# Ref: docs/08-WIREGUARD-AND-NETWORK.md, K-05; peer naming K-12. + +[Interface] +# Tunnel address from inventory (one fixed /32 per device, hub is 10.8.0.1). +Address = 10.8.0.15/32 +# Device private key. with the real key; never commit it. +PrivateKey = +# Open management ports on the tunnel interface when it comes up. +PostUp = ufw allow in on wg0 to any port 22 proto tcp; ufw allow in on wg0 to any port 3389 proto tcp +# Remove tunnel-scoped management rules when the tunnel goes down. +PostDown = ufw delete allow in on wg0 to any port 22 proto tcp; ufw delete allow in on wg0 to any port 3389 proto tcp + +[Peer] +# Hub public key. with the real hub key. +PublicKey = +# Hub endpoint. with the real hub address. +Endpoint = :51820 +# Return traffic is limited to the tunnel subnet. +AllowedIPs = 10.8.0.0/16 +# Keep the NAT/CGNAT mapping alive behind mobile networks. +PersistentKeepalive = 25 diff --git a/docs-site/.gitignore b/docs-site/.gitignore new file mode 100644 index 0000000..c5dbc4b --- /dev/null +++ b/docs-site/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +build/ +.docusaurus/ +# Generated content (recreated by sync-docs.sh on prestart/prebuild). +docs/*.md +!docs/.gitkeep diff --git a/docs-site/README.md b/docs-site/README.md new file mode 100644 index 0000000..d53b04b --- /dev/null +++ b/docs-site/README.md @@ -0,0 +1,19 @@ +# Blueforce docs site (Docusaurus) + +Static documentation is synced one-way from `../docs/*.md` into `./docs/`; never edit generated `./docs/` directly. + +## Local development + +```bash +cd docs-site +npm install +npm start # http://127.0.0.1:3000 (loopback only) +npm run build +npm run serve # local static preview on 127.0.0.1:3000 +``` + +For an explicitly approved intranet preview, use `npm run start:intranet` or `npm run serve:intranet`; those commands bind `0.0.0.0:3000`. + +## Deploy + +Publish `build/` through the intranet web server. The site has no favicon reference until a maintained favicon asset is added. diff --git a/docs-site/babel.config.js b/docs-site/babel.config.js new file mode 100644 index 0000000..e00595d --- /dev/null +++ b/docs-site/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts new file mode 100644 index 0000000..bddafe9 --- /dev/null +++ b/docs-site/docusaurus.config.ts @@ -0,0 +1,30 @@ +import {themes as prismThemes} from 'prism-react-renderer'; +import type {Config} from '@docusaurus/types'; +import type * as Preset from '@docusaurus/preset-classic'; + +// Blueforce fleet documentation site. Content source: ../docs/*.md. +const config: Config = { + title: 'Blueforce Docs', + tagline: 'Fleet runbooks and architecture', + url: 'http://docs.blueforce.intranet', + baseUrl: '/', + onBrokenLinks: 'throw', + i18n: { defaultLocale: 'tr', locales: ['tr'] }, + markdown: { + mermaid: true, + hooks: { onBrokenMarkdownLinks: 'warn' }, + }, + themes: ['@docusaurus/theme-mermaid', '@easyops-cn/docusaurus-search-local'], + presets: [ + ['classic', { + docs: { path: 'docs', routeBasePath: '/', sidebarPath: './sidebars.js' }, + blog: false, + theme: { customCss: './src/css/custom.css' }, + } satisfies Preset.Options], + ], + themeConfig: { + colorMode: { defaultMode: 'dark', disableSwitch: false, respectPrefersColorScheme: false }, + navbar: { title: 'Blueforce Docs', items: [{ type: 'docSidebar', sidebarId: 'docs', position: 'left', label: 'Docs' }] }, + } satisfies Preset.ThemeConfig, +}; +export default config; \ No newline at end of file diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json new file mode 100644 index 0000000..3e53cb7 --- /dev/null +++ b/docs-site/package-lock.json @@ -0,0 +1,20121 @@ +{ + "name": "blueforce-docs", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "blueforce-docs", + "version": "0.1.0", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-mermaid": "3.10.2", + "@easyops-cn/docusaurus-search-local": "0.49.2", + "@mdx-js/react": "3.0.1", + "@mermaid-js/layout-elk": "0.1.9", + "clsx": "2.1.1", + "prism-react-renderer": "2.4.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "typescript": "5.5.4" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@11ty/gray-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@11ty/gray-matter/-/gray-matter-1.0.0.tgz", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=11" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.10.tgz", + "integrity": "sha512-K8uQ5iZCCMeqqE0JxjvcxJzDFuqZC1LTGJvR2P76ofDoOdKTs6jqoNxG5MnhzlTkNfE98VOQjcrg7qLxXPpT7g==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.10", + "@algolia/autocomplete-shared": "1.19.10" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.10.tgz", + "integrity": "sha512-zy/cYkMKEDdT4ns/SZC6by99qaIzLCTtckPw7cTe8BMPepwXjjQdhRyBCk2CJodMQc4Ktb0zJrt2YxTtMYa4Gg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.10" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.10.tgz", + "integrity": "sha512-vGJ2MDLgDOK3k256ks6t8u07bX4jdRQ0B/8MuRjjcvGTveVWwOU5lNd564uyuuqPMXQITl03HJx/juMtIFcQRw==", + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/events": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", + "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==", + "license": "MIT" + }, + "node_modules/@algolia/ingestion": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.29.7.tgz", + "integrity": "sha512-J0wGhKan+rIiE2OhfhRptySLrJ6SjQYM6b6N1FMlhyhCcw1Mig8vQjWchyB+bgHGDvaWo6Diu6CLRMra2uMtmg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz", + "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-react-display-name": "^7.29.7", + "@babel/plugin-transform-react-jsx": "^7.29.7", + "@babel/plugin-transform-react-jsx-development": "^7.29.7", + "@babel/plugin-transform-react-pure-annotations": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@csstools/cascade-layer-name-parser": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", + "integrity": "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/postcss-alpha-function": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-alpha-function/-/postcss-alpha-function-1.0.1.tgz", + "integrity": "sha512-isfLLwksH3yHkFXfCI2Gcaqg7wGGHZZwunoJzEZk0yKYIokgre6hYVFibKL3SYAoR1kBXova8LB+JoO5vZzi9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", + "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.12.tgz", + "integrity": "sha512-yx3cljQKRaSBc2hfh8rMZFZzChaFgwmO2JfFgFr1vMcF3C/uyy5I4RFIBOIWGq1D+XbKCG789CGkG6zzkLpagA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-function-display-p3-linear": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function-display-p3-linear/-/postcss-color-function-display-p3-linear-1.0.1.tgz", + "integrity": "sha512-E5qusdzhlmO1TztYzDIi8XPdPoYOjoTY6HBYBCYSj+Gn4gQRBlvjgPQXzfzuPQqt8EhkC/SzPKObg4Mbn8/xMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-function": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.12.tgz", + "integrity": "sha512-4STERZfCP5Jcs13P1U5pTvI9SkgLgfMUMhdXW8IlJWkzOOOqhZIjcNhWtNJZes2nkBDsIKJ0CJtFtuaZ00moag==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-1.0.2.tgz", + "integrity": "sha512-rM67Gp9lRAkTo+X31DUqMEq+iK+EFqsidfecmhrteErxJZb6tUoJBVQca1Vn1GpDql1s1rD1pKcuYzMsg7Z1KQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-content-alt-text": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.8.tgz", + "integrity": "sha512-9SfEW9QCxEpTlNMnpSqFaHyzsiRpZ5J5+KqCu1u5/eEJAWsMhzT40qf0FIbeeglEvrGRMdDzAxMIz3wqoGSb+Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-contrast-color-function": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-contrast-color-function/-/postcss-contrast-color-function-2.0.12.tgz", + "integrity": "sha512-YbwWckjK3qwKjeYz/CijgcS7WDUCtKTd8ShLztm3/i5dhh4NaqzsbYnhm4bjrpFpnLZ31jVcbK8YL77z3GBPzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-exponential-functions": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.9.tgz", + "integrity": "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-4.0.0.tgz", + "integrity": "sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-gamut-mapping": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.11.tgz", + "integrity": "sha512-fCpCUgZNE2piVJKC76zFsgVW1apF6dpYsqGyH8SIeCcM4pTEsRTWTLCaJIMKFEundsCKwY1rwfhtrio04RJ4Dw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-gradients-interpolation-method": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.12.tgz", + "integrity": "sha512-jugzjwkUY0wtNrZlFeyXzimUL3hN4xMvoPnIXxoZqxDvjZRiSh+itgHcVUWzJ2VwD/VAMEgCLvtaJHX+4Vj3Ow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.12.tgz", + "integrity": "sha512-mL/+88Z53KrE4JdePYFJAQWFrcADEqsLprExCM04GDNgHIztwFzj0Mbhd/yxMBngq0NIlz58VVxjt5abNs1VhA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.4.tgz", + "integrity": "sha512-yQ4VmossuOAql65sCPppVO1yfb7hDscf4GseF0VCA/DTDaBc0Wtf8MTqVPfjGYlT5+2buokG0Gp7y0atYZpwjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-initial": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-initial/-/postcss-initial-2.0.1.tgz", + "integrity": "sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz", + "integrity": "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-light-dark-function": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.11.tgz", + "integrity": "sha512-fNJcKXJdPM3Lyrbmgw2OBbaioU7yuKZtiXClf4sGdQttitijYlZMD5K7HrC/eF83VRWRrYq6OZ0Lx92leV2LFA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-float-and-clear": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-3.0.0.tgz", + "integrity": "sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-overflow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-2.0.0.tgz", + "integrity": "sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-overscroll-behavior": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-2.0.0.tgz", + "integrity": "sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-resize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-3.0.0.tgz", + "integrity": "sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-viewport-units": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.4.tgz", + "integrity": "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-minmax": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.9.tgz", + "integrity": "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.5.tgz", + "integrity": "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-4.0.0.tgz", + "integrity": "sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.1.tgz", + "integrity": "sha512-TQUGBuRvxdc7TgNSTevYqrL8oItxiwPDixk20qCB5me/W8uF7BPbhRrAvFuhEoywQp/woRsUZ6SJ+sU5idZAIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.12.tgz", + "integrity": "sha512-HhlSmnE1NKBhXsTnNGjxvhryKtO7tJd1w42DKOGFD6jSHtYOrsJTQDKPMwvOfrzUAk8t7GcpIfRyM7ssqHpFjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-position-area-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-position-area-property/-/postcss-position-area-property-1.0.0.tgz", + "integrity": "sha512-fUP6KR8qV2NuUZV3Cw8itx0Ep90aRjAZxAEzC3vrl6yjFv+pFsQbR18UuQctEKmA72K9O27CoYiKEgXxkqjg8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.2.1.tgz", + "integrity": "sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-property-rule-prelude-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-property-rule-prelude-list/-/postcss-property-rule-prelude-list-1.0.0.tgz", + "integrity": "sha512-IxuQjUXq19fobgmSSvUDO7fVwijDJaZMvWQugxfEUxmjBeDCVaDuMpsZ31MsTm5xbnhA+ElDi0+rQ7sQQGisFA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-random-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-2.0.1.tgz", + "integrity": "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-relative-color-syntax": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.12.tgz", + "integrity": "sha512-0RLIeONxu/mtxRtf3o41Lq2ghLimw0w9ByLWnnEVuy89exmEEq8bynveBxNW3nyHqLAFEeNtVEmC1QK9MZ8Huw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-4.0.1.tgz", + "integrity": "sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-sign-functions": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", + "integrity": "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.9.tgz", + "integrity": "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-syntax-descriptor-syntax-production": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-syntax-descriptor-syntax-production/-/postcss-syntax-descriptor-syntax-production-1.0.1.tgz", + "integrity": "sha512-GneqQWefjM//f4hJ/Kbox0C6f2T7+pi4/fqTqOFGTL3EjnvOReTqO1qUQ30CaUjkwjYq9qZ41hzarrAxCc4gow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-system-ui-font-family": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-system-ui-font-family/-/postcss-system-ui-font-family-1.0.0.tgz", + "integrity": "sha512-s3xdBvfWYfoPSBsikDXbuorcMG1nN1M6GdU0qBsGfcmNR0A/qhloQZpTxjA3Xsyrk1VJvwb2pOfiOT3at/DuIQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-4.0.3.tgz", + "integrity": "sha512-KSkGgZfx0kQjRIYnpsD7X2Om9BUXX/Kii77VBifQW9Ih929hK0KNjVngHDH0bFB9GmfWcR9vJYJJRvw/NQjkrA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.9.tgz", + "integrity": "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-4.0.0.tgz", + "integrity": "sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/utilities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-2.0.0.tgz", + "integrity": "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@docsearch/core": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.7.0.tgz", + "integrity": "sha512-p/9xVKmPDj3FPvMfPf5naVO3Ej8SCbcUugGvx1+8GgkuBNbqxqN2Irx3WLBv8VY0jH7XpRwKWdlmjXLZsmTLsg==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@docsearch/css": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.7.0.tgz", + "integrity": "sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==", + "license": "MIT" + }, + "node_modules/@docsearch/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.7.0.tgz", + "integrity": "sha512-x6oedjJ8O8/pIDBsMo5Orca3/6cQCz616/CwthVe68l43mqnj2lrJ9kFQITBqy8hMsS3nWeBWFoVO5dJ1DCFKA==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.19.2", + "@docsearch/core": "4.7.0", + "@docsearch/css": "4.7.0" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-core": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", + "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", + "@algolia/autocomplete-shared": "1.19.2" + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", + "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.2" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-shared": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", + "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@docusaurus/babel": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.2.tgz", + "integrity": "sha512-aJ1hpGyvfkte3dDAfNbWM4biW4yWZBVz7TIGLZP+v+tWOBgxX3e0N5ZIXHIvmfNNXTI77pcHUx3KmtOk05Ze3Q==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.25.9", + "@babel/preset-env": "^7.25.9", + "@babel/preset-react": "^7.25.9", + "@babel/preset-typescript": "^7.25.9", + "@babel/runtime": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "babel-plugin-dynamic-import-node": "^2.3.3", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/bundler": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.2.tgz", + "integrity": "sha512-i0ZNcy0f0WhaOlYVgzLsWhIoEXO9kS3HRoKPtgE6vQtZUq7arKZaYdNBudr3mqCmd+TyOkwtwfHgs1ENj07r5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.9", + "@docusaurus/babel": "3.10.2", + "@docusaurus/cssnano-preset": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "babel-loader": "^9.2.1", + "clean-css": "^5.3.3", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.11.0", + "css-minimizer-webpack-plugin": "^5.0.1", + "cssnano": "^6.1.2", + "file-loader": "^6.2.0", + "html-minifier-terser": "^7.2.0", + "mini-css-extract-plugin": "^2.9.2", + "null-loader": "^4.0.1", + "postcss": "^8.5.4", + "postcss-loader": "^7.3.4", + "postcss-preset-env": "^10.2.1", + "terser-webpack-plugin": "^5.3.9", + "tslib": "^2.6.0", + "url-loader": "^4.1.1", + "webpack": "^5.95.0", + "webpackbar": "^7.0.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/faster": "*" + }, + "peerDependenciesMeta": { + "@docusaurus/faster": { + "optional": true + } + } + }, + "node_modules/@docusaurus/core": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.2.tgz", + "integrity": "sha512-EYByj6nk+aD9KeVxV6Hmo2/nAAT79P21Y82ycTBOBtrmqilloIbIEhgL2/8Xpt2Jz/pgNqHAwyusOGwmbKeJmA==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.10.2", + "@docusaurus/bundler": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "boxen": "^6.2.1", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "cli-table3": "^0.6.3", + "combine-promises": "^1.1.0", + "commander": "^5.1.0", + "core-js": "^3.31.1", + "detect-port": "^2.1.0", + "escape-html": "^1.0.3", + "eta": "^2.2.0", + "eval": "^0.1.8", + "execa": "^5.1.1", + "fs-extra": "^11.1.1", + "html-tags": "^3.3.1", + "html-webpack-plugin": "^5.6.0", + "leven": "^3.1.0", + "lodash": "^4.17.21", + "open": "^8.4.0", + "p-map": "^4.0.0", + "prompts": "^2.4.2", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", + "react-loadable-ssr-addon-v5-slorber": "^1.0.3", + "react-router": "^5.3.4", + "react-router-config": "^5.1.1", + "react-router-dom": "^5.3.4", + "semver": "^7.5.4", + "serve-handler": "^6.1.7", + "tinypool": "^1.0.2", + "tslib": "^2.6.0", + "update-notifier": "^6.0.2", + "webpack": "^5.95.0", + "webpack-bundle-analyzer": "^4.10.2", + "webpack-dev-server": "^5.2.2", + "webpack-merge": "^6.0.1" + }, + "bin": { + "docusaurus": "bin/docusaurus.mjs" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/faster": "*", + "@mdx-js/react": "^3.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@docusaurus/faster": { + "optional": true + } + } + }, + "node_modules/@docusaurus/cssnano-preset": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.2.tgz", + "integrity": "sha512-4gCnHRbJLTloiwfvFAa92tgb2gI4KYhvjfQVYnEaiMO/EgvWfCo1LwytHXen+1oZAN0VAlS0JAPxp3MsvKDa3A==", + "license": "MIT", + "dependencies": { + "cssnano-preset-advanced": "^6.1.2", + "postcss": "^8.5.4", + "postcss-sort-media-queries": "^5.2.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/logger": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.2.tgz", + "integrity": "sha512-gSEwqtPfCAnC3ZSJY6xL7tcIfgg0vFD39jbv93eakuweyvO2864xR0K+kmKwBhkTCtWRNjuGGnb5rdmkD/ndqw==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/mdx-loader": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.2.tgz", + "integrity": "sha512-9Fd4V/SFjfrVQ0JH5EN0+iPWyFunvTeQE3gfyFeetqPaXMP0OylIjOw16dCuXG4NZJrYdBqwzjh18/h3gRi47w==", + "license": "MIT", + "dependencies": { + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@mdx-js/mdx": "^3.0.0", + "@slorber/remark-comment": "^1.0.0", + "escape-html": "^1.0.3", + "estree-util-value-to-estree": "^3.0.1", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "image-size": "^2.0.2", + "mdast-util-mdx": "^3.0.0", + "mdast-util-to-string": "^4.0.0", + "rehype-raw": "^7.0.0", + "remark-directive": "^3.0.0", + "remark-emoji": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "stringify-object": "^3.3.0", + "tslib": "^2.6.0", + "unified": "^11.0.3", + "unist-util-visit": "^5.0.0", + "url-loader": "^4.1.1", + "vfile": "^6.0.1", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/module-type-aliases": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.2.tgz", + "integrity": "sha512-h/I5e4jaAhDHW4vaLENi1i2hnOEnXY1t9R+nnRTbgUl7ymVRzN/HF7dDfj8rKYGj8gfIge+Ef+iYRAMtbGvsrQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/types": "3.10.2", + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router-config": "*", + "@types/react-router-dom": "*", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@docusaurus/plugin-content-blog": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.2.tgz", + "integrity": "sha512-0cbEnNKf0InmLkhj/+nVRmqEnWEoOE8Mh+2x1qOXI0qYpCnphq4RXknVJ8BvybKRXqYVvbmdMfiJSup+k4tm5w==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "cheerio": "1.0.0-rc.12", + "combine-promises": "^1.1.0", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "schema-dts": "^1.1.2", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-docs": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz", + "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@types/react-router-config": "^5.0.7", + "combine-promises": "^1.1.0", + "fs-extra": "^11.1.1", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "schema-dts": "^1.1.2", + "tslib": "^2.6.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-pages": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.2.tgz", + "integrity": "sha512-h5R12sZ/vV9EPiVjvIl9YFCOwkpwXes7dQMYt3EvP6Pphu4amHxxTqWxf08Fl5DR8h+oZMbWpFTNw5vKEYfvzQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-css-cascade-layers": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.2.tgz", + "integrity": "sha512-UkdvQby5OQUKWrw3lLnSTJXQ6VETaUVTuPQX9AABtmFm5h+ifEBx1OQ+LN726Q4byuwBf2ElHkf4qU4hTxdvRg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/plugin-debug": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.2.tgz", + "integrity": "sha512-8vbZNOSCpnsT57EY6CgN7sgRVmx3KTYwO8Uvo2pbxOyb8tbqAwtT9SslqaQ41HbA1v1hpn5RP7u5s2KvRwAFpQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "fs-extra": "^11.1.1", + "react-json-view-lite": "^2.3.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-analytics": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.2.tgz", + "integrity": "sha512-kMHMBK9j4VAtgd5owwrRLRIi0EjkrpXlX7ePj1+y68XfVZV9I1T4S+koPDm+Hfw2TtnyHvh0uNrDvjz+DjQGVA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-gtag": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.2.tgz", + "integrity": "sha512-Vt90nNFhtAChRe9+it1hcHFgFvETdSnOkL5Bma+p6E/yU2tAYrvvyk+gv+LJGM2ZUkyKuKXLRsZ2Lb0bO7+Vog==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-tag-manager": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.2.tgz", + "integrity": "sha512-MLCffCldysi/R0nzJQP7ZWd0xAoGNnSTiVOo6TTR6mKVGFhE+/XArGe67ZcaZv1uytgQXoXs92VJrgVDrz80rQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-sitemap": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.2.tgz", + "integrity": "sha512-PODkwg5XetLML3hU/3xpCKJUZ9cqExLaBnD/Fzzwj2VHogLeqnDisLIujae87zuze7T4mCm2A6KEqZkyiz07EQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "fs-extra": "^11.1.1", + "sitemap": "^7.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-svgr": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.2.tgz", + "integrity": "sha512-JgfT3jWM0TJ8Uw0cEcqxHpybngQY1vlBYpuuNO+gEh5iPh5Ar+vxq/u9CFrYsWeXy48BN7Db76Pzp2edNXUQ8A==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@svgr/core": "8.1.0", + "@svgr/webpack": "^8.1.0", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/preset-classic": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.2.tgz", + "integrity": "sha512-a4B3VczmDl99zK0EufDQYomdJ186WDingjmDXxhN2PNPS9Ty/Y2M5CLFX1KQMRKqRTLiRDKfutzG5IY1FC/ceg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/plugin-css-cascade-layers": "3.10.2", + "@docusaurus/plugin-debug": "3.10.2", + "@docusaurus/plugin-google-analytics": "3.10.2", + "@docusaurus/plugin-google-gtag": "3.10.2", + "@docusaurus/plugin-google-tag-manager": "3.10.2", + "@docusaurus/plugin-sitemap": "3.10.2", + "@docusaurus/plugin-svgr": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@docusaurus/types": "3.10.2" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-classic": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.2.tgz", + "integrity": "sha512-JqTSLQmqmA9uKWZsD5iwBGJ4JyKB4/yTw6PsSXVPRJG/6GAm/u+add9Iip+hvwP12/AnPNztrdxsI14NJW4KeA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "copy-text-to-clipboard": "^3.2.0", + "infima": "0.2.0-alpha.45", + "lodash": "^4.17.21", + "nprogress": "^0.2.0", + "postcss": "^8.5.4", + "prism-react-renderer": "^2.3.0", + "prismjs": "^1.29.0", + "react-router-dom": "^5.3.4", + "rtlcss": "^4.1.0", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.2.tgz", + "integrity": "sha512-R9b/vMpK1yye6hNZTA6x/ivRv+at6GhxnXcxkpzCGzO1R1RwiquqiFg2wMFh6aqlJTpWRFKpFD2TzCDQcyOU0A==", + "license": "MIT", + "dependencies": { + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router-config": "*", + "clsx": "^2.0.0", + "parse-numeric-range": "^1.3.0", + "prism-react-renderer": "^2.3.0", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-mermaid": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.10.2.tgz", + "integrity": "sha512-Stssh5MYQJ+EdYugUXf+ZcpeJFQPKXf0KCd/SWp10o3CmXNaOoh5IEgVjVqY1e1XhQf3on4+Y4BnrMiD95E2SQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "mermaid": ">=11.6.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@mermaid-js/layout-elk": "^0.1.9", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@mermaid-js/layout-elk": { + "optional": true + } + } + }, + "node_modules/@docusaurus/theme-search-algolia": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.2.tgz", + "integrity": "sha512-1msxllyhi/5m77JukXtp5UFnUAriwZIC1oJ7MTnpQpCwLTbclJi5BK5n28CTZuSXpQN2ewbbnqRgAhMM6c6ihg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "^1.19.2", + "@docsearch/react": "^3.9.0 || ^4.3.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "algoliasearch": "^5.37.0", + "algoliasearch-helper": "^3.26.0", + "clsx": "^2.0.0", + "eta": "^2.2.0", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-translations": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.2.tgz", + "integrity": "sha512-iv20wrxnyXkY89LM3TzRlzGlt5fIGO5UnaR6UL1ZVfB9RRFjxQFQ6awDrwAc6Km8Y5gD8pInuwYPF+6/TiCxXA==", + "license": "MIT", + "dependencies": { + "fs-extra": "^11.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/types": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.2.tgz", + "integrity": "sha512-B6rvfwIFSapUqUJjMriZswX13K8l5Z7AcmVE6uTEJpYddQieSTR12DsGaFtcZAIDsQd4p+0WTl0Vc6jmZK0Trw==", + "license": "MIT", + "dependencies": { + "@mdx-js/mdx": "^3.0.0", + "@types/history": "^4.7.11", + "@types/mdast": "^4.0.2", + "@types/react": "*", + "commander": "^5.1.0", + "joi": "^17.9.2", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", + "utility-types": "^3.10.0", + "webpack": "^5.95.0", + "webpack-merge": "^5.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/types/node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@docusaurus/utils": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.2.tgz", + "integrity": "sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==", + "license": "MIT", + "dependencies": { + "@11ty/gray-matter": "^1.0.0", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "escape-string-regexp": "^4.0.0", + "execa": "^5.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", + "jiti": "^1.20.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "micromatch": "^4.0.5", + "p-queue": "^6.6.2", + "prompts": "^2.4.2", + "resolve-pathname": "^3.0.0", + "tslib": "^2.6.0", + "url-loader": "^4.1.1", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/utils-common": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.2.tgz", + "integrity": "sha512-x3Dz6jv6iQKBNjBmVTu8p57abMp/VNTUgKBMgRVXJc5444orBTsArv0+cdfrXTiz/VMmHfDRVkPbL7GH2B7T7w==", + "license": "MIT", + "dependencies": { + "@docusaurus/types": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/utils-validation": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.2.tgz", + "integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==", + "license": "MIT", + "dependencies": { + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "fs-extra": "^11.2.0", + "joi": "^17.9.2", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@easyops-cn/autocomplete.js": { + "version": "0.38.1", + "resolved": "https://registry.npmjs.org/@easyops-cn/autocomplete.js/-/autocomplete.js-0.38.1.tgz", + "integrity": "sha512-drg76jS6syilOUmVNkyo1c7ZEBPcPuK+aJA7AksM5ZIIbV57DMHCywiCr+uHyv8BE5jUTU98j/H7gVrkHrWW3Q==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "immediate": "^3.2.3" + } + }, + "node_modules/@easyops-cn/docusaurus-search-local": { + "version": "0.49.2", + "resolved": "https://registry.npmjs.org/@easyops-cn/docusaurus-search-local/-/docusaurus-search-local-0.49.2.tgz", + "integrity": "sha512-ytXxbtpJ2gVECWrsdc6Wx7bPanueLQ95lLdR9n/tHKEPBm5d1v/vAsx487iUGVXYwzeEO8W/BQJ66Fcdvpq6xg==", + "license": "MIT", + "dependencies": { + "@docusaurus/plugin-content-docs": "^2 || ^3", + "@docusaurus/theme-translations": "^2 || ^3", + "@docusaurus/utils": "^2 || ^3", + "@docusaurus/utils-common": "^2 || ^3", + "@docusaurus/utils-validation": "^2 || ^3", + "@easyops-cn/autocomplete.js": "^0.38.1", + "@node-rs/jieba": "^1.6.0", + "cheerio": "^1.0.0", + "clsx": "^2.1.1", + "comlink": "^4.4.2", + "debug": "^4.2.0", + "fs-extra": "^10.0.0", + "klaw-sync": "^6.0.0", + "lunr": "^2.3.9", + "lunr-languages": "^1.4.0", + "mark.js": "^8.11.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@docusaurus/theme-common": "^2 || ^3", + "react": "^16.14.0 || ^17 || ^18 || ^19", + "react-dom": "^16.14.0 || 17 || ^18 || ^19" + } + }, + "node_modules/@easyops-cn/docusaurus-search-local/node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/@easyops-cn/docusaurus-search-local/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@easyops-cn/docusaurus-search-local/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@easyops-cn/docusaurus-search-local/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.78.0.tgz", + "integrity": "sha512-0YE5XQj383AwNj5fG0Mm1gyx/ViVe2DJzS5FeVbJulw0pteEK9mfbeB0MWH3/ARoXb2AhHHuiiNthdWF0Y6EYQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.78.0", + "@jsonjoy.com/fs-node-utils": "4.78.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.78.0.tgz", + "integrity": "sha512-V1lUo5k4xUypsfdmvCrrUJ44BCixbgvpus71WI3a3BOQt39bK2LasU/HgpJ1P9or8a2d5mWR3kk26sRoehDlaw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.78.0", + "@jsonjoy.com/fs-node-builtins": "4.78.0", + "@jsonjoy.com/fs-node-utils": "4.78.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.78.0.tgz", + "integrity": "sha512-AhoKCEEzsTU5C+9pSegNkErrQijvpHj10kzVhaXIxjs/0taxjCCLG7zE+Y+EWCMNCVmgFEyca+oTQQWm2RO+sg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.78.0", + "@jsonjoy.com/fs-node-builtins": "4.78.0", + "@jsonjoy.com/fs-node-utils": "4.78.0", + "@jsonjoy.com/fs-print": "4.78.0", + "@jsonjoy.com/fs-snapshot": "4.78.0", + "glob-to-regex.js": "^1.3.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.78.0.tgz", + "integrity": "sha512-yiyMh9uKnQvRVDYzINP9P2AXZPdHYM2iVsQ/2U2V+g+gfTr4vPcT1Ox0aQs0IIXGQRAnksyA63KSqpOOJ/O3lA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.78.0.tgz", + "integrity": "sha512-L1e0UgRJ/igqt3JUDJh3z8YXTKqJc9r9MY4ZakEMOqqEDq/C5fmSZDDzq03d7HqdG1rehkmCDpKyXWqQEVpQGw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.78.0", + "@jsonjoy.com/fs-node-builtins": "4.78.0", + "@jsonjoy.com/fs-node-utils": "4.78.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.78.0.tgz", + "integrity": "sha512-Am25+Y/rEFtkJBZCFgs84XPXqeqXq4FvXmCYMZ0bEC/M+vT0e6tHBU4Camp9/Yzuf6NoxaInC3iXIN791wVu9g==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.78.0", + "glob-to-regex.js": "^1.3.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.78.0.tgz", + "integrity": "sha512-Uwk0SvDN1X+5FNW3MxibrFVXXUnd0nTUP6qwvPikg3QDeXRiRww91mz34lXYa/xO5eVuUqa1WXmd0ljy1+Ej0w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.78.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.78.0.tgz", + "integrity": "sha512-+HvPGwV1IcXtwFFxMBK5x8rmqBuIkPpEFZMGkO8BolJH+AuSvQGVwvlKulUjQ9KECpN4GTCcgeQtyUvYC8heSA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.78.0", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz", + "integrity": "sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==", + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.1.9.tgz", + "integrity": "sha512-HuvaqFZBr6yT9PpWYockvKAZPJVd89yn/UjOYPxhzbZxlybL2v+2BjVCg7MVH6vRs1irUohb/s42HEdec1CCZw==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-rs/jieba": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba/-/jieba-1.10.4.tgz", + "integrity": "sha512-GvDgi8MnBiyWd6tksojej8anIx18244NmIOc1ovEw8WKNUejcccLfyu8vj66LWSuoZuKILVtNsOy4jvg3aoxIw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/jieba-android-arm-eabi": "1.10.4", + "@node-rs/jieba-android-arm64": "1.10.4", + "@node-rs/jieba-darwin-arm64": "1.10.4", + "@node-rs/jieba-darwin-x64": "1.10.4", + "@node-rs/jieba-freebsd-x64": "1.10.4", + "@node-rs/jieba-linux-arm-gnueabihf": "1.10.4", + "@node-rs/jieba-linux-arm64-gnu": "1.10.4", + "@node-rs/jieba-linux-arm64-musl": "1.10.4", + "@node-rs/jieba-linux-x64-gnu": "1.10.4", + "@node-rs/jieba-linux-x64-musl": "1.10.4", + "@node-rs/jieba-wasm32-wasi": "1.10.4", + "@node-rs/jieba-win32-arm64-msvc": "1.10.4", + "@node-rs/jieba-win32-ia32-msvc": "1.10.4", + "@node-rs/jieba-win32-x64-msvc": "1.10.4" + } + }, + "node_modules/@node-rs/jieba-android-arm-eabi": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-android-arm-eabi/-/jieba-android-arm-eabi-1.10.4.tgz", + "integrity": "sha512-MhyvW5N3Fwcp385d0rxbCWH42kqDBatQTyP8XbnYbju2+0BO/eTeCCLYj7Agws4pwxn2LtdldXRSKavT7WdzNA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-android-arm64": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-android-arm64/-/jieba-android-arm64-1.10.4.tgz", + "integrity": "sha512-XyDwq5+rQ+Tk55A+FGi6PtJbzf974oqnpyCcCPzwU3QVXJCa2Rr4Lci+fx8oOpU4plT3GuD+chXMYLsXipMgJA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-darwin-arm64": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-darwin-arm64/-/jieba-darwin-arm64-1.10.4.tgz", + "integrity": "sha512-G++RYEJ2jo0rxF9626KUy90wp06TRUjAsvY/BrIzEOX/ingQYV/HjwQzNPRR1P1o32a6/U8RGo7zEBhfdybL6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-darwin-x64": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-darwin-x64/-/jieba-darwin-x64-1.10.4.tgz", + "integrity": "sha512-MmDNeOb2TXIZCPyWCi2upQnZpPjAxw5ZGEj6R8kNsPXVFALHIKMa6ZZ15LCOkSTsKXVC17j2t4h+hSuyYb6qfQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-freebsd-x64": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-freebsd-x64/-/jieba-freebsd-x64-1.10.4.tgz", + "integrity": "sha512-/x7aVQ8nqUWhpXU92RZqd333cq639i/olNpd9Z5hdlyyV5/B65LLy+Je2B2bfs62PVVm5QXRpeBcZqaHelp/bg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-linux-arm-gnueabihf": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-linux-arm-gnueabihf/-/jieba-linux-arm-gnueabihf-1.10.4.tgz", + "integrity": "sha512-crd2M35oJBRLkoESs0O6QO3BBbhpv+tqXuKsqhIG94B1d02RVxtRIvSDwO33QurxqSdvN9IeSnVpHbDGkuXm3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-linux-arm64-gnu": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-linux-arm64-gnu/-/jieba-linux-arm64-gnu-1.10.4.tgz", + "integrity": "sha512-omIzNX1psUzPcsdnUhGU6oHeOaTCuCjUgOA/v/DGkvWC1jLcnfXe4vdYbtXMh4XOCuIgS1UCcvZEc8vQLXFbXQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-linux-arm64-musl": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-linux-arm64-musl/-/jieba-linux-arm64-musl-1.10.4.tgz", + "integrity": "sha512-Y/tiJ1+HeS5nnmLbZOE+66LbsPOHZ/PUckAYVeLlQfpygLEpLYdlh0aPpS5uiaWMjAXYZYdFkpZHhxDmSLpwpw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-linux-x64-gnu": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-linux-x64-gnu/-/jieba-linux-x64-gnu-1.10.4.tgz", + "integrity": "sha512-WZO8ykRJpWGE9MHuZpy1lu3nJluPoeB+fIJJn5CWZ9YTVhNDWoCF4i/7nxz1ntulINYGQ8VVuCU9LD86Mek97g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-linux-x64-musl": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-linux-x64-musl/-/jieba-linux-x64-musl-1.10.4.tgz", + "integrity": "sha512-uBBD4S1rGKcgCyAk6VCKatEVQb6EDD5I40v/DxODi5CuZVCANi9m5oee/MQbAoaX7RydA2f0OSCE9/tcwXEwUg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-wasm32-wasi": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-wasm32-wasi/-/jieba-wasm32-wasi-1.10.4.tgz", + "integrity": "sha512-Y2umiKHjuIJy0uulNDz9SDYHdfq5Hmy7jY5nORO99B4pySKkcrMjpeVrmWXJLIsEKLJwcCXHxz8tjwU5/uhz0A==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/jieba-win32-arm64-msvc": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-win32-arm64-msvc/-/jieba-win32-arm64-msvc-1.10.4.tgz", + "integrity": "sha512-nwMtViFm4hjqhz1it/juQnxpXgqlGltCuWJ02bw70YUDMDlbyTy3grCJPpQQpueeETcALUnTxda8pZuVrLRcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-win32-ia32-msvc": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-win32-ia32-msvc/-/jieba-win32-ia32-msvc-1.10.4.tgz", + "integrity": "sha512-DCAvLx7Z+W4z5oKS+7vUowAJr0uw9JBw8x1Y23Xs/xMA4Em+OOSiaF5/tCJqZUCJ8uC4QeImmgDFiBqGNwxlyA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/jieba-win32-x64-msvc": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@node-rs/jieba-win32-x64-msvc/-/jieba-win32-x64-msvc-1.10.4.tgz", + "integrity": "sha512-+sqemSfS1jjb+Tt7InNbNzrRh1Ua3vProVvC4BZRPg010/leCbGFFiQHpzcPRfpxAXZrzG5Y0YBTsPzN/I4yHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@slorber/remark-comment": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@slorber/remark-comment/-/remark-comment-1.0.0.tgz", + "integrity": "sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==", + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.1.0", + "micromark-util-symbol": "^1.0.1" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", + "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.1.3", + "deepmerge": "^4.3.1", + "svgo": "^3.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/webpack": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", + "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-transform-react-constant-elements": "^7.21.3", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "@svgr/plugin-svgo": "8.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-config": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.11.tgz", + "integrity": "sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "^5.1.0" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "license": "MIT" + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/algoliasearch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/algoliasearch-helper": { + "version": "3.29.3", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.29.3.tgz", + "integrity": "sha512-gVOMbbPVrCO3Xs+B+BLAIGFqIQow6qHMTYCEeBqLQB9m4ZmKUqMwkEumlal2iyyezHEd4Y0FvFTLbCRutCutIQ==", + "license": "MIT", + "dependencies": { + "@algolia/events": "^4.0.1" + }, + "peerDependencies": { + "algoliasearch": ">= 3.1 < 6" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/autoprefixer": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-loader": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", + "license": "MIT", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "license": "MIT", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-6.2.1.tgz", + "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^6.2.0", + "chalk": "^4.1.2", + "cli-boxes": "^3.0.0", + "string-width": "^5.0.1", + "type-fest": "^2.5.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combine-promises": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.2.0.tgz", + "integrity": "sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "license": "Apache-2.0" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "license": "ISC" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compressible/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/configstore": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", + "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", + "license": "BSD-2-Clause", + "dependencies": { + "dot-prop": "^6.0.1", + "graceful-fs": "^4.2.6", + "unique-string": "^3.0.0", + "write-file-atomic": "^3.0.3", + "xdg-basedir": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/yeoman/configstore?sponsor=1" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/copy-text-to-clipboard": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", + "integrity": "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "license": "MIT", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/css-blank-pseudo": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-7.0.1.tgz", + "integrity": "sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz", + "integrity": "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==", + "license": "ISC", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-7.0.3.tgz", + "integrity": "sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-has-pseudo/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", + "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-5.0.1.tgz", + "integrity": "sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "cssnano": "^6.0.1", + "jest-worker": "^29.4.3", + "postcss": "^8.4.24", + "schema-utils": "^4.0.1", + "serialize-javascript": "^6.0.1" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "lightningcss": { + "optional": true + } + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-10.0.0.tgz", + "integrity": "sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssdb": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.11.0.tgz", + "integrity": "sha512-VzY/8kcK5M8oCVr/cwh24J8XVlCOITpmzCizKBC28o7Z1s23MMojXoJ3q7a+TQQoMKvxC3YlG0Z9yU10kJF1uQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", + "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^6.1.2", + "lilconfig": "^3.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-advanced": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", + "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", + "license": "MIT", + "dependencies": { + "autoprefixer": "^10.4.19", + "browserslist": "^4.23.0", + "cssnano-preset-default": "^6.1.2", + "postcss-discard-unused": "^6.0.5", + "postcss-merge-idents": "^6.0.3", + "postcss-reduce-idents": "^6.0.3", + "postcss-zindex": "^6.0.2" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", + "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^4.0.2", + "postcss-calc": "^9.0.1", + "postcss-colormin": "^6.1.0", + "postcss-convert-values": "^6.1.0", + "postcss-discard-comments": "^6.0.2", + "postcss-discard-duplicates": "^6.0.3", + "postcss-discard-empty": "^6.0.3", + "postcss-discard-overridden": "^6.0.2", + "postcss-merge-longhand": "^6.0.5", + "postcss-merge-rules": "^6.1.1", + "postcss-minify-font-values": "^6.1.0", + "postcss-minify-gradients": "^6.0.3", + "postcss-minify-params": "^6.1.0", + "postcss-minify-selectors": "^6.0.4", + "postcss-normalize-charset": "^6.0.2", + "postcss-normalize-display-values": "^6.0.2", + "postcss-normalize-positions": "^6.0.2", + "postcss-normalize-repeat-style": "^6.0.2", + "postcss-normalize-string": "^6.0.2", + "postcss-normalize-timing-functions": "^6.0.2", + "postcss-normalize-unicode": "^6.1.0", + "postcss-normalize-url": "^6.0.2", + "postcss-normalize-whitespace": "^6.0.2", + "postcss-ordered-values": "^6.0.2", + "postcss-reduce-initial": "^6.1.0", + "postcss-reduce-transforms": "^6.0.2", + "postcss-svgo": "^6.0.3", + "postcss-unique-selectors": "^6.0.4" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-utils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", + "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/detect-port": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", + "license": "MIT", + "dependencies": { + "address": "^2.0.1" + }, + "bin": { + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.428", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.428.tgz", + "integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==", + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/emoticon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz", + "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.1.tgz", + "integrity": "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-value-to-estree": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.5.0.tgz", + "integrity": "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eval": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", + "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", + "dependencies": { + "@types/node": "*", + "require-like": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/express/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/feed": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "license": "MIT", + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/file-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/file-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", + "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", + "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "12.6.1", + "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", + "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-yarn": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", + "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.8", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz", + "integrity": "sha512-MZmKQcTnhEh1SPSyMiEytIeDZDUoBZVorNHivQGXMASHf/BSGGOrKa2xQ5bGx3TCe1n109ecCt+cpww7wwWhKA==", + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x || 2.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/html-webpack-plugin/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/html-webpack-plugin/node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz", + "integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/immediate": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infima": { + "version": "0.2.0-alpha.45", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.45.tgz", + "integrity": "sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-yarn-global": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", + "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/joi": { + "version": "17.13.8", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.8.tgz", + "integrity": "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/latest-version": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", + "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", + "license": "MIT", + "dependencies": { + "package-json": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "license": "MIT" + }, + "node_modules/lunr-languages": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/lunr-languages/-/lunr-languages-1.22.0.tgz", + "integrity": "sha512-zNog8491aJ5Fl4WGe+aiMcc851d7d5s31nuDjfR0qv5zoVFSBB8/nq/WLMP9Xrtx+UeLX7jDvKFEmus4V5aKZA==", + "license": "MPL-1.1" + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "license": "MIT" + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.78.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.78.0.tgz", + "integrity": "sha512-gX14RGPP+fyNodL8g9jc5DfKtWNCronEMItOKDyZatTI3CPpMZDPtm7rAbftDqQAFovP4nzR0RO80sESSEnI4g==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.78.0", + "@jsonjoy.com/fs-fsa": "4.78.0", + "@jsonjoy.com/fs-node": "4.78.0", + "@jsonjoy.com/fs-node-builtins": "4.78.0", + "@jsonjoy.com/fs-node-to-fsa": "4.78.0", + "@jsonjoy.com/fs-node-utils": "4.78.0", + "@jsonjoy.com/fs-print": "4.78.0", + "@jsonjoy.com/fs-snapshot": "4.78.0", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.3.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-directive": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", + "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-space/node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-character/node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-events-to-acorn/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-normalize-identifier/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.2.tgz", + "integrity": "sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==", + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.1.tgz", + "integrity": "sha512-+dsZEyTcy1lkq41lxdiOqvb26gXbYGgwY+30w37f9PqUVkuEBejBLDotsHOTjuga9xJyGLW2DqzKDUyJ4W/PMQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.3", + "terser": "^5.51.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@napi-rs/image": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/null-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", + "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/null-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/null-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/null-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/null-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", + "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", + "license": "MIT", + "dependencies": { + "got": "^12.1.0", + "registry-auth-token": "^5.0.1", + "registry-url": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-numeric-range": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==", + "license": "ISC" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-7.0.1.tgz", + "integrity": "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-calc": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", + "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.11", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.12.tgz", + "integrity": "sha512-TLCW9fN5kvO/u38/uesdpbx3e8AkTYhMvDZYa9JpmImWuTE99bDQ7GU7hdOADIZsiI9/zuxfAJxny/khknp1Zw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-10.0.0.tgz", + "integrity": "sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-10.0.0.tgz", + "integrity": "sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-colormin": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-6.1.0.tgz", + "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-api": "^3.0.0", + "colord": "^2.9.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-convert-values": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-6.1.0.tgz", + "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-custom-media": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-11.0.6.tgz", + "integrity": "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-properties": { + "version": "14.0.6", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz", + "integrity": "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-8.0.5.tgz", + "integrity": "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-9.0.1.tgz", + "integrity": "sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-discard-comments": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", + "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-6.0.3.tgz", + "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-empty": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-6.0.3.tgz", + "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-6.0.2.tgz", + "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-unused": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-6.0.5.tgz", + "integrity": "sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.4.tgz", + "integrity": "sha512-m6IKmxo7FxSP5nF2l63QbCC3r+bWpFUWmZXZf096WxG0m7Vl1Q1+ruFOhpdDRmKrRS+S3Jtk+TVk/7z0+BVK6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-10.0.1.tgz", + "integrity": "sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-focus-within": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-9.0.1.tgz", + "integrity": "sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-6.0.0.tgz", + "integrity": "sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-image-set-function": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-7.0.0.tgz", + "integrity": "sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-lab-function": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-7.0.12.tgz", + "integrity": "sha512-tUcyRk1ZTPec3OuKFsqtRzW2Go5lehW29XA21lZ65XmzQkz43VY2tyWEC202F7W3mILOjw0voOiuxRGTsN+J9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-loader": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", + "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.3.5", + "jiti": "^1.20.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-logical": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-8.1.0.tgz", + "integrity": "sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-merge-idents": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-6.0.3.tgz", + "integrity": "sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-6.0.5.tgz", + "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^6.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-rules": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-6.1.1.tgz", + "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^4.0.2", + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", + "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-6.0.3.tgz", + "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", + "license": "MIT", + "dependencies": { + "colord": "^2.9.3", + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-params": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-6.1.0.tgz", + "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-6.0.4.tgz", + "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nesting": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.2.tgz", + "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-resolve-nested": "^3.1.0", + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz", + "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", + "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-6.0.2.tgz", + "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-6.0.2.tgz", + "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-6.0.2.tgz", + "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-string": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-6.0.2.tgz", + "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-6.0.2.tgz", + "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-6.1.0.tgz", + "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-6.0.2.tgz", + "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-6.0.2.tgz", + "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-3.0.0.tgz", + "integrity": "sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==", + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-ordered-values": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz", + "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-6.0.0.tgz", + "integrity": "sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-10.0.0.tgz", + "integrity": "sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-preset-env": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-10.6.1.tgz", + "integrity": "sha512-yrk74d9EvY+W7+lO9Aj1QmjWY9q5NsKjK2V9drkOPZB/X6KZ0B3igKsHUYakb7oYVhnioWypQX3xGuePf89f3g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-alpha-function": "^1.0.1", + "@csstools/postcss-cascade-layers": "^5.0.2", + "@csstools/postcss-color-function": "^4.0.12", + "@csstools/postcss-color-function-display-p3-linear": "^1.0.1", + "@csstools/postcss-color-mix-function": "^3.0.12", + "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.2", + "@csstools/postcss-content-alt-text": "^2.0.8", + "@csstools/postcss-contrast-color-function": "^2.0.12", + "@csstools/postcss-exponential-functions": "^2.0.9", + "@csstools/postcss-font-format-keywords": "^4.0.0", + "@csstools/postcss-gamut-mapping": "^2.0.11", + "@csstools/postcss-gradients-interpolation-method": "^5.0.12", + "@csstools/postcss-hwb-function": "^4.0.12", + "@csstools/postcss-ic-unit": "^4.0.4", + "@csstools/postcss-initial": "^2.0.1", + "@csstools/postcss-is-pseudo-class": "^5.0.3", + "@csstools/postcss-light-dark-function": "^2.0.11", + "@csstools/postcss-logical-float-and-clear": "^3.0.0", + "@csstools/postcss-logical-overflow": "^2.0.0", + "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", + "@csstools/postcss-logical-resize": "^3.0.0", + "@csstools/postcss-logical-viewport-units": "^3.0.4", + "@csstools/postcss-media-minmax": "^2.0.9", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", + "@csstools/postcss-nested-calc": "^4.0.0", + "@csstools/postcss-normalize-display-values": "^4.0.1", + "@csstools/postcss-oklab-function": "^4.0.12", + "@csstools/postcss-position-area-property": "^1.0.0", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/postcss-property-rule-prelude-list": "^1.0.0", + "@csstools/postcss-random-function": "^2.0.1", + "@csstools/postcss-relative-color-syntax": "^3.0.12", + "@csstools/postcss-scope-pseudo-class": "^4.0.1", + "@csstools/postcss-sign-functions": "^1.1.4", + "@csstools/postcss-stepped-value-functions": "^4.0.9", + "@csstools/postcss-syntax-descriptor-syntax-production": "^1.0.1", + "@csstools/postcss-system-ui-font-family": "^1.0.0", + "@csstools/postcss-text-decoration-shorthand": "^4.0.3", + "@csstools/postcss-trigonometric-functions": "^4.0.9", + "@csstools/postcss-unset-value": "^4.0.0", + "autoprefixer": "^10.4.23", + "browserslist": "^4.28.1", + "css-blank-pseudo": "^7.0.1", + "css-has-pseudo": "^7.0.3", + "css-prefers-color-scheme": "^10.0.0", + "cssdb": "^8.6.0", + "postcss-attribute-case-insensitive": "^7.0.1", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^7.0.12", + "postcss-color-hex-alpha": "^10.0.0", + "postcss-color-rebeccapurple": "^10.0.0", + "postcss-custom-media": "^11.0.6", + "postcss-custom-properties": "^14.0.6", + "postcss-custom-selectors": "^8.0.5", + "postcss-dir-pseudo-class": "^9.0.1", + "postcss-double-position-gradients": "^6.0.4", + "postcss-focus-visible": "^10.0.1", + "postcss-focus-within": "^9.0.1", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^6.0.0", + "postcss-image-set-function": "^7.0.0", + "postcss-lab-function": "^7.0.12", + "postcss-logical": "^8.1.0", + "postcss-nesting": "^13.0.2", + "postcss-opacity-percentage": "^3.0.0", + "postcss-overflow-shorthand": "^6.0.0", + "postcss-page-break": "^3.0.4", + "postcss-place": "^10.0.0", + "postcss-pseudo-class-any-link": "^10.0.1", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^8.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-10.0.1.tgz", + "integrity": "sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-reduce-idents": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz", + "integrity": "sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.1.0.tgz", + "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-6.0.2.tgz", + "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-8.0.1.tgz", + "integrity": "sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-sort-media-queries": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz", + "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", + "license": "MIT", + "dependencies": { + "sort-css-media-queries": "2.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.4.23" + } + }, + "node_modules/postcss-svgo": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-6.0.3.tgz", + "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^3.2.0" + }, + "engines": { + "node": "^14 || ^16 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-6.0.4.tgz", + "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postcss-zindex": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-6.0.2.tgz", + "integrity": "sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prism-react-renderer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.0.tgz", + "integrity": "sha512-327BsVCD/unU4CNLZTWVHyUHKnsqcvj2qbPlQ8MiBE2eq2rgctjigPA1Gp9HLF83kZ20zNN6jgizHJeEsyFYOw==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet-async": { + "name": "@slorber/react-helmet-async", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@slorber/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-json-view-lite": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz", + "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-loadable": { + "name": "@docusaurus/react-loadable", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", + "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-loadable-ssr-addon-v5-slorber": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.3.tgz", + "integrity": "sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.3" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "react-loadable": "*", + "webpack": ">=4.41.1 || 5.x" + } + }, + "node_modules/react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-config": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-router-config/-/react-router-config-5.1.1.tgz", + "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + }, + "peerDependencies": { + "react": ">=15", + "react-router": ">=5" + } + }, + "node_modules/react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remark-directive": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", + "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-emoji": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-4.0.1.tgz", + "integrity": "sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.2", + "emoticon": "^4.0.1", + "mdast-util-find-and-replace": "^3.0.1", + "node-emoji": "^2.1.0", + "unified": "^11.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/renderkid/node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/renderkid/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-like": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", + "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", + "engines": { + "node": "*" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rtlcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", + "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==", + "license": "MIT", + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0", + "postcss": "^8.4.21", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "rtlcss": "bin/rtlcss.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-dts": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", + "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", + "license": "Apache-2.0" + }, + "node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "license": "MIT", + "peer": true + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", + "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.3.tgz", + "integrity": "sha512-tAjEd+wt/YwnEbfNB2ht51ybBJxbEWwe5ki/Z//Wh0rpBFTCUSj46GnxUKEWzhfuJTsee8x3lybHxFgUMig2hw==", + "license": "MIT", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.2.4" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=5.6.0" + } + }, + "node_modules/sitemap/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "license": "MIT" + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/sort-css-media-queries": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.2.0.tgz", + "integrity": "sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==", + "license": "MIT", + "engines": { + "node": ">= 6.3.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/srcset": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", + "integrity": "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylehacks": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz", + "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/svgo": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", + "license": "MIT", + "dependencies": { + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/thingies": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-notifier": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", + "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^7.0.0", + "chalk": "^5.0.1", + "configstore": "^6.0.0", + "has-yarn": "^3.0.0", + "import-lazy": "^4.0.0", + "is-ci": "^3.0.1", + "is-installed-globally": "^0.4.0", + "is-npm": "^6.0.0", + "is-yarn-global": "^0.4.0", + "latest-version": "^7.0.0", + "pupa": "^3.1.0", + "semver": "^7.3.7", + "semver-diff": "^4.0.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/boxen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-loader": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", + "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.27", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "file-loader": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "file-loader": { + "optional": true + } + } + }, + "node_modules/url-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/url-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/url-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/url-loader/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webpack": { + "version": "5.111.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.111.0.tgz", + "integrity": "sha512-A2R74kfE6b3eLKS91iZiol03Ebx7avmJJBOyiP+4LePW9NNeNJGQkKeSQos4dk6R4H0YAMYlv+aOrs1CyWT3eA==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.25.0", + "es-module-lexer": "^2.1.0", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.7.0", + "schema-utils": "^4.5.0", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.6.tgz", + "integrity": "sha512-yBWCMvIfUmuhAE8vdqUKzH0vg9kuWN0KeG4vBnqRplUFHRU7lMQjkiJWxVQzvo2BTewqhPhDlMB41rAt2jVA9A==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-middleware/node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.8.1", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.14.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^5.5.0", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpackbar": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-7.0.0.tgz", + "integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==", + "license": "MIT", + "dependencies": { + "ansis": "^3.2.0", + "consola": "^3.2.3", + "pretty-time": "^1.1.0", + "std-env": "^3.7.0" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@rspack/core": "*", + "webpack": "3 || 4 || 5" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 0000000..cacb4e5 --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,31 @@ +{ + "name": "blueforce-docs", + "version": "0.1.0", + "private": true, + "description": "Blueforce fleet documentation site. Source of truth is ../docs/*.md; this package only builds the site.", + "scripts": { + "sync": "./sync-docs.sh", + "prestart": "./sync-docs.sh", + "prebuild": "./sync-docs.sh", + "start": "docusaurus start --host 127.0.0.1 --port 3000", + "start:intranet": "docusaurus start --host 0.0.0.0 --port 3000", + "build": "docusaurus build", + "serve": "docusaurus serve build --host 127.0.0.1 --port 3000", + "serve:intranet": "docusaurus serve build --host 0.0.0.0 --port 3000", + "clear": "docusaurus clear" + }, + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-mermaid": "3.10.2", + "@easyops-cn/docusaurus-search-local": "0.49.2", + "@mermaid-js/layout-elk": "0.1.9", + "@mdx-js/react": "3.0.1", + "clsx": "2.1.1", + "prism-react-renderer": "2.4.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { "typescript": "5.5.4" }, + "engines": { "node": ">=18.0" } +} \ No newline at end of file diff --git a/docs-site/sidebars.js b/docs-site/sidebars.js new file mode 100644 index 0000000..9ea4e3b --- /dev/null +++ b/docs-site/sidebars.js @@ -0,0 +1,9 @@ +// Auto-generated sidebar: every synced Markdown file appears without manual entries. +// @ts-check + +/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +const sidebars = { + docs: [{type: 'autogenerated', dirName: '.'}], +}; + +export default sidebars; diff --git a/docs-site/src/css/custom.css b/docs-site/src/css/custom.css new file mode 100644 index 0000000..6a056d0 --- /dev/null +++ b/docs-site/src/css/custom.css @@ -0,0 +1,4 @@ +/* Blueforce docs theme overrides. Dark mode is the default (see docusaurus.config.ts). */ +:root { + --ifm-color-primary: #4aa8ff; +} diff --git a/docs-site/src/pages/index.tsx b/docs-site/src/pages/index.tsx new file mode 100644 index 0000000..51a5b2f --- /dev/null +++ b/docs-site/src/pages/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Layout from '@theme/Layout'; + +export default function Home() { + const {siteConfig} = useDocusaurusContext(); + return ( + +
+

{siteConfig.title}

+

{siteConfig.tagline}

+ + Open docs + +
+
+ ); +} diff --git a/docs-site/sync-docs.sh b/docs-site/sync-docs.sh new file mode 100755 index 0000000..4fe70a6 --- /dev/null +++ b/docs-site/sync-docs.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# One-way sync: repo docs (../docs/*.md) -> site content (./docs/). +# The repo Markdown files are the single source of truth; never edit ./docs by hand. +set -euo pipefail + +SITE_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC_DIR="$SITE_DIR/../docs" +DST_DIR="$SITE_DIR/docs" + +mkdir -p "$DST_DIR" + +# Copy only numbered canonical sources; docs/_TEMPLATE.md is never site content. +shopt -s nullglob +sources=("$SRC_DIR"/[0-9][0-9]-*.md) +if [ "${#sources[@]}" -eq 0 ]; then + echo "No numbered Markdown sources found in $SRC_DIR" >&2 + exit 1 +fi +cp -f "${sources[@]}" "$DST_DIR"/ + +# Remove stale or non-numbered site documents, including _TEMPLATE.md. +for f in "$DST_DIR"/*.md; do + base="$(basename "$f")" + if [[ ! "$base" =~ ^[0-9][0-9]-.*\.md$ ]] || [ ! -f "$SRC_DIR/$base" ]; then + rm -f "$f" + fi +done + +echo "Synced ${#sources[@]} numbered files into docs-site/docs." diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json new file mode 100644 index 0000000..d250afa --- /dev/null +++ b/docs-site/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/docs/01-ARCHITECTURE.md b/docs/01-ARCHITECTURE.md index f279597..3a8d82b 100644 --- a/docs/01-ARCHITECTURE.md +++ b/docs/01-ARCHITECTURE.md @@ -1,9 +1,9 @@ # 01 — Sistem Mimarisi -> Kısa özet: 700 saha cihazlı Blueforce filosunun uçtan uca mimarisi: saha PC'si, WireGuard ağı, 4 erişim kanalı, filo otomasyonu, monitoring ve update zinciri tek bakışta. Kararların görsel karşılığıdır. +> Kısa özet: 700 saha cihazlı Blueforce filosunun uçtan uca mimarisi: saha PC'si, WireGuard ağı, 4 erişim kanalı, filo otomasyonu, monitoring ve update zinciri tek bakışta. Kararların görsel karşılığıdır. Bu turda mimari iki noktada kesinleşti: **V1 medyası tek USB Field OS ISO'dur** (autoinstall motoru + offline APT + firstboot; upstream ISO + NoCloud seed geri dönüş yolu — K-20) ve **xRDP her zaman hazırdır** (`bf-gui-*` yalnız yerel fiziksel GUI'yi kontrol eder — K-22). > > - Dosya: `docs/01-ARCHITECTURE.md` -> - İlgili kararlar: `24-DECISION-LOG.md#K-01…K-14` (tüm mimari bu kararlara dayanır) +> - İlgili kararlar: `24-DECISION-LOG.md#K-01…K-24` (tüm mimari bu kararlara dayanır; medya için `#K-20`, RDP/yerel GUI ayrımı için `#K-22`, durum modeli için `#K-21`, masaüstü ölçümü için `#K-23`) > - Durum: [x] Onaylı (Faz 2 kilidi) --- @@ -20,14 +20,16 @@ Başka bir DevOps mühendisinin filoyu tek dosyadan anlayıp kurmaya başlayabil ## 3. Kararlar ```text -KARAR: Filo mimarisi: saha = Ubuntu Server 26.04.1+ GNOME + Docker(MEG) + WireGuard spoke; merkez = WireGuard hub + RustDesk hbbs/hbbr + MeshCentral + Ansible/Semaphore + Prometheus/Grafana + Uptime Kuma (hepsi self-hosted, ücretsiz katman). -GEREKÇE: 24-DECISION-LOG K-01…K-08'in birleşimi; hareketli parça en aza indirildi (agent'sız filo, tek Go binary UI, tek konteyner uptime), iki WireGuard-bağımsız erişim kanalıyla tek-nokta körlüğü engellendi. -ALTERNATİF: K8s-tabanlı yönetim / Cloud-merkezli monitoring — sadelik kuralını bozduğu için elendi (bkz. 24 §4). -RİSK: Merkez VDS tek fiziksel nokta; azaltma: yedekleme + hızlı yeniden kurulum (17-RECOVERY), kritik kanalların ayrı VDS'e taşınabilirliği. +KARAR: Filo mimarisi: saha = Ubuntu Server 26.04.1+ üzerinde GNOME katmanlı Blueforce Field OS + Docker(MEG) + WireGuard spoke; V1 medya = TEK USB Field OS ISO (medya kökünde autoinstall.yaml + offline APT repo + firstboot; otomatik disk silme yok), geri dönüş yolu = upstream Ubuntu ISO + NoCloud seed USB; merkez = WireGuard hub + RustDesk hbbs/hbbr + MeshCentral + Ansible/Semaphore + Prometheus/Grafana + Uptime Kuma (hepsi self-hosted, ücretsiz katman). xRDP servisi her zaman hazır (`bf-gui-*` yalnız yerel fiziksel GUI'yi yönetir); durum modeli tek yönlüdür: PROVISIONED_OFFLINE → ENROLLED → READY. +GEREKÇE: K-01…K-24 kararlarının birleşimi; hareketli parça en aza indirildi (agent'sız filo, tek Go binary UI, tek konteyner uptime), iki WireGuard-bağımsız erişim kanalıyla tek-nokta körlüğü engellendi. Tek medya teknisyen hatasını azaltır, otomatik disk silme yasağı (interactive storage) veri kaybını önler; xRDP'nin `bf-gui-on`'dan bağımsız olması yerel GUI kapalı cihazda uzaktan kurtarmayı korur. +ALTERNATİF: K8s-tabanlı yönetim / Cloud-merkezli monitoring — sadelik kuralını bozduğu için elendi (bkz. 24 §4). İki medyalı kurulum (upstream ISO + seed) — tek-medya deneyimi olmadığı için V1 yolu olmaktan çıktı, geri dönüş yolu olarak kaldı (K-20). +RİSK: Merkez VDS tek fiziksel nokta; azaltma: yedekleme + hızlı yeniden kurulum (17-RECOVERY), kritik kanalların ayrı VDS'e taşınabilirliği. Remaster medya boot zinciri kanıtlanmazsa assisted akış bloke olur; azaltma: iki medyalı geri dönüş yolu sıcak tutulur. MALİYET: Ücretsiz (lisans $0; VDS kira bedeli altyapı maliyetidir). LİSANS: Bileşen lisansları için 24-DECISION-LOG Ek A'ya bakılır. ``` +> **Güncelleme (2026-09-16):** Bu bölümdeki "V1 medya = upstream ISO + ayrı NoCloud seed USB" ifadesi K-20 ile güncellendi (V1 = tek USB Field OS ISO; upstream + seed = geri dönüş yolu). §9'daki saha imajı akışı ve §12 kontrol listesi aynı PR'da hizalandı; diyagramlar bu kararlarla uyumludur. + ## 4. Neden Bu Karar? Öncelik sırası (kesintisiz çalışma > veri kaybı yok > uzaktan erişim > bakım kolaylığı) mimariyi şekillendirdi: boot zinciri her katmanda otomatik toparlanır (systemd + `unless-stopped` + `wg-quick` enable), erişim 4 kanalla yedeklenir (ikisi WireGuard-bağımsız), update'ler onay zincirinden geçmeden sahaya inmez. Hiçbir katman ücretli lisansa dayanmaz. @@ -61,8 +63,8 @@ LİSANS: Bileşen lisansları için 24-DECISION-LOG Ek A'ya bakılır. ## 9. Uygulama Planı 1. Merkez VDS: WireGuard hub → hbbs/hbbr → MeshCentral → Semaphore → Prometheus/Grafana/Kuma sırasıyla kurulur. -2. Saha imajı: Ubuntu Server → GNOME → Docker → WireGuard spoke → RustDesk/MeshCentral agent → Node Exporter. -3. İlk 2 LAB cihazı bu mimariye göre uca eklenir, 3 akış (erişim/boot/update) testlenir. +2. Saha imajı: Field OS V1 = tek USB Field OS ISO (autoinstall motoru + offline APT repo + firstboot; `interactive-sections: [storage]` ile diski operatör onaylar) → GNOME → Docker → WireGuard spoke → RustDesk/MeshCentral agent → Node Exporter; offline tamamlanırsa durum `PROVISIONED_OFFLINE`, yalnız enrollment ve merkezi kanal doğrulamasından sonra `READY` (26–29). Medya boot etmezse upstream ISO + NoCloud seed yolu devreye girer (K-20). +3. İlk 2 LAB cihazı bu mimariye göre uca eklenir; 3 akış (erişim/boot/update) + GNOME vs XFCE A/B ölçümü (K-23) testlenir. ```bash # örnek: cihaz kimliği standardı (K-12) @@ -76,6 +78,8 @@ hostnamectl set-hostname bf-12010193 | 3 Mermaid'deki her ok için bağlantı testi | Uçtan uca akış çalışır | LAB(2) | | VDS down simülasyonu (hbbs kapalı) | SSH-over-WG + MeshCentral ayakta | P1(5) | | Güç kesintisi simülasyonu | Boot zinciri sırayla toparlanır | LAB(2) | +| `bf-gui-off` sonrası erişim | xRDP her zaman hazır, uzaktan grafik kurtarma çalışır (K-22) | LAB(2) | +| Tek-USB Field OS ISO ile kurulum | Assisted kurulum, operatör disk onayı, `PROVISIONED_OFFLINE` (K-20) | LAB(2) UEFI+Legacy | ## 11. Rollback @@ -84,13 +88,15 @@ Mimari değişikliği 24-DECISION-LOG PR'ı ile yapılır; bu dosyanın diyagram ## 12. Kontrol Listesi - [ ] 3 Mermaid bloğu render oluyor (Docusaurus + GitHub preview). -- [ ] Her bileşen adının kararı 24'te var (K-01…K-14). +- [ ] Her bileşen adının kararı 24'te var (K-01…K-24). - [ ] `bf-` / `BF-` adlandırması diyagramlarda tutarlı. +- [ ] Medya (tek USB Field OS ISO + geri dönüş yolu), RDP'nin her-zaman-hazır olması ve tek yönlü durum modeli diyagramlarla uyumlu (K-20/K-21/K-22). ## 13. Açık Sorular - [ ] Merkez servislerin tek VDS'e sığıp sığmadığı — LAB yük testi (sahibi: 25-ROADMAP). - [ ] Prometheus federasyonu gerekip gerekmediği — 700 node ölçümü (sahibi: 14-MONITORING). +- [ ] Field OS ISO boot zincirinin (UEFI/Legacy/Secure Boot) LAB kanıtı ve medya tabanı kararı (Desktop flavour) — F2B (sahibi: release yöneticisi) [K-20]. --- @@ -116,21 +122,24 @@ flowchart LR PC -->|Internet doğrudan
VDS genel adresi| MC RD -.->|WG çökse bile
ayakta| PC MC -.->|WG çökse bile
ayakta| PC + GUI["bf-gui-on / bf-gui-off
(yalnız yerel fiziksel GUI, K-22)"] -.->|"xRDP'i KONTROL ETMEZ"| WG ``` ## Ek: Mermaid — 2. Power boot zinciri ```mermaid flowchart TB - PWR["Power ON
(BIOS: Restore on AC Power Loss = Power On)"] --> BIOS["BIOS/UEFI"] + MEDYA["V1 medya: tek USB Field OS ISO
(autoinstall + offline APT + firstboot)
geri dönüş: upstream ISO + seed"] --> PWR["Power ON
(BIOS: Restore on AC Power Loss = Power On)"] + PWR --> BIOS["BIOS/UEFI"] BIOS --> UB["Ubuntu Server 26.04.1
multi-user terminal boot"] UB --> NET["Network
(NetworkManager/systemd-networkd)"] NET --> WGV["WireGuard
(wg-quick@wg0 enable)"] WGV --> DKR["Docker daemon
(log rotasyonlu)"] DKR --> MEG["MEG konteyneri
(unless-stopped)"] MEG --> BF["Blueforce servisleri"] - BF --> REM["Remote: SSH + xRDP +
RustDesk + MeshCentral agent"] + BF --> REM["Remote: SSH + xRDP (her zaman hazır, K-22) +
RustDesk + MeshCentral agent"] REM --> MON["Monitoring:
Node Exporter + Uptime Push"] + UB -.->|"ileri yönlü durum modeli (K-21)"| ST["PROVISIONED_OFFLINE → ENROLLED → READY"] ``` ## Ek: Mermaid — 3. Update onay akışı diff --git a/docs/03-UBUNTU-BASELINE.md b/docs/03-UBUNTU-BASELINE.md index 35b1e7d..dc3b08e 100644 --- a/docs/03-UBUNTU-BASELINE.md +++ b/docs/03-UBUNTU-BASELINE.md @@ -1,9 +1,9 @@ # 03 — Ubuntu Baseline -> Kısa özet: Saha PC'lerinin standart zemini: Ubuntu Server 26.04.1+ LTS minimal kurulum, GNOME sonradan eklenir, terminale boot edilir, `unattended-upgrades` kapalıdır. BIOS güç-kurtarma ayarı ve GUI aç/kapa anahtarları bu dosyadadır. +> Kısa özet: Saha PC'lerinin standart zemini: Ubuntu Server 26.04.1+ LTS minimal kurulum, GNOME masaüstü (saha standardı; XFCE yalnız LAB A/B ölçüm kolu, K-23), terminale boot edilir, `unattended-upgrades` kapalıdır. BIOS güç-kurtarma ayarı, GUI aç/kapa anahtarları ve RDP'nin her-zaman-hazır ayrımı (K-22) bu dosyadadır. - Dosya: `docs/03-UBUNTU-BASELINE.md` -- İlgili kararlar: `24-DECISION-LOG.md#K-01` (OS/sürüm), `K-02` (GNOME), `K-03` (boot + bf-gui), `K-11` (otomatik update kapalı) +- İlgili kararlar: `24-DECISION-LOG.md#K-01` (OS/sürüm), `K-02` (GNOME saha standardı), `K-03` (boot + bf-gui), `K-11` (otomatik update kapalı), `K-22` (RDP her zaman hazır), `K-23` (GNOME vs XFCE LAB A/B ölçümü) - Durum: [ ] Taslak --- @@ -29,23 +29,27 @@ LİSANS: Ubuntu LTS ücretsiz (ESM kapsamına bel bağlanmaz) — https://rele ``` ```text -KARAR: Saha masaüstü ortamı GNOME'dur (Ubuntu Desktop standardı); XFCE elendi. -GEREKÇE: Kullanıcı kararı + Ubuntu Desktop standardı: tanıdık modern masaüstü, teknisyen akışıyla birebir uyum. -ALTERNATİF: XFCE — hafif ve düşük kaynak tüketimine rağmen kullanıcı kararıyla elendi; yedekte tutulmaz. -RİSK: xRDP+GNOME kombinasyonu ek ayar isteyebilir (uyarı: GNOME oturumu RDP'de ek yapılandırma gerektirebilir); azaltma: 13-MEG-LINUX-ACCEPTANCE testi GNOME üzerinde koşar, `bf-gui-*` LAB'da 26.04.1 imajında doğrulanmadan dondurulmaz. +KARAR: Saha masaüstü ortamı GNOME'dur (Ubuntu Desktop standardı) ve bu karar yürürlüktedir. XFCE saha standardı DEĞİLDİR: elenmiş sayılmaz, K-23 uyarınca LAB'da GNOME vs XFCE A/B ölçümünde karşılaştırma koludur ve karar ölçüm verisiyle revize edilebilir. +GEREKÇE: Kullanıcı kararı + Ubuntu Desktop standardı: tanıdık modern masaüstü, teknisyen akışıyla birebir uyum. RDP oturumu + headless RustDesk + düşük RAM'li mini PC koşulları ölçülmeden "tek doğru masaüstü" iddiası kanıtsız kalır; bu yüzden GNOME baseline olarak sabit tutulur, XFCE yalnız LAB ölçüm koluna alınır (ölçüm seti: RAM, CPU, boot süresi, RDP güvenilirliği, RustDesk reboot sonrası davranış, login screen erişimi, dummy display, 24/72 saat stabilite). Ölçüm seti ve 24/72 saat süreleri sabittir; XFCE kolu saha medyasına girmez. RustDesk headless ZORUNLU şartı her iki kolda da aynen korunur. +ALTERNATİF: XFCE — hafif ve düşük kaynak tüketimine rağmen saha standardı değildir (K-02); K-23 uyarınca LAB yedek/karşılaştırma koludur, revizyon yalnız ölçüm verisiyle yapılır. XFCE'yi doğrudan saha standardı yapmak kullanıcı kararı dışı ve kanıtsız olduğu için reddedildi; XFCE'yi tamamen kapatmak da ölçüm şansını yok ettiği için reddedildi. +RİSK: xRDP+GNOME kombinasyonu ek ayar isteyebilir (uyarı: GNOME oturumu RDP'de ek yapılandırma gerektirebilir); azaltma: 13-MEG-LINUX-ACCEPTANCE testi GNOME üzerinde koşar, `bf-gui-*` LAB'da 26.04.1 imajında doğrulanmadan dondurulmaz. A/B ölçümü ek LAB zamanı ve iki masaüstü bakım yükü getirir; azaltma: ölçüm seti/süreleri sabit ve sonuç tek tabloda LAB release kaydına bağlıdır. MALİYET: Ücretsiz. -LİSANS: GNOME (GPL/LGPL bileşenler), ücretsiz — https://www.gnome.org/ (doğrulanma: 2026-09-15). +LİSANS: GNOME (GPL/LGPL bileşenler), ücretsiz — https://www.gnome.org/ (doğrulanma: 2026-09-15; yeniden doğrulama: 2026-09-16, HTTP 200); XFCE GPL — https://xfce.org/ (doğrulama: 2026-09-16, HTTP 200; yalnız LAB A/B ölçüm adayıdır, saha standardı değildir). ``` +> **Güncelleme (2026-09-16, K-23):** Yukarıdaki "XFCE elendi / yedekte tutulmaz" ifadesi geçersizdir. Doğru ifade: **GNOME seçildi ve saha standardı olarak kalır; XFCE LAB A/B ölçümünde karşılaştırılır.** Ölçüm verisi gelmeden revizyon yapılmaz; revizyon 24-DECISION-LOG PR'ı ile işlenir. Ayrıntı: `24-DECISION-LOG.md#K-23`. + ```text -KARAR: Cihazlar terminale (multi-user) boot eder; grafik katman `bf-gui-on` / `bf-gui-off` ile systemd üzerinden açılıp kapatılır. -GEREKÇE: xRDP mimarisi oturum yöneticisi (sesman) + systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerine kuruludur; GUI, display-manager + xRDP servisleri üzerinden yönetilebilir bir katman olarak modellenir. Varsayılan kapalı GUI = düşük RAM, düşük saldırı yüzeyi, öngörülebilir boot. -ALTERNATİF: Her zaman grafik boot — kaynak israfı ve 700 cihazda gereksiz hata yüzeyi nedeniyle elendi. -RİSK: Kesin birim adları ve GNOME oturum başlatma satırı 26.04.1'e göre değişebilir; azaltma: LAB'da 26.04.1 imajında doğrulanmadan `bf-gui-*` dondurulmaz. +KARAR: Cihazlar terminale (multi-user) boot eder. `xrdp` + `xrdp-sesman` multi-user seviyesinde HER ZAMAN enable ve active kalır (RDP kanalı `bf-gui-on` beklemez); `bf-gui-on` / `bf-gui-off` yalnız YEREL FİZİKSEL grafik katmanını (display-manager: gdm3/gdm/sddm/lightdm + default target) systemd üzerinden açar/kapatır. GUI kapalı ≠ RDP kapalı. +GEREKÇE: xRDP mimarisi oturum yöneticisi (sesman) + systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerine kuruludur. xRDP'nin servis seviyesinde sürekli hazır olması, yerel GUI kapalı saha cihazında uzaktan grafik kurtarma kanalını korur (sahaya gitme oranını düşürür); `bf-gui-*` yalnız gereksiz yerel grafik katmanını yönetir. Varsayılan kapalı yerel GUI = düşük RAM, düşük saldırı yüzeyi, öngörülebilir boot. +ALTERNATİF: RDP'yi `bf-gui-on`'a bağlamak — GUI kapalıyken uzaktan grafik kurtarma kaybolur → reddedildi; her zaman grafik boot — kaynak israfı ve 700 cihazda gereksiz hata yüzeyi nedeniyle elendi. +RİSK: Kesin birim adları ve GNOME oturum başlatma satırı 26.04.1'e göre değişebilir; azaltma: LAB'da 26.04.1 imajında doğrulanmadan `bf-gui-*` dondurulmaz. `bf-gui-off` içinde xRDP'i durduran bir satır bulunursa bu sözleşme ihlalidir (`tests/check-specs.sh` bunu zorunlu tutar). MALİYET: Ücretsiz. -LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15). +LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15; yeniden doğrulama: 2026-09-16, HTTP 200). ``` +> **Güncelleme (2026-09-16, K-22):** Bu bloktaki "GUI, display-manager + xRDP servisleri üzerinden yönetilir" ifadesi K-22 ile kesinleştirilmiştir: **`xrdp`/`xrdp-sesman` her zaman enable+active; `bf-gui-on`/`bf-gui-off` yalnız yerel fiziksel GUI'yi (display-manager + default target) yönetir; `bf-gui-off` xRDP servislerini asla durdurmaz veya disable etmez.** Ayrıntı ve kod kanıtı: `24-DECISION-LOG.md#K-22`. + ```text KARAR: Golden image'da `unattended-upgrades` kapatılır (`/etc/apt/apt.conf.d/20auto-upgrades` değerleri `0` + `apt-daily*.timer` maskeleme). GEREKÇE: `unattended-upgrades` Ubuntu Server'da varsayılan kurulu ve etkindir, günde bir kez çalışır; kapatılmazsa 700 cihaz ilk açılışta güncellemeye kalkar ("onaysız update yasak" ilkesi delinir). @@ -57,7 +61,7 @@ LİSANS: Yok (OS yapılandırması) — https://ubuntu.com/server/docs/how-to/ ## 4. Neden Bu Karar? -Server minimal kurulum + sonradan GNOME (Ubuntu Desktop standardı, kullanıcı kararı). Terminale boot, 700 cihazın varsayılan durumunu en öngörülebilir hale getirir; grafik yalnızca bakım penceresinde açılır. Otomatik update'in kapatılması, update zincirinin (10-UPDATE) "onay olmadan değişim yok" ilkesinin teknik kilididir. +Server minimal kurulum + sonradan GNOME (Ubuntu Desktop standardı, kullanıcı kararı; XFCE saha standardı değil, yalnız K-23 LAB A/B ölçüm kolu). Terminale boot, 700 cihazın varsayılan durumunu en öngörülebilir hale getirir; yerel grafik yalnızca bakım penceresinde açılır — uzaktan grafik erişim (xRDP) ise her zaman hazırdır (K-22), yani "yerel GUI kapalı" bir erişim kaybı değildir. Otomatik update'in kapatılması, update zincirinin (10-UPDATE) "onay olmadan değişim yok" ilkesinin teknik kilididir. ## 5. Alternatifler @@ -65,8 +69,8 @@ Server minimal kurulum + sonradan GNOME (Ubuntu Desktop standardı, kullanıcı |---|---|---|---| | Ubuntu 24.04 LTS | Olgun donanım desteği | Daha kısa destek penceresi | Yedek | | Ubuntu Desktop 26.04 | Tek adımda GUI | 6 GB RAM şartı, şişkin zemin | Elendi | -| XFCE | Hafif, düşük kaynak | Kullanıcı kararıyla elendi (saha standardı GNOME) | Elendi | -| Her zaman grafik boot | Teknisyen rahatlığı | Kaynak israfı, geniş hata yüzeyi | Elendi | +| XFCE | Hafif, düşük kaynak | Saha standardı değil (GNOME baseline, K-02); yalnız ölçüm kolu | LAB A/B ölçüm adayı (K-23) | +| Yerel GUI her zaman açık | Teknisyen rahatlığı | Kaynak israfı, geniş hata yüzeyi (RDP'yi etkilemez) | Elendi | ## 6. Avantajlar @@ -77,7 +81,7 @@ Server minimal kurulum + sonradan GNOME (Ubuntu Desktop standardı, kullanıcı ## 7. Dezavantajlar - GNOME oturum başlatma satırı ve display-manager birim adları 26.04.1'e göre LAB'da doğrulanmadan dondurulamaz. -- Teknisyen grafik istediğinde ek komut (`bf-gui-on`) gerekir — runbook'a işlenir. +- Teknisyen **yerel fiziksel** ekran istediğinde ek komut (`bf-gui-on`) gerekir — runbook'a işlenir; uzaktan grafik (RDP) için bu komut gerekmez, xRDP her zaman hazırdır (K-22). ## 8. Riskler @@ -86,12 +90,14 @@ Server minimal kurulum + sonradan GNOME (Ubuntu Desktop standardı, kullanıcı | 26.04.x sürücü farkı heterojen donanımda | Orta | Orta | LAB profil testi (04) | | `bf-gui-*` birim adları 26.04.1'de farklı | Orta | Düşük | LAB doğrulaması, §13 açık soru | | `unattended-upgrades` açık unutulur | Orta | Yüksek | final-check + monitoring metriği | +| `bf-gui-off` yanlış yazılıp xRDP'i durdurur (uzaktan kurtarma kaybı) | Düşük | Yüksek | `tests/check-specs.sh` `systemctl stop/disable xrdp*` görürse testi düşürür; 08-rdp check'i active xrdp ister (K-22) | +| GNOME/XFCE kararı kanıtsız revize edilir | Düşük | Orta | A/B ölçüm seti sabit; revizyon yalnız LAB verisiyle ve PR ile (K-23) | ## 9. Uygulama Planı 1. BIOS ayarı (her saha PC'sinde, elektrik kesintisi sonrası otomatik açılış için): - `Restore on AC Power Loss = Power On` (üretici menüsünde `After Power Loss: Power On` / `AC Recovery: Power On` adlarıyla da geçer; teknisyen imaj öncesi menüden doğrular). -2. Ubuntu Server 26.04.1+ minimal kurulum (GUI seçilmez). +2. V1 medya: TEK USB **Blueforce Field OS ISO** (`dist/Blueforce-Field-OS--amd64.iso`); ISO içinde autoinstall (kurulum motoru) + offline APT repo + firstboot bulunur ve kimliksiz zemin kurulur (`unattended-upgrades` kapalıdır). `interactive-sections: [storage]` korunur: kurulum katılımsız ilerler ama diski operatör seçer/onaylar, otomatik disk silme YOKTUR. Geri dönüş yolu her zaman sıcaktır: resmi upstream ISO + NoCloud seed USB (K-20). Medya üretimi ve doğrulaması 26'da, boot kabulü LAB'dadır. 3. Boot hedefi terminale sabitlenir: ```bash @@ -108,11 +114,11 @@ sudo systemctl enable xrdp xrdp-sesman echo "gnome-session" > ~/.xsession ``` -5. GUI aç/kapa anahtarları (kurucu tarafından `/usr/local/bin/` altına yazılır): +5. GUI aç/kapa anahtarları (kurucu tarafından `/usr/local/bin/` altına yazılır; **yalnız yerel fiziksel GUI**, K-22): ```bash -sudo bf-gui-on # display-manager + xrdp/xrdp-sesman başlatılır, RDP oturumu açılır -sudo bf-gui-off # grafik katman durdurulur, cihaz terminale döner +sudo bf-gui-on # yalnız display-manager (yerel fiziksel oturum) başlatılır; RDP zaten hazırdır +sudo bf-gui-off # yerel grafik katman durdurulur, cihaz terminale döner; xrdp/xrdp-sesman ACTIVE KALIR ``` 6. Otomatik update kapatılır: @@ -124,7 +130,8 @@ sudo bf-gui-off # grafik katman durdurulur, cihaz terminale döner sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer ``` -7. Açılışta koşması beklenen servisler (final-check bunları denetler): `NetworkManager` (veya `systemd-networkd`), `wg-quick@wg0`, `docker`, `xrdp`+`xrdp-sesman` (yalnızca GUI-açık modda), `node_exporter`, RustDesk/MeshCentral agent, `bf-health-push.timer`. +7. Açılışta koşması beklenen servisler (final-check bunları denetler): `NetworkManager` (veya `systemd-networkd`), `wg-quick@wg0`, `docker`, `xrdp`+`xrdp-sesman` (**her zaman enable/ready; yerel GNOME display-manager durumu bundan bağımsız**), `node_exporter`, RustDesk/MeshCentral agent, `bf-health-push.timer`. Ubuntu 26.04.1 GNOME+xRDP kesin oturum davranışı LAB ile doğrulanmalıdır. +8. GNOME vs XFCE A/B ölçümü (LAB, K-23): aynı donanımda iki kol kurulur ve sabit ölçüm seti doldurulur — RAM, CPU, boot süresi, RDP güvenilirliği, RustDesk reboot sonrası davranışı, login screen erişimi, dummy display davranışı, 24 saat + 72 saat stabilite. RustDesk headless zorunlu şartı iki kolda da aranır; sonuç tablosu LAB release kaydına bağlanır. XFCE kolu saha medyasına girmez; revizyon yalnız bu veriyle yapılır. ## 10. Test Planı @@ -132,8 +139,11 @@ sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer |---|---|---| | `get-default` | `multi-user.target` | LAB(2) | | Güç kesintisi simülasyonu | BIOS ayarı ile cihaz kendiliğinden açılır | LAB(2) | -| `bf-gui-on/off` sonrası RDP | GNOME oturumu açılır/kapanır | LAB(2) | +| `bf-gui-on/off` sonrası yerel grafik | Yerel oturum açılır/kapanır (display-manager) | LAB(2) | +| `bf-gui-off` sonrası xRDP | `xrdp`/`xrdp-sesman` **active kalır**, RDP oturumu kurulur (K-22) | LAB(2) | | `20auto-upgrades` değerleri `0`, timer'lar maskeli | Otomatik apt çalışmaz | LAB(2) imaj denetimi | +| GNOME vs XFCE A/B ölçümü (8 metrik, 24/72 saat) | Tablo dolar; karar LAB verisiyle verilir (K-23) | LAB(2) | +| Tek-USB Field OS ISO ile kurulum (03 zemini) | Kimliksiz zemin kurulur, storage operatör onayı beklenir (K-20) | LAB(2) UEFI+Legacy | ## 11. Rollback @@ -146,13 +156,16 @@ sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer - [ ] ISO sürümü 26.04.1+ (LAB'da `lsb_release -a` çıktısı kayıtlı). - [ ] BIOS `Restore on AC Power Loss = Power On` her profilde doğrulandı. - [ ] `get-default` = `multi-user.target`. -- [ ] `bf-gui-on/off` LAB 26.04.1 imajında test edildi. +- [ ] `bf-gui-on/off` LAB 26.04.1 imajında test edildi; `bf-gui-off` sonrası xRDP hâlâ active (K-22). - [ ] `unattended-upgrades` kapalı + timer'lar maskeli. +- [ ] GNOME vs XFCE A/B ölçüm tablosu (8 metrik) doldurulmuş; XFCE saha medyasına girmemiş (K-23). ## 13. Açık Sorular - [ ] GNOME oturum başlatma satırı + `bf-gui-*` kesin birim listesi — LAB 26.04.1 imajında (sahibi: Faz 3). - [ ] `NetworkManager` vs `systemd-networkd` seçimi — netplan profiliyle birlikte LAB'da netleşecek (sahibi: Faz 3, 08 yazarı). +- [ ] GNOME vs XFCE A/B ölçüm seti ve sonuç tablosu (8 metrik, 24/72 saat) — LAB 2 cihaz (sahibi: platform ekibi + bu dosyanın yazarı) [K-23]. +- [ ] Medya tabanı sapması: LAB ISO'su Desktop flavour ile üretildi; işletim öncesi Server ISO ile yeniden build ya da Desktop tabanının kabulü — LAB/F2B (sahibi: release yöneticisi) [K-20]. --- @@ -160,8 +173,12 @@ sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer ```mermaid flowchart TB - ISO["Ubuntu Server 26.04.1+ ISO
(GUI'siz minimal)"] --> BOOT["Boot hedefi: multi-user.target
(terminale boot)"] - BOOT --> GUI["GUI katmanı (kapalı varsayılan)
GNOME + xRDP + bf-gui-on/off"] - GUI --> KILIT["Update kilidi
unattended-upgrades kapalı + timer maskeli"] + ISO["V1 medya: tek USB Field OS ISO
(autoinstall.yaml + offline APT + firstboot)
geri dönüş: upstream ISO + NoCloud seed"] --> BOOT["Boot hedefi: multi-user.target
(terminale boot; yerel GUI kapalı varsayılan)"] + BOOT --> GUI["Yerel GUI katmanı (kapalı varsayılan)
GNOME + bf-gui-on/off"] + BOOT --> RDP["xRDP servisi HER ZAMAN hazır
xrdp + xrdp-sesman enable+active (K-22)"] + GUI -.->|"bf-gui-off xRDP'i DURDURMAZ"| RDP + RDP --> KILIT["Update kilidi
unattended-upgrades kapalı + timer maskeli"] + GUI --> KILIT KILIT --> SERVIS["Açılış servisleri
wg-quick + docker + node_exporter + agent'lar"] + SERVIS --> AB["LAB A/B: GNOME vs XFCE
8 metrik + 24/72 saat (K-23)"] ``` diff --git a/docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md b/docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md index 3208ab2..8ef2e6d 100644 --- a/docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md +++ b/docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md @@ -1,9 +1,9 @@ # 04 — Golden Image ve Provisioning -> Kısa özet: Saha imajının nasıl üretildiği ve cihaza nasıl basıldığı: autoinstall-tabanlı Ubuntu zemini + ilk açılışta kimlik enjeksiyonu. Hedef akış: USB → Ubuntu → tek script → bayi no → READY. +> Kısa özet: Saha imajının nasıl üretildiği ve cihaza nasıl basıldığı: autoinstall **kurulum motorudur**, Blueforce Field OS ISO ise **paketleme** yöntemidir — ikisi rakip değil, birlikte kullanılır (K-20). V1 medyası tek USB Field OS ISO'dur (autoinstall.yaml + offline APT + firstboot medya kökünde); upstream ISO + NoCloud seed USB geri dönüş yoludur. `interactive-sections: [storage]` korunur, otomatik disk silme yoktur. Offline tamamlanma durumu `PROVISIONED_OFFLINE`'dır; `READY` merkezi enrollment ve bağımsız kanıt sonrasındadır. - Dosya: `docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md` -- İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image yöntemi), `K-01` (OS), `K-11` (update kapalı), `K-12` (kimlik imajda değil, ilk boot'ta) +- İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image yöntemi), `K-01` (OS), `K-11` (update kapalı), `K-12` (kimlik imajda değil, ilk boot'ta), `K-20` (medya stratejisi), `K-21` (durum modeli) - Durum: [ ] Taslak --- @@ -20,29 +20,33 @@ Heterojen 700 saha PC'sinde aynı zemini tekrarlanabilir şekilde kurmak: imajı ## 3. Kararlar ```text -KARAR: Golden image = Ubuntu Server 26.04.1+ minimal + `unattended-upgrades` kapalı + `blueforce-install.sh` tek kurucu (bayi no → READY); imaj, preseed/autoinstall dosyasıyla üretilir, cihaz-spesifik kimlik (`bf-`) imajda DEĞİL ilk açılışta verilir. -GEREKÇE: Donanım heterojenliği bilinmediği için bit-kopya klon (Clonezilla) tek imajda kırılgandır; autoinstall + idempotent kurulum scripti her donanım profilinde aynı zemini kurar, kimlik ilk boot'ta enjekte edilir. Hedef akış: USB → Ubuntu → tek script → bayi no → READY. -ALTERNATİF: Clonezilla bit-kopya (hızlı ama donanım-fragil → yedek yöntem olarak tutulur); Packer/custom ISO (ek derleme hattı yükü → elendi); cloud-init (autoinstall'a göre saha-USB akışına daha az uygun → elendi). -RİSK: İlk imaj heterojen donanımda sürücü eksiltebilir; azaltma: önce `bf-hardware-inventory.sh` taraması (18-ROLLOUT), LAB'da her profilde test. +KARAR: Golden image = kimliksiz Blueforce Field OS zemini: Ubuntu Server 26.04.1+ minimal + `unattended-upgrades` kapalı + `blueforce-install.sh`. Autoinstall kurulum MOTORUDUR ve Blueforce Field OS ISO PAKETLEME/DAĞITIM yöntemidir; ikisi rakip değildir, birlikte kullanılır. V1 medyası TEK USB `Blueforce-Field-OS--amd64.iso`'dur: ISO kökünde `autoinstall.yaml` + offline APT repo + firstboot bulunur. Güvenlik için autoinstall'da `interactive-sections: [storage]` KORUNUR — otomatik disk silme YOKTUR, diski operatör seçip onaylar. Resmi upstream ISO + ayrı NoCloud seed USB, V1'in tek yolu değil geri dönüş/fallback yoludur ve her zaman sıcak tutulur. Cihaz-spesifik kimlik (`bf-`), token veya secret imajda DEĞİL, firstboot/enrollment aşamasında verilir. +GEREKÇE: Donanım heterojenliği bilinmediği için bit-kopya klon (Clonezilla) tek imajda kırılgandır; autoinstall + idempotent kurulum scripti her donanım profilinde aynı zemini kurar, kimlik ilk boot'ta enjekte edilir. Subiquity resmi referansı autoinstall yapılandırmasını `/autoinstall.yaml` yolundan okur ("irrespective of how it was provided"), yani ISO köküne konan dosya desteklenir; `interactive-sections` resmi olarak desteklenir ve `apt.fallback` varsayılanı `offline-install`'dır. Böylece tek-USB deneyimi ile "otomatik disk silme yok" şartı aynı anda sağlanır: ISO autoinstall'ı başlatır, teknisyen yalnız disk adımını onaylar. Hedef akış: USB → Ubuntu → tek script → bayi no → READY (READY yalnız merkezi kanıtla). +ALTERNATİF: Clonezilla bit-kopya (hızlı ama donanım-fragil → yedek yöntem olarak tutulur); Packer + QEMU derleme hattı (ek altyapı, öğrenme eğrisi, 700 PC için aşırı → elendi); cloud-init/NoCloud (autoinstall'ın tamamlayıcısıdır, ayrı seed USB ile geri dönüş yoludur — elenmedi); yalnız upstream ISO + seed (tek-medya deneyimi yok → V1'in tek yolu olmaktan çıktı, geri dönüş yolu olarak kalır); otomatik disk wipe'lı unattended (veri kaybı riski → yasak, F6 kapsamı). +RİSK: İlk imaj heterojen donanımda sürücü eksiltebilir; azaltma: önce `bf-hardware-inventory.sh` taraması (18-ROLLOUT), LAB'da her profilde test. Remaster boot zinciri (UEFI/Legacy/Secure Boot) kanıtlanmazsa saha USB'si açılmayabilir; azaltma: F2B çıkış kriteri = tek-USB assisted kurulumun iki LAB cihazında kanıtı. MALİYET: Ücretsiz. -LİSANS: Ubuntu autoinstall/subiquity (ücretsiz, Ubuntu lisans seti içinde) — https://releases.ubuntu.com/26.04/ (doğrulanma: 2026-09-15). NOT: resmi autoinstall sözdizimi LAB çıktısıyla kesinleşir. +LİSANS: Ubuntu autoinstall/subiquity — https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html (interactive-sections, `/autoinstall.yaml`, `apt.fallback`; doğrulama: 2026-09-16, HTTP 200) + https://releases.ubuntu.com/26.04/ (doğrulanma: 2026-09-15). NOT: 26.04.1'e özgü kesin direktifler LAB çıktısıyla kesinleşir. ``` +> **Güncelleme (2026-09-16, K-20/K-21):** Bu bölümdeki "V1'de resmi upstream ISO + ayrı NoCloud seed USB kullanılır" ve "custom Field ISO ertelendi" ifadeleri geçersizdir. Kanonik karar: **autoinstall motor, Field OS ISO paketleme; V1 medyası tek USB Field OS ISO, upstream ISO + seed geri dönüş yolu.** Ayrıntı: `24-DECISION-LOG.md#K-20`; gerçek üretilmiş artefakt ve doğrulama kaydı: `docs/26-BLUEFORCE-FIELD-OS-ISO.md`. + ## 4. Neden Bu Karar? -Bit-kopya klon, kaynak makinenin sürücü setini dondurur; farklı anakart/ağ kartı olan sahada açılmayan cihaz üretir. Autoinstall her makinede donanıma göre kurulum yapar, ardından aynı kurucu script aynı zemini kurar — sonuç donanımdan bağımsız deterministik zemin + ilk boot'ta enjekte edilen benzersiz kimliktir. Custom ISO/Packer ek derleme hattı (imzalı ISO, build sunucusu, sürüm takibi) getirir; sadelik kuralını bozar. +Bit-kopya klon, kaynak makinenin sürücü setini dondurur; farklı anakart/ağ kartı olan sahada açılmayan cihaz üretir. Autoinstall her makinede donanıma göre kurulum yapar, ardından aynı kurucu script aynı zemini kurar — sonuç donanımdan bağımsız deterministik zemin + ilk boot'ta enjekte edilen benzersiz kimliktir. **Custom ISO ile autoinstall rakip değildir:** ISO, aynı autoinstall motorunu + offline APT repo'yu + firstboot'u tek medyada paketler; bu yüzden "custom ISO'yu eledik" demek yanlıştır (K-20). Elenen şey ayrı bir Packer/QEMU derleme hattı kurmaktır; repo içindeki `provisioning/iso/build-blueforce-iso.sh` bu işi mevcut xorriso/zorunlu araçlarla yapar ve boot zincirini LAB kabulüne bağlar. ## 5. Alternatifler | Yöntem | Artı | Eksi | Sonuç | |---|---|---|---| -| Ubuntu autoinstall (user-data) | Resmi yol, donanım-bağımsız, USB'den katılımsız | Sözdizimi LAB'da doğrulanmalı | **Seçildi** | +| Ubuntu autoinstall (user-data) | Resmi yol, donanım-bağımsız, USB'den katılımsız | Sözdizimi LAB'da doğrulanmalı | **Seçildi (kurulum motoru)** | | Clonezilla bit-kopya | Çok hızlı klon, basit | Donanım-fragil, kimlik çakışması riski | Yedek yöntem | -| cloud-init (NoCloud USB) | Esnek ilk-boot yapılandırma | Saha-USB akışına autoinstall kadar uygun değil, ek katman | Elendi | -| Custom ISO (cubic/live-build) | Her şey gömülü tek ISO | Derleme hattı + imza + sürüm yükü | Elendi | +| cloud-init / NoCloud seed USB | Autoinstall'i tamamlar, seed değişimi kolay | 26.04 söz dizimi/medya LABEL davranışı LAB'da doğrulanmalı | Geri dönüş yolu (tek medya değil) | +| Tek USB Field OS ISO (remaster) | Tek medya, offline APT + firstboot içinde | UEFI/Legacy/Secure Boot LAB kabulü gerekir | **V1 medyası (F2B)** | | Packer + QEMU build | Tekrarlanabilir build | Ek altyapı, öğrenme eğrisi, 700 PC için aşırı | Elendi | | Ansible-only (kurulu OS üstüne) | İmaja gerek yok | Temel OS kurulumu yine manuel/USB gerektirir | Tamamlayıcı (kurucu sonrası filo) | +> **Faz eşleşmesi (K-20 / 25-ROADMAP):** Tek-USB Field OS ISO işi **F2B — Assisted Offline Field ISO**'dadır; `F6` yalnız PXE / tam unattended / uzaktan provisioning içindir (otomatik disk yapılandırması orada, onay kapısıyla). Bu dosyadaki eski "LAB artefact / F6" ifadesi F2B olarak düzeltilmiştir. + ## 6. Avantajlar - İmaj kimliksizdir: tek USB tüm sahada kullanılır, yanlış kimlikli cihaz çıkmaz. @@ -61,21 +65,24 @@ Bit-kopya klon, kaynak makinenin sürücü setini dondurur; farklı anakart/ağ | Heterojen donanımda sürücü eksikliği | Orta | Orta | Envanter taraması + LAB profil matrisi | | Autoinstall sözdizimi sürüm farkı | Orta | Düşük | LAB 26.04.1 çıktısı dondurulur, dosyası repo'da sürümlenir | | USB medyası bozuk/eskimiş | Düşük | Düşük | Çift USB + SHA256 doğrulama | +| Remaster Field OS ISO boot etmez (UEFI/Legacy/Secure Boot) | Orta | Yüksek | F2B çıkış kapısı: tek-USB assisted kurulum iki LAB cihazında kanıtlanır; kanıt yoksa upstream ISO + seed yolu sıcak tutulur (K-20) | +| Medya tabanı sapması (LAB ISO'su Ubuntu Desktop flavour ile üretildi) | Yüksek (mevcut durum) | Orta | Sapma gizlenmez: `dist/1.0.0-manifest.yaml` içinde `base_flavor: desktop` + `baseline_deviation` bloğu; işletim öncesi Server ISO ile yeniden build veya Desktop tabanının kabulü (26 §6) | ## 9. Uygulama Planı 1. İmaj içeriği (kimliksiz zemin): - - Ubuntu Server 26.04.1+ minimal (GUI yok). + - Ubuntu Server 26.04.1+ minimal (GUI yok). **Not (sapma):** LAB'da üretilen `dist/Blueforce-Field-OS-1.0.0-amd64.iso` base flavour'ı `desktop`'tur (build host'ta checksum'ı doğrulanmış tek kurulum medyası Desktop ISO'ydu); GNOME katmanı Desktop medyasıyla geldiği için ek kurulum adımı gerekmez. Server tabanlı medya `--upstream-iso` ile aynı builder'dan çıkar; üretim öncesi Server ISO ile yeniden build ya da Desktop tabanının kabulü kararı 26 §6 ve §13'tedir. - `unattended-upgrades` kapalı (03 §9 adım 6). - - `blueforce-install.sh` + modüller USB'de `/opt/blueforce-installer/` altında (veya ilk boot'ta merkezi depodan çekilir — LAB'da netleşir). + - `blueforce-install.sh` + modüller medyada `/blueforce-provisioning/` altında (ISO'da `firstboot/`, `autoinstall/`, `offline-repo/`, `release/`; kurulu sistemde `/opt/blueforce-installer/`) — LAB'da netleşir. - Bootstrap SSH anahtarı (ilk Ansible run'ında rotasyon, K-13). -2. Saha akışı (hedef: teknisyen başına <15 dk aktif iş): +2. Saha akışı (hedef: teknisyen başına <15 dk aktif iş): ```text -USB tak → Ubuntu Server kur (autoinstall, katılımsız) - → ilk açılışta: sudo ./blueforce-install.sh --dealer-id <8hane> - → script bitince: bf-status → READY - → USB çıkar, cihaz sahaya alınır +USB (Field OS ISO) tak → autoinstall başlar (Ubuntu kurulumu katılımsız ilerler) + → installer ekranında SADECE disk adımı operatör onayı bekler (interactive-sections: [storage]; otomatik disk silme yok) + → ilk açılışta: firstboot 8 haneli bayi no sorar → sudo ./blueforce-install.sh --dealer-id <8hane> (--offline) + → script bitince: bf-status → PROVISIONED_OFFLINE + → USB çıkar; merkezi enrollment tamamlanana kadar cihaz READY değildir ``` ```bash @@ -90,16 +97,20 @@ sudo ./blueforce-install.sh --dealer-id 12010193 | Test | Beklenen sonuç | Ortam | |---|---|---| -| Aynı USB ile 2 farklı donanım profili | İkisi de READY | LAB(2) | -| İmajda kimlik artığı taraması (`bf-*`, peer key) | Hiçbir cihaz-spesifik değer yok | LAB(2) | +| Aynı USB (Field OS ISO) ile 2 farklı donanım profili | İkisi de PROVISIONED_OFFLINE | LAB(2) | +| Tek-USB assisted kurulumda disk adımı | Operatör onayı beklenir, disk kendiliğinden silinmez (K-20) | LAB(2) UEFI+Legacy | +| İmajda kimlik artığı taraması (`bf-*`, peer key, token, parola) | Hiçbir cihaz-spesifik değer/secret yok | LAB(2) + build CI | +| ISO checksum doğrulaması | `dist/*.iso.sha256` ile eşleşir | LAB(2)/CI | | Yarım kesilen kurulumun tekrarı | İkinci koşu tamamlar, exit 0 | LAB(2) | -| Clonezilla yedek yöntemi | Autoinstall takılan profilde READY | LAB(2) | +| Clonezilla yedek yöntemi | Autoinstall takılan profilde PROVISIONED_OFFLINE | LAB(2) | +| Geri dönüş yolu (upstream ISO + NoCloud seed) | Aynı zemini kurar; F2B geri alınırsa sıcak yol çalışır | LAB(2) | ## 11. Rollback 1. Bozuk imaj: önceki USB sürümüne dönüş (USB'ler sürüm etiketli saklanır). 2. Kurulum sonrası hata: kurucu idempotent yeniden koşar; düzelmezse 17-RECOVERY LEVEL 9 (imajdan temiz kurulum). 3. Autoinstall dosyası hatalıysa düzeltme repo PR'ı ile sürümlenir, SHA256 yeniden yayımlanır. +4. Field OS ISO boot etmezse (F2B) derhal upstream ISO + son onaylı NoCloud seed USB yoluna dönülür (bu yol her zaman sıcak tutulur, K-20). ## 12. Kontrol Listesi @@ -108,12 +119,17 @@ sudo ./blueforce-install.sh --dealer-id 12010193 - [ ] İmajda kimlik artığı yok (otomatik tarama). - [ ] Her donanım profili LAB matrisinde READY almış. - [ ] Clonezilla yedek prosedürü yazılı ve test edilmiş. +- [ ] Tek-USB medyada `interactive-sections: [storage]` korunmuş ve disk otomatik silinmiyor (K-20). +- [ ] F2B/F6 ayrımı doğru anlatılmış: assisted medya F2B, PXE/tam unattended F6 (25-ROADMAP). +- [ ] Medya tabanı sapması (Desktop flavour) kayda geçmiş ve işletim öncesi karar verilmiş (26 §6). ## 13. Açık Sorular - [ ] Kurucu USB'de mi taşınacak, ilk boot'ta merkezden mi çekilecek (saha interneti varsayımı) (sahibi: Faz 3). - [ ] Autoinstall kesin direktif seti — LAB 26.04.1 çıktısı (sahibi: Faz 3, bu dosyanın yazarı). - [ ] USB hazırlama standardı (Ventoy vs dd vs Balena) (sahibi: Faz 3). +- [ ] Offline APT repo'nun medyaya gömülmesi (bugün `offline_repo_status: not-included`, pinler `UNPINNED`) — F2B (sahibi: release yöneticisi) [K-20]. +- [ ] Medya tabanı: Desktop flavour kabul mü, Server ISO ile yeniden build mi — F2B çıkış kapısı (sahibi: release yöneticisi) [K-01/K-20]. --- @@ -121,9 +137,11 @@ sudo ./blueforce-install.sh --dealer-id 12010193 ```mermaid flowchart LR - USB["USB
(autoinstall + kurucu)"] --> KUR["Ubuntu Server 26.04.1
katılımsız kurulum"] - KUR --> TEK["sudo ./blueforce-install.sh
--dealer-id <8hane>"] + USB["V1 medya: tek USB Field OS ISO
/autoinstall.yaml + /blueforce-provisioning/
(interactive-sections: [storage])"] --> KUR["Autoinstall (kurulum motoru)
Ubuntu zemin kurulur, 18 modül sonra"] + KUR --> DISK["Operatör disk onayı
otomatik silme YOK"] + DISK --> TEK["firstboot → sudo ./blueforce-install.sh
--dealer-id <8hane> --offline"] TEK --> KIMLIK["Kimlik enjeksiyonu
bf- + peer + envanter"] - KIMLIK --> HAZIR["bf-status → READY"] - HAZIR --> SAHA["Sahaya alım"] + KIMLIK --> HAZIR["bf-status → PROVISIONED_OFFLINE"] + HAZIR --> SAHA["ENROLLED → READY
(merkezi kanıt, 29)"] + FB["Geri dönüş yolu: upstream ISO + NoCloud seed USB"] -.->|"F2B geri alınırsa"| KUR ``` diff --git a/docs/05-ONE-CLICK-INSTALLER.md b/docs/05-ONE-CLICK-INSTALLER.md index 548e496..839cb2d 100644 --- a/docs/05-ONE-CLICK-INSTALLER.md +++ b/docs/05-ONE-CLICK-INSTALLER.md @@ -30,7 +30,7 @@ LİSANS: Yok (şirket içi script). ```text KARAR: Kurucu 18 modülden oluşur (`01-precheck` … `18-final-check`); her modül idempotenttir — yeniden koşu güvenli ve kaldığı yerden tamamlayıcıdır. -GEREKÇE: Saha elektriği/kesintisi yarım kurulum üretir; idempotency olmadan her kesinti temiz-kurulum demektir. Final-check kapısı (modül 18) exit 0 vermeden cihaz READY sayılmaz. +GEREKÇE: Saha elektriği/kesintisi yarım kurulum üretir; idempotency olmadan her kesinti temiz-kurulum demektir. Final-check kapısı (modül 18) exit 0 vermeden cihaz kurulu sayılmaz; offline akışta sonuç `PROVISIONED_OFFLINE`, token sonrası `ENROLLED`, yalnız merkezi kanallar doğrulanınca `READY` olur (27/29). ALTERNATİF: Tek seferlik (non-idempotent) script — kesinti durumunda belirsiz ara durum nedeniyle elendi. RİSK: Modülün idempotent yazılmaması (çift kayıt, çift key); azaltma: §10'daki çift-koşu testi her modül için zorunlu. MALİYET: Ücretsiz. diff --git a/docs/07-REMOTE-ACCESS.md b/docs/07-REMOTE-ACCESS.md index 841af5d..cc8725a 100644 --- a/docs/07-REMOTE-ACCESS.md +++ b/docs/07-REMOTE-ACCESS.md @@ -1,9 +1,9 @@ # 07 — Uzaktan Erişim (4 Kanal) -> Kısa özet: Saha cihazlarına 4 bağımsız erişim kanalı: SSH-over-WireGuard, xRDP + GNOME, RustDesk OSS self-hosted (birincil grafik), MeshCentral self-hosted (ikincil yönetim). AnyDesk mimariden çıkarılmıştır; yalnızca müşteri sahasında zaten lisanslıysa opsiyonel modül olarak kurulur. +> Kısa özet: Saha cihazlarına 4 bağımsız erişim kanalı: SSH-over-WireGuard, xRDP + GNOME (service her zaman hazır — K-22), RustDesk OSS self-hosted (birincil grafik), MeshCentral self-hosted (ikincil yönetim). `bf-gui-on`/`bf-gui-off` yalnız **yerel fiziksel** GUI'yi kontrol eder, RDP'yi değil: **GUI kapalı ≠ RDP kapalı**. AnyDesk ücretsiz zorunlu mimarinin parçası DEĞİLDİR (ticari lisans şartı); yalnız `scripts/install/optional/anydesk/` altında lisans onayına bağlı opsiyonel modül vardır. - Dosya: `docs/07-REMOTE-ACCESS.md` -- İlgili kararlar: `24-DECISION-LOG.md#K-04` (4 kanal + AnyDesk hükmü), `K-03` (xRDP + bf-gui), `K-05` (WireGuard arkası), `K-02` (GNOME) +- İlgili kararlar: `24-DECISION-LOG.md#K-04` (4 kanal + AnyDesk hükmü), `K-22` (RDP her zaman hazır; `bf-gui-*` yalnız yerel GUI), `K-03` (boot + bf-gui), `K-05` (WireGuard arkası), `K-02`/`K-23` (GNOME saha standardı + LAB A/B ölçümü) - Durum: [ ] Taslak --- @@ -29,16 +29,18 @@ LİSANS: RustDesk istemci+sunucu AGPL-3.0 (1.4.9 / 1.1.16) — https://rustdes ``` ```text -KARAR: xRDP grafik bakımı verir (GNOME oturumu), yalnızca WireGuard arkasında dinler; GUI varsayılan kapalıdır, bakım penceresinde `bf-gui-on` ile açılır. -GEREKÇE: xRDP systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerinden yönetilebilir bir katmandır; WG-arkası dinleme RDP'yi internet taramasından gizler, kapalı GUI varsayılanı RAM'i korur. -ALTERNATİF: RDP'yi doğrudan internete açma — tarama + kaba-kuvvet riski nedeniyle elendi. -RİSK: Kesin birim adları ve GNOME oturum satırı 26.04.1'de değişebilir; azaltma: LAB doğrulaması olmadan `bf-gui-*` dondurulmaz (03). +KARAR: RDP kanalı HER ZAMAN HAZIRDIR: `xrdp` ve `xrdp-sesman` multi-user seviyesinde sürekli enable ve active kalır; RDP `bf-gui-on` komutuna bağlı DEĞİLDİR. `bf-gui-on` / `bf-gui-off` yalnız YEREL FİZİKSEL grafik katmanını (display-manager: gdm3/gdm/sddm/lightdm + default target) kontrol eder ve `bf-gui-off` xRDP servislerini asla durdurmaz/disable etmez. xRDP GNOME oturumu üzerinden grafik bakım verir ve yalnızca WireGuard arkasında dinler (internete açık RDP portu yoktur). +GEREKÇE: xRDP systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerinden yönetilen bir katmandır; servisi sürekli hazır tutmak, yerel GUI kapalı saha cihazında uzaktan grafik kurtarma kanalını korur (sahaya gitme oranını düşürür). WG-arkası dinleme RDP'yi internet taramasından gizler, yerel GUI'nin kapalı kalması RAM'i ve hata yüzeyini korur. Depodaki kod bu ayrımı uygular: `08-rdp` modülü xrdp+xrdp-sesman'ın enable VE active olmasını, default target'ın multi-user kalmasını doğrular; `15-power-loss` boot zinciri de xRDP'i her zaman ready varsayar. +ALTERNATİF: RDP'yi `bf-gui-on`'a bağlamak — GUI kapalı saha cihazında uzaktan grafik kurtarma kaybolur → reddedildi. RDP'yi doğrudan internete açma — tarama + kaba-kuvvet riski nedeniyle elendi. +RİSK: Kesin birim adları ve GNOME oturum satırı 26.04.1'de değişebilir; azaltma: LAB doğrulaması olmadan `bf-gui-*` dondurulmaz (03). `bf-gui-off` içine xRDP'i durduran bir satır girmesi sözleşme ihlalidir (`tests/check-specs.sh` zorunlu tutar). MALİYET: Ücretsiz. -LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15). +LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15; yeniden doğrulama: 2026-09-16, HTTP 200). GNOME GPL/LGPL — https://www.gnome.org/ (doğrulama: 2026-09-16, HTTP 200). ``` +> **Güncelleme (2026-09-16, K-22):** Bu bölümdeki eski "GUI varsayılan kapalıdır, bakım penceresinde `bf-gui-on` ile açılır → RDP" anlatımı RDP'yi `bf-gui-on`'a bağlı gösteriyordu ve yanlıştı. Doğru ayrım: **RDP servisi her zaman hazır; `bf-gui-*` yalnız yerel fiziksel GUI'yi açar/kapatır.** Ayrıntı ve kod kanıtı: `24-DECISION-LOG.md#K-22`, `docs/03-UBUNTU-BASELINE.md` §9. + ```text -KARAR: Dört kanalın agent/servisleri boot'ta otomatik başlar (systemd enable): `wg-quick@wg0`, `rustdesk` katılımsız servis, MeshCentral agent, xRDP servisleri (GUI kapalı olsa da sesman hazır). +KARAR: Dört kanalın agent/servisleri boot'ta otomatik başlar (systemd enable): `wg-quick@wg0`, RustDesk katılımsız servis, MeshCentral agent, `xrdp`/`xrdp-sesman` (yerel GUI kapalı olsa da her zaman enable/ready). RustDesk'in Ubuntu 26.04.1 headless/katılımsız kesin davranışı LAB ile doğrulanmadan dondurulmaz. GEREKÇE: Elektrik kesintisi sonrası cihaz açılınca VPN + yedek kanallar kendiliğinden gelir; 16-POWER-LOSS boot zincirinin varsayımıdır. ALTERNATİF: Manuel başlatma — 700 dokunulmaz cihazda her kesintide saha ziyareti demek → elendi. RİSK: Boot race (agent ağdan önce kalkar); azaltma: systemd `After=network-online.target` + yeniden deneme, LAB güç-kesinti testiyle doğrulanır. @@ -48,14 +50,14 @@ LİSANS: Yok (OS yapılandırması). ## 4. Neden Bu Karar? -Tek tünel körlüğü engellenir: SSH ve RDP WireGuard'a bağımlıdır (güvenlik için), RustDesk ve MeshCentral ise VDS genel adresine doğrudan çıkar (erişilebilirlik için). RustDesk birincil grafiktir çünkü katılımsız erişim + dosya transferini OSS'te verir; MeshCentral ikinci düzlemdir çünkü terminal/dosya/envanter sunar ve RustDesk arızasında devreye girer. AnyDesk'in ücretsiz katmanı ticari kullanımı kapsamadığı için 700 cihazda lisans ihlali doğurur — bu hukuki risk teknik kolaylığa ağır basar. +Tek tünel körlüğü engellenir: SSH ve RDP WireGuard'a bağımlıdır (güvenlik için), RustDesk ve MeshCentral ise VDS genel adresine doğrudan çıkar (erişilebilirlik için). RustDesk birincil grafiktir çünkü katılımsız erişim + dosya transferini OSS'te verir; MeshCentral ikinci düzlemdir çünkü terminal/dosya/envanter sunar ve RustDesk arızasında devreye girer. RDP'nin servis seviyesinde sürekli hazır olması (K-22) üçüncü bir sigortadır: yerel GUI kapalı bir cihazda bile teknisyen WG üzerinden GNOME oturumu açabilir; bu yüzden `bf-gui-off` xRDP'i asla durdurmaz. AnyDesk'in ücretsiz katmanı ticari kullanımı kapsamadığı için 700 cihazda lisans ihlali doğurur — bu hukuki risk teknik kolaylığa ağır basar ve AnyDesk ücretsiz/zorunlu mimarinin parçası değildir. ## 5. Alternatifler | Alternatif | Artı | Eksi | Sonuç | |---|---|---|---| -| AnyDesk (ücretsiz) | Tanınmış, kolay | Ticari kullanımda lisans ihlali | Elendi | -| AnyDesk (lisanslı) | Kurumsal destek | 700 cihaz lisans bedeli + kapalı kaynak | Opsiyonel modül (bkz. §9) | +| AnyDesk (ücretsiz) | Tanınmış, kolay | Ticari kullanımda lisans ihlali; zorunlu mimarinin parçası değil | Elendi | +| AnyDesk (lisanslı) | Kurumsal destek | 700 cihaz lisans bedeli + kapalı kaynak; ücretsiz mimariye girmez | Opsiyonel modül (bkz. §9) | | Tailscale/ZeroTier | Kolay mesh | Freemium, merkezi kimlik | Elendi | | Tek kanal (yalnızca SSH) | En sade | Tünel çökerse körlük | Elendi | | VNC doğrudan | Basit | Şifresiz/zayıf varyantlar, WG-dışı risk | Elendi | @@ -79,19 +81,23 @@ Tek tünel körlüğü engellenir: SSH ve RDP WireGuard'a bağımlıdır (güven | WireGuard çöküşü (istemci/saha tarafı) | Orta | Orta | RustDesk/MeshCentral bağımsız kanallar (§9 analizi) | | VDS kesintisi (hbbs+hbbr+MeshCentral) | Düşük | Yüksek | Health monitörü + yedek VDS planı (17) | | RustDesk ID çakışması / key sızıntısı | Düşük | Orta | Self-hosted key, `bf-` etiketi, key rotasyon prosedürü | -| xRDP + GNOME ek ayar ihtiyacı (26.04.1) | Orta | Orta | LAB'da `bf-gui-*` doğrulaması (03) | +| xRDP + GNOME ek ayar ihtiyacı (26.04.1) | Orta | Orta | LAB'da `bf-gui-*` doğrulaması (03); A/B ölçümünde "RDP güvenilirliği" metriği (K-23) | +| `bf-gui-off` yanlış kurgulanır, xRDP durur → uzaktan kurtarma kaybı | Düşük | Yüksek | `tests/check-specs.sh` bf-gui-off içinde `systemctl stop/disable xrdp*` görürse testi düşürür; 08-rdp check'i active xrdp ister (K-22) | +| Lisanssız AnyDesk sahaya sızar | Düşük | Orta | Modül yalnız `ANYDESK_LICENSE_CONFIRMED=true` ile çalışır ve kendisi hiçbir şey kurmaz; lisans kaydı cihaz dışında tutulur (K-04) | ## 9. Uygulama Planı 1. Merkez VDS: `hbbs`/`hbbr` (`--net=host` önerilir) + MeshCentral kurulumu, health monitörü (Uptime Kuma) bağlanır. -2. Golden image: RustDesk istemcisi (self-hosted key + VDS adresi gömülü) + MeshCentral agent + xRDP kurulu gelir; dördü de systemd enable. +2. Golden image / Field OS medyası: RustDesk istemcisi (self-hosted key + VDS adresi gömülü) + MeshCentral agent + xRDP kurulu gelir; dördü de systemd enable. `xrdp`/`xrdp-sesman` her zaman enable+active kalır (K-22); `bf-gui-off` bunlara dokunmaz. 3. Cihaz kimliği (`bf-`, 02) RustDesk etiketi + MeshCentral adı + Ansible inventory adı olarak aynı yazılır. -4. Opsiyonel AnyDesk modülü: SADECE müşterinin sahasında geçerli ticari AnyDesk lisansı varsa, `blueforce-install.sh --with-anydesk` bayrağıyla kurulur; lisans anahtarı müşteri tarafından sağlanır, AMC lisans sağlamaz. Lisanssız cihaza kurulmaz. +4. Opsiyonel AnyDesk modülü (zorunlu mimarinin parçası DEĞİLDİR): depoda tek yeri `scripts/install/optional/anydesk/install-anydesk.sh`. Modül yalnızca `ANYDESK_LICENSE_CONFIRMED=true` verildiğinde devam eder ve **kasıtlı olarak hiçbir şey indirmez/kurmaz**; lisanslı paket kurumun kendi kontrollü sürecinden gelir, cihaz istisnası Blueforce installer'ının dışında kayda geçer. Lisanssız cihaza kurulmaz. ```bash # örnek: kanal durum denetimi (saha cihazında) -systemctl is-active wg-quick@wg0 xrdp rustdesk meshcentral 2>/dev/null +systemctl is-active wg-quick@wg0 xrdp xrdp-sesman rustdesk meshcentral 2>/dev/null ss -tlnp | grep -E ':(22|3389)' # yalnızca WG arayüzünde dinlemeli +# yerel GUI kapalıyken de RDP çalışmalı (K-22): +sudo bf-gui-off && systemctl is-active xrdp xrdp-sesman # ikisi de "active" dönmeli ``` ### Kanal seçim matrisi @@ -99,10 +105,11 @@ ss -tlnp | grep -E ':(22|3389)' # yalnızca WG arayüzünde dinlemeli | Durum | Kullanılacak kanal | |---|---| | Normal bakım (komut/config) | SSH-over-WireGuard | -| Grafik bakım (teknisyen) | xRDP + GNOME (WG üzerinden) | +| Grafik bakım (teknisyen) | xRDP + GNOME (WG üzerinden; **yerel GUI kapalı olsa da çalışır**, K-22) | | WireGuard çöktü, grafik lazım | RustDesk OSS (birincil yedek) | | WireGuard + RustDesk çöktü | MeshCentral terminal/dosya | | Otomasyon (filo) | Ansible-over-SSH (09), kanal değil araç | +| Sahada yerel fiziksel ekran gerekiyor | `bf-gui-on` (yalnız yerel GUI; RDP'yi etkilemez) | ### WG çökerse RustDesk analizi @@ -113,10 +120,12 @@ RustDesk istemcisi tünele değil VDS genel adresine (`hbbs`/`hbbr` portları) d | Test | Beklenen sonuç | Ortam | |---|---|---| | 4 kanalın normal durumda erişilebilirliği | Tümü OK | LAB(2) | +| `bf-gui-off` sonrası RDP oturumu | `xrdp`/`xrdp-sesman` active kalır, GNOME oturumu WG üzerinden açılır (K-22) | LAB(2) | | `wg-quick@wg0` stop → RustDesk oturumu | Bağlantı kurulur | LAB(2) | | WG down + RustDesk kapalı → MeshCentral terminal | Komut çalışır | P1(5) | | Güç kesintisi → 4 kanalın otomatik dönüşü | Tümü 5 dk içinde OK | LAB(2) | | RDP portu internetten tarama | Kapalı (yalnızca WG) | LAB(2) | +| Lisanssız AnyDesk denemesi | Modül kurulum yapmaz, `ANYDESK_LICENSE_CONFIRMED` yoksa durur | LAB(2) | ## 11. Rollback @@ -129,25 +138,29 @@ RustDesk istemcisi tünele değil VDS genel adresine (`hbbs`/`hbbr` portları) d - [ ] hbbs/hbbr + MeshCentral VDS'te ayakta, Uptime Kuma izliyor. - [ ] 4 servisin tamamı boot-enable, güç-kesinti testinden geçti. - [ ] SSH/RDP yalnızca WG arayüzünde dinliyor. +- [ ] `bf-gui-off` sonrası `xrdp` + `xrdp-sesman` hâlâ active (K-22 kanıtı kayıtlı). - [ ] RustDesk etiketleri `bf-` ile eşleşiyor. -- [ ] AnyDesk hiçbir lisanssız cihaza kurulu değil. +- [ ] AnyDesk hiçbir lisanssız cihaza kurulu değil; modül yalnız `scripts/install/optional/anydesk/` altında ve lisans onayı kapısıyla. ## 13. Açık Sorular - [ ] hbbs/hbbr ile MeshCentral aynı VDS'te mi, ayrı mı — yük testiyle karar (sahibi: 25-ROADMAP). - [ ] RustDesk self-hosted key rotasyon sıklığı (sahibi: 11-SECURITY yazarı). +- [ ] GNOME vs XFCE A/B ölçümünde "RDP güvenilirliği" ve "login screen erişimi" metriklerinin ölçüm yöntemi (sahibi: platform ekibi) [K-23]. +- [ ] RustDesk headless/katılımsız servis davranışının reboot sonrası kanıtı (her iki masaüstü kolunda) (sahibi: platform ekibi) [K-23]. --- -## Ek: Mermaid — kanal bağımsızlığı +## Ek: Mermaid — kanal bağımsızlığı ve RDP'nin GUI'den ayrılması ```mermaid flowchart LR PC["Saha bf-"] -->|WG tüneli| WG["WireGuard hub"] WG --> SSH["SSH / Ansible"] - WG --> RDP["xRDP + GNOME"] + WG --> RDP["xRDP + GNOME
(her zaman hazır, K-22)"] PC -->|doğrudan internet
VDS genel adresi| RD["RustDesk hbbs/hbbr"] PC -->|doğrudan internet
VDS genel adresi| MC["MeshCentral"] + GUI["bf-gui-on/off
(yalnız yerel fiziksel GUI)"] -.->|"KONTROL ETMEZ"| RDP WG -.->|çökerse| DOWN{"WG down?"} DOWN -->|evet| RD DOWN -->|evet| MC diff --git a/docs/08-WIREGUARD-AND-NETWORK.md b/docs/08-WIREGUARD-AND-NETWORK.md index 7400c37..eb225d6 100644 --- a/docs/08-WIREGUARD-AND-NETWORK.md +++ b/docs/08-WIREGUARD-AND-NETWORK.md @@ -76,7 +76,7 @@ Saha cihazları NAT/CGNAT arkasındadır; merkez onlara ulaşamaz, onlar merkeze 1. Merkez hub kurulur, subnet planı dondurulur (ör. `10.8.0.0/16`, hub `10.8.0.1`, cihazlara sabit `/32`). 2. Her cihaz için peer üretilir: adı `bf-<8hane>`, tünel IP'si envanter haritasından. 3. İstemci config'i (`/etc/wireguard/wg0.conf`): hub endpoint + `PersistentKeepalive = 25` + `wg-quick@wg0 enable`. -4. Peer ekleme/çıkarma Ansible rolüyle yapılır (09); hub config'i Git'te sürümlenir. +4. Peer ekleme/çıkarma yalnız 29-ENROLLMENT sonrasında Ansible rolüyle yapılır (09); hub config'i Git'te sürümlenir. `PROVISIONED_OFFLINE` cihaz peer/handshake doğrulanmadan `READY` olamaz. 5. PİLOT-1'de kopma sayacı koşar (handshake yaşı metriği 14'te), keepalive saha verisiyle ayarlanır. ```bash diff --git a/docs/14-MONITORING-AND-HEALTH.md b/docs/14-MONITORING-AND-HEALTH.md index 9f311e9..5093d90 100644 --- a/docs/14-MONITORING-AND-HEALTH.md +++ b/docs/14-MONITORING-AND-HEALTH.md @@ -91,7 +91,7 @@ LİSANS: systemd-journald / textfile (Apache-2.0) — https://github.com/prome 1. Merkez: Prometheus + Grafana OSS + Uptime Kuma kurulur. 2. Saha: Node Exporter + textfile cron/timer + Kuma Push kurulur. 3. Etiket şeması `device_id="BF-"` tüm metriklerde uygulanır. -4. `bf-status` / `bf-diagnostics` / `bf-support-bundle` sahaya dağıtılır. +4. `bf-status` / `bf-diagnostics` / `bf-support-bundle` sahaya dağıtılır. `bf-status`, yaşam döngüsü durumunu açık gösterir: offline kurulum `PROVISIONED_OFFLINE`, token sonrası `ENROLLED`, Prometheus/Kuma + WireGuard + erişim doğrulanınca `READY` (27/29). ```bash # saha: Node Exporter + textfile dizini (örnek) diff --git a/docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md b/docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md index 67d5735..ec5ecdf 100644 --- a/docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md +++ b/docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md @@ -29,7 +29,7 @@ LİSANS: Yok (donanım ayarı). ``` ```text -KARAR: Boot zinciri systemd bağımlılıklarıyla sıralanır: network → `wg-quick@wg0` → `docker.service` → MEG/uygulama birimleri → remote/monitoring ajanları. +KARAR: Boot zinciri systemd bağımlılıklarıyla sıralanır: network → `wg-quick@wg0` → `docker.service` → MEG/uygulama birimleri → remote/monitoring ajanları; `xrdp`/`xrdp-sesman` her zaman enable/ready kalır, yerel GNOME display-manager durumu bağımsızdır. GEREKÇE: Sırasız boot race üretir (Docker WG'den önce kalkar, MEG ağ bulamaz); `After=`/`Wants=` zinciri her açılışta aynı sırayı garanti eder. ALTERNATİF: Bağımlılıksız "hepsi enable" — açılışta rastgele sıra, elendi. RİSK: Birim adları 26.04.1'e göre değişebilir; azaltma: LAB imajında doğrulanmadan dondurulmaz (K-03 riski). diff --git a/docs/20-FIELD-TECHNICIAN-RUNBOOK.md b/docs/20-FIELD-TECHNICIAN-RUNBOOK.md index b2cd517..21e6d3c 100644 --- a/docs/20-FIELD-TECHNICIAN-RUNBOOK.md +++ b/docs/20-FIELD-TECHNICIAN-RUNBOOK.md @@ -101,8 +101,9 @@ Hostname: bf-12010193 bf-status ``` -3. Ekranda **READY** (veya tüm satırlar yeşil/OK) görmelisin. -4. Kontrol listesini doldur (§12), sahadan çık. +3. Offline medya ile kurulumda önce **PROVISIONED_OFFLINE** görmen normaldir; bu durumda cihaz READY değildir. İnternet geldiğinde merkezden tek-kullanımlık enrollment token iste ve 29'daki adımı uygula. +4. Token sonrası **ENROLLED** görünür; WireGuard, remote ve monitoring merkezi doğrulaması bitince **READY** görmelisin. +5. READY sonrası kontrol listesini doldur (§12), sahadan çık. ### B. Arızalı cihaza gittin — ilk 3 hareket diff --git a/docs/21-CENTRAL-ADMIN-RUNBOOK.md b/docs/21-CENTRAL-ADMIN-RUNBOOK.md index 9b6e254..3819e1d 100644 --- a/docs/21-CENTRAL-ADMIN-RUNBOOK.md +++ b/docs/21-CENTRAL-ADMIN-RUNBOOK.md @@ -102,7 +102,7 @@ ansible-playbook playbooks/update.yml --limit wave_p1 ### Günlük / haftalık rutinler - Günlük (operatör, 10 dk): Uptime Kuma UP/DOWN, Prometheus `up` == 0 listesi, gece reboot olanlar, yedek job durumu. -- Haftalık (yönetici, 30 dk): dalga takvimi, açık halt'lar, anahtar/envanter uyumsuzlukları, L9 tatbikat sayacı. +- Haftalık (yönetici, 30 dk): dalga takvimi, açık halt'lar, anahtar/envanter uyumsuzlukları, `PROVISIONED_OFFLINE`/`ENROLLED` bekleyen cihaz kuyruğu, release manifest/rollback kanıtı ve L9 tatbikat sayacı (27/29/30). ## 10. Test Planı diff --git a/docs/24-DECISION-LOG.md b/docs/24-DECISION-LOG.md index 5bea1bf..ababc7d 100644 --- a/docs/24-DECISION-LOG.md +++ b/docs/24-DECISION-LOG.md @@ -1,12 +1,13 @@ # 24 — Karar Günlüğü (Decision Log) -> Kısa özet: Blueforce 700 cihaz Linux filosunun tüm nihai mimari kararları tek tabloda; her karar KARAR/GEREKÇE/ALTERNATİF/RİSK/MALİYET/LİSANS bloğu + resmi kaynak URL ile. Diğer 25 dokümanın başvurduğu kilit dosyadır. +> Kısa özet: Blueforce 700 cihaz Linux filosunun tüm nihai mimari kararları tek tabloda; her karar KARAR/GEREKÇE/ALTERNATİF/RİSK/MALİYET/LİSANS bloğu + resmi kaynak URL ile. Diğer numaralı dokümanların başvurduğu kilit dosyadır (K-01…K-24). > > - Dosya: `docs/24-DECISION-LOG.md` > - İlgili kararlar: bu dosyanın kendisi kilit kaynaktır; `01-ARCHITECTURE.md` bu kararları diyagramlaştırır. > - Durum: [x] Onaylı (Faz 2 kilidi; değişiklik bu dosyadan PR ile yapılır) > - Kural: tüm kararlarda FREE sürümler baz alınır; Pro/Enterprise zorunlu mimariye girmez. -> - Doğrulama günü: 2026-09-15 (tüm URL'ler bu tarihte HTTP 200 ile çekildi; ayrıntı `research/` dosyalarında). +> - Doğrulama günü: 2026-09-15 (tüm URL'ler bu tarihte HTTP 200 ile çekildi). İkinci doğrulama turu: 2026-09-16 (Subiquity autoinstall referansı + GNOME/XFCE/xrdp/NoCloud URL'leri yeniden HTTP 200 ile doğrulandı); ayrıntı `research/` dosyalarında. +> - Numaralandırma kuralı: **append-only**. Mevcut K-01…K-19 silinmez/yeniden numaralanmaz (başka dokümanlar `#K-16`, `#K-17`, `#K-18`, `#K-19` başlıklarına link verir); yeni kararlar bir sonraki boş numaradan eklenir. Bu turda eklenen: K-20…K-24. --- @@ -16,7 +17,7 @@ ## 2. Kapsam -- Kapsam içi: OS/GUI/boot, 4 erişim kanalı, WireGuard, fleet, monitoring, docs, golden image, Docker, cihaz kimliği, update dalgaları — aşağıdaki K-01…K-14 kararları. +- Kapsam içi: OS/GUI/boot, 4 erişim kanalı (RDP her-zaman-hazır ayrımı dahil), WireGuard, fleet, monitoring, docs, golden image, Docker, cihaz kimliği, update dalgaları, Field OS ISO/medya stratejisi, offline provisioning, enrollment/durum modeli, release + Field OS sürüm kimliği ve Windows geçişi — aşağıdaki K-01…K-24 kararları. - Kapsam dışı: script kodları, VDS kurulum komutları, gerçek cihaz test sonuçları (Faz 3+ işi; ilgili dokümanlara havale edilir). ## 3. Kararlar @@ -37,12 +38,14 @@ LİSANS: Ubuntu LTS ücretsiz (açık kaynak bileşenler; ESM kapsamına bel b ```text KARAR: Saha masaüstü ortamı GNOME'dur (Ubuntu Desktop standardı); XFCE elendi. GEREKÇE: Kullanıcı kararı + Ubuntu Desktop standardı: tanıdık modern masaüstü, teknisyen akışıyla birebir uyum. -ALTERNATİF: XFCE — hafif ve düşük kaynak tüketimine rağmen kullanıcı kararıyla elendi; yedekte tutulmaz. +ALTERNATİF: XFCE — hafif ve düşük kaynak tüketimine rağmen kullanıcı kararıyla elendi; ancak K-23 uyarınca LAB A/B ölçümü için yedekte tutulur (ölçüm sonucu bu kararı revize edebilir; saha standardı ölçüm bitene kadar GNOME kalır). RİSK: xRDP+GNOME kombinasyonu ek ayar isteyebilir (uyarı: GNOME oturumu RDP'de ek yapılandırma gerektirebilir); azaltma: 13-MEG-LINUX-ACCEPTANCE testi GNOME üzerinde koşar, `bf-gui-*` LAB'da 26.04.1 imajında doğrulanmadan dondurulmaz. MALİYET: Ücretsiz. -LİSANS: GNOME (GPL/LGPL bileşenler), ücretsiz — https://www.gnome.org/ (doğrulanma: 2026-09-15). +LİSANS: GNOME (GPL/LGPL bileşenler), ücretsiz — https://www.gnome.org/ (doğrulanma: 2026-09-15; yeniden doğrulama: 2026-09-16, HTTP 200). ``` +> **Güncelleme (2026-09-16):** K-02 geçerli kalır; yalnız **K-23** ile ölçüme bağlanır. Saha standardı GNOME'dur; XFCE saha standardı değildir, LAB A/B ölçüm adayıdır. İki karar çelişmez: baseline = GNOME, revizyon kapısı = LAB verisi. + ### K-03 — Boot davranışı ve GUI aç/kapa ```text @@ -54,6 +57,8 @@ MALİYET: Ücretsiz. LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15). ``` +> **Güncelleme (2026-09-16):** `bf-gui-on` / `bf-gui-off` yalnız **yerel fiziksel** grafik katmanı (display-manager + default target) yönetir; `xrdp`/`xrdp-sesman` her zaman enable/active kalır ve RDP bu anahtarlara bağlı değildir — ayrıntı ve kanıt: **K-22**. + ### K-04 — Uzaktan erişim kanalları (4 kanal) ```text @@ -120,6 +125,118 @@ MALİYET: Ücretsiz. LİSANS: Ubuntu autoinstall/subiquity (ücretsiz, Ubuntu lisans seti içinde) — https://releases.ubuntu.com/26.04/ (doğrulanma: 2026-09-15). NOT: Faz 1'de resmi autoinstall sözdizimi ayrıca doğrulanmadı; kesin direktifler 04-GOLDEN-IMAGE'da LAB çıktısıyla yazılır. ``` +### K-15 — Blueforce Field OS kurulum medyası + +```text +KARAR: Blueforce Field OS = Ubuntu Server 26.04.1+ LTS üzerinde GNOME katmanı; V1 kurulum medyası resmi upstream ISO + ayrı NoCloud seed USB'dir. Autoinstall ve custom Field ISO tamamlayıcıdır; custom ISO yalnız LAB build artefact'ı olarak ele alınır. +GEREKÇE: Upstream ISO + seed, cihaz-spesifik veriyi medyadan ayırır ve remaster boot riskini V1'den uzak tutar. Custom ISO dağıtım kolaylığı sağlayabilir ancak UEFI/Legacy/Secure Boot davranışı kanıtlanmadan standart değildir. +ALTERNATİF: Yalnız custom remaster ISO — doğrulanmamış boot zinciri nedeniyle V1 için elendi; custom build gelecekte tamamlayıcıdır. +RİSK: 26.04 autoinstall veya remaster davranışı değişir; azaltma: her release'te checksum + LAB matrisi. +MALİYET: Ücretsiz. +LİSANS: Ubuntu Server — https://ubuntu.com/download/server ; Subiquity — https://canonical-subiquity.readthedocs-hosted.com/en/latest/ ; NoCloud — https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html (26.04 kesin davranışı LAB gerektirir). +``` + +### K-16 — Assisted offline provisioning durumu + +```text +KARAR: İnternetsiz assisted provisioning imzalı/sürümlü ISO, seed ve offline APT snapshot ile yapılır; başarılı yerel sonuç yalnız PROVISIONED_OFFLINE'dır. +GEREKÇE: Saha bağlantısı yokken kurulum tamamlanabilir, ancak merkezi kimlik/kanal doğrulaması olmadan READY iddiası güvenli değildir. +ALTERNATİF: İnternet gelene kadar kurmamak — saha blokajı nedeniyle elendi; rastgele .deb taşıma — bağımlılık/sürüm belirsizliği nedeniyle elendi. +RİSK: Eski snapshot veya medya secret taşıması; azaltma: manifest/checksum, medya secret taraması, sürüm son kullanma tarihi. +MALİYET: Ücretsiz. +LİSANS: APT — https://manpages.ubuntu.com/manpages/noble/en/man8/apt.8.html ; dpkg — https://manpages.ubuntu.com/manpages/noble/en/man1/dpkg.1.html (26.04 offline akışı LAB gerektirir). +``` + +### K-17 — Cihaz enrollment ve READY kapısı + +```text +KARAR: PROVISIONED_OFFLINE cihaz, BF-'ya bağlı kısa ömürlü tek-kullanımlık token ile ENROLLED olur; yalnız WireGuard, merkezi erişim ve monitoring kanalları doğrulanınca READY olur. +GEREKÇE: Token cihaz kimliğini bağlar, fakat bağlantı/health kanıtı değildir. Ayrı durumlar yanlış teslimi ve ortak bootstrap secret riskini önler. +ALTERNATİF: Kalıcı ortak token — sızıntı blast-radius'u filo geneli olduğu için elendi; manuel kayıt — denetim zayıf olduğu için elendi. +RİSK: Token çalınması/yanlış kullanım; azaltma: TTL, atomik tek kullanım, cihaz bağlama, iptal ve audit kaydı. +MALİYET: Ücretsiz. +LİSANS: OpenSSH — https://www.openssh.com/ ; WireGuard — https://www.wireguard.com/ ; token API kurum içi uygulama ve LAB doğrulamasıdır. +``` + +### K-18 — Field OS release ve rollback yönetimi + +```text +KARAR: Field OS release'i immutable manifestli bir artefact setidir: upstream ISO referansı, seed, offline APT snapshot, installer/Ansible commit'i, uygulama sürümleri ve checksum'lar birlikte sürümlenir; rollback önceki onaylı release'e döner. +GEREKÇE: OS sürümü tek başına davranışı açıklamaz; release manifesti tekrarlanabilir kurulum, denetim ve gerçek rollback hedefi sağlar. +ALTERNATİF: latest/yalnız Git branch — artefact bileşimi ve rollback hedefi belirsiz olduğu için elendi. +RİSK: Eksik artefact veya test edilmemiş rollback; azaltma: manifest kapısı, checksum/imza ve LAB rollback kanıtı. +MALİYET: Ücretsiz. +LİSANS: Git — https://git-scm.com/doc ; Ubuntu releases — https://releases.ubuntu.com/ ; Docker image sürümleme — https://docs.docker.com/reference/cli/docker/image/pull/. +``` + +### K-19 — Windows'tan Linux'e geçiş + +```text +KARAR: Her Windows saha cihazında önce envanter + hash doğrulanmış geri dönüş imajı alınır, sonra temiz Field OS kurulumu ve kabul yapılır; V1'de yerinde dönüşüm ve dual-boot yoktur. +GEREKÇE: Temiz kurulum drift'i azaltır; geri dönüş imajı uygulama/periferik kabulü başarısız olduğunda kanıtlı çıkış verir. +ALTERNATİF: Yerinde dönüşüm veya dual-boot — disk/bootloader karmaşıklığı ve destek yükü nedeniyle elendi. +RİSK: Gizli iş/veri bağımlılığı; azaltma: iş sahibi onayı, image restore provası ve 13-MEG kabul kapısı. +MALİYET: Ücretsiz araçlar kullanılabilir; image depolama altyapı maliyetidir. +LİSANS: Ubuntu — https://ubuntu.com/download/server ; Clonezilla — https://clonezilla.org/ ; Windows lisans/uygulama durumu kurum tarafından doğrulanır. +``` + +> **2026-09-16 turu — yeni kararlar (K-20…K-24).** Aşağıdaki bloklar bu turda eklendi. Numaralar append-only'dir: K-16 (offline provisioning durumu), K-17 (enrollment), K-18 (release), K-19 (Windows geçişi) **silinmedi/yeniden numaralanmadı**; `docs/27`, `docs/29`, `docs/30` ve `docs/28` bu başlıklara link verir. İstenen konularla eşleşme: "medya stratejisi güncellemesi" → K-20; "durum modeli" → K-21 (K-16/K-17 ile birlikte okunur); "RDP her zaman hazır" → K-22; "GNOME vs XFCE A/B ölçümü" → K-23; "Field OS sürüm yönetimi" → K-24 (K-18 ile birlikte okunur). + +### K-20 — Medya stratejisi: autoinstall kurulum motoru, Blueforce Field OS ISO paketleme yöntemidir + +```text +KARAR: Autoinstall kurulum MOTORUDUR; Blueforce Field OS ISO ise PAKETLEME/DAĞITIM yöntemidir. İkisi rakip değildir, birlikte kullanılır: Field OS ISO içinde autoinstall + offline APT repo + firstboot bulunur. Güvenlik için autoinstall'da `interactive-sections: [storage]` KORUNUR — otomatik disk silme YOKTUR (operatör disk onayı verir). Teslim sırası: F2B'de assisted tek-USB Field OS ISO üretilir; upstream ISO + NoCloud seed USB geri dönüş yoludur. K-15 geçersiz kılınmaz, yedek yol olarak korunur. +GEREKÇE: Subiquity resmi referansı: autoinstall yapılandırması `/autoinstall.yaml` yolunda okunur ("irrespective of how it was provided") → ISO köküne konan autoinstall dosyası desteklenir. `interactive-sections` resmi olarak desteklenir ("A list of configuration keys to still show in the user interface"). `storage` bölümü resmi olarak "can be interactive: true" işaretlidir ve varsayılanı tek-disk sistemde lvm layout'tur; yani storage interactive bırakılmazsa kurulum en büyük diski kendiliğinden bölümler. Bu yüzden tek-USB deneyimi ile "otomatik disk silme yok" güvenlik şartı AYNI ANDA sağlanır: ISO autoinstall'ı başlatır, teknisyen yalnız disk adımını onaylar. Ayrıca `apt.fallback` varsayılanı `offline-install` ("revert to an offline installation") olduğu için internetsiz kurulum tasarım gereği çalışır. +ALTERNATİF: Yalnız upstream ISO + NoCloud seed USB (tek medya deneyimi yok; prompt hedefi karşılanmaz → V1'in tek yolu olmaktan çıkar, yedek/geri dönüş yolu olarak kalır); Yalnız custom remaster ISO (UEFI/Legacy/Secure Boot boot zinciri LAB'da kanıtlanmadan sahaya çıkar → reddedildi); otomatik disk wipe'lı unattended (veri kaybı riski → yasak, F6 kapsamı). +RİSK: Remaster boot zinciri (UEFI/Legacy/Secure Boot) LAB'da kanıtlanmazsa saha USB'si açılmayabilir; azaltma: F2B çıkış kriteri = tek USB assisted kurulumun iki LAB cihazında kanıtı; kanıt yoksa F3 başlamaz ve upstream ISO + seed yolu sıcak tutulur. +MALİYET: Ücretsiz. +LİSANS: Subiquity autoinstall — https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html (interactive-sections; `/autoinstall.yaml` yolu; `apt.fallback` default `offline-install`; `storage` "can be interactive: true" — doğrulama: 2026-09-16, HTTP 200); cloud-init NoCloud — https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html (doğrulama: 2026-09-16); Ubuntu Server ISO — https://ubuntu.com/download/server. +``` + +### K-21 — Cihaz durum modeli (tek kaynak): PROVISIONED_OFFLINE → ENROLLED → READY + +```text +KARAR: Cihaz yaşam döngüsü ÜÇ durumdur ve tek kaynağı `/var/lib/blueforce/state.json` + `bf-enrollment-status` çıktısıdır: PROVISIONED_OFFLINE (offline kurulum bitti, merkez doğrulaması yok) → ENROLLED (tek kullanımlık token tüketildi, WireGuard atomik kuruldu) → READY (merkezi monitoring/remote/management kanıtı + yerel WG/xRDP/RustDesk kontrolleri). READY TEK DURUM DEĞİLDİR: "işletim sistemi kuruldu" READY değildir, "token alındı" READY değildir. K-16 ve K-17 bu modelin parçalarıdır; K-21 ikisini tek sözleşmede sabitler. +GEREKÇE: Kod ve testler aynı sözleşmeyi uygular: `blueforce-install.sh --offline` başarıyla biterse state.json'a `phase=PROVISIONED_OFFLINE`, `enrollment_status=PENDING`, `fleet_status=PENDING` yazar; `bf-enrollment-status` yalnız bu üç durumu geçerli sayar (aksi halde `INVALID`/`PENDING` + exit≠0); `bf-enroll` yalnız PROVISIONED_OFFLINE/ENROLLED fazından enrollment kabul eder; `--offline` olmadan bu damga yazılmaz. `bf-check-ready` kalıcı READY değerini okumaz, güncel handshake ve merkez kanıtını bağımsız denetler; kanıt eksikse fail-closed kalır. +ALTERNATİF: İkili model (kuruldu/kurulmadı) — "merkezce güvenildi" farkını görünmez kılar ve yanlış teslim üretir → elendi; READY'yi installer çıkış kodu yapmak — ağ/merkez kanıtı olmadan READY iddiası doğurur → reddedildi. +RİSK: Merkez kanıt alanları (`central_verification`) `pending` kalırsa cihaz ENROLLED'da bekler ve teslim gecikir; azaltma: enrollment yanıt sözleşmesi zorunlu alanları (29), fail-closed READY kapısı, 24/72 saat gözlem penceresi (IMPLEMENTATION-REPORT §9). +MALİYET: Ücretsiz. +LİSANS: systemd/journald (LGPL bileşenler) — https://www.freedesktop.org/software/systemd/man/latest/systemctl.html ; token API kurum içi uygulamadır (27/29). +``` + +### K-22 — RDP her zaman hazır; `bf-gui-on` / `bf-gui-off` yalnız yerel fiziksel GUI'yi kontrol eder + +```text +KARAR: `xrdp` ve `xrdp-sesman` multi-user seviyesinde HER ZAMAN enable ve active kalır; RDP kanalı `bf-gui-on` komutuna bağlı DEĞİLDİR. `bf-gui-on` / `bf-gui-off` yalnız YEREL FİZİKSEL grafik katmanını (display-manager: gdm3/gdm/sddm/lightdm + default target) kontrol eder. `bf-gui-off` xRDP servislerini asla durdurmaz veya disable etmez. Başka bir deyişle: GUI kapalı ≠ RDP kapalı. +GEREKÇE: Depodaki kod bu ayrımı uygular: `08-rdp` modülü xrdp+xrdp-sesman'ın enable VE active olmasını, default target'ın multi-user kalmasını doğrular; `bf-gui-off` yalnız display-manager'ı durdurur ve "xRDP stays enabled and active so remote recovery is available" sözleşmesini taşır; `tests/check-specs.sh` bf-gui-off içinde `systemctl stop/disable xrdp*` görürse testi bilinçli olarak düşürür ve 08-rdp check'i active xrdp ister; `16-POWER-LOSS` boot zinciri de xrdp'in her zaman ready olduğunu varsayar. Bu ayrım 700 cihazda yerel GUI kapalıyken de uzaktan kurtarma kanalını korur. +ALTERNATİF: RDP'yi `bf-gui-on`'a bağlamak — GUI kapalı saha cihazında uzaktan grafik kurtarma kaybolur (sahaya gitme oranı artar) → reddedildi; her zaman grafik boot — kaynak israfı ve geniş hata yüzeyi (K-03) → elendi. +RİSK: GNOME oturumu RDP üzerinden ek yapılandırma isteyebilir; azaltma: 13-MEG kabulü GNOME üzerinde koşar; LAB'da 26.04.1 imajında xRDP+GNOME ve bf-gui-* doğrulanmadan dondurulmaz. +MALİYET: Ücretsiz. +LİSANS: xRDP Apache-2.0 (v0.10.6.1) — https://github.com/neutrinolabs/xrdp (doğrulama: 2026-09-16, HTTP 200); GNOME GPL/LGPL — https://www.gnome.org/ (doğrulama: 2026-09-16, HTTP 200). +``` + +### K-23 — GNOME saha standardı kalır; LAB'da GNOME vs XFCE A/B ölçümü yapılır + +```text +KARAR: Saha masaüstü ortamı GNOME'dur (K-02 yürürlükte kalır, geçersiz kılınmaz). Buna ek olarak LAB'da GNOME vs XFCE A/B ölçümü yapılacak ve karar ölçüm sonucuna bağlanacaktır. Ölçüm seti sabittir: (1) RAM tüketimi, (2) CPU tüketimi, (3) boot süresi, (4) RDP güvenilirliği, (5) RustDesk'in reboot sonrası davranışı, (6) login screen erişimi, (7) dummy display davranışı, (8) 24 saat ve 72 saat stabilite. RustDesk headless ZORUNLU şartı her iki kolda da aynen korunur. Ölçüm sonucu K-02'yi revize edebilir; veri gelmeden revizyon yapılmaz. +GEREKÇE: GNOME kullanıcı kararıdır; ancak saha koşulları (RDP oturumu + headless RustDesk + düşük RAM'li mini PC'ler) ölçülmeden "tek doğru masaüstü" iddiası kanıtsız kalır. A/B ölçümü kararı kanıta bağlar: baseline GNOME olarak sabit kalır, XFCE yalnız ölçüm adayıdır. Bu yazım K-02 ile çelişmez — K-02 sahada geçerli olan karardır, K-23 onu ölçüm kapısına bağlayan revizyon mekanizmasıdır. +ALTERNATİF: XFCE'yi doğrudan saha standardı yapmak — kullanıcı kararı dışı ve kanıtsız → elendi; XFCE'yi tamamen kapatmak — PİLOT'ta kanıtsız revizyon talebi doğurur ve ölçüm şansı kaybolur → elendi. +RİSK: A/B ölçümü LAB zamanı ve iki masaüstü bakım yükü getirir; azaltma: ölçüm seti ve süreleri (24/72 saat) sabittir, sonuç tek tabloda tutulur ve LAB release kaydına bağlanır; XFCE kolu saha medyasına girmez. +MALİYET: Ücretsiz. +LİSANS: GNOME GPL/LGPL — https://www.gnome.org/ ; XFCE GPL — https://xfce.org/ (her ikisi de doğrulama: 2026-09-16, HTTP 200; XFCE yalnız LAB ölçüm adayıdır, saha standardı değildir). +``` + +### K-24 — Field OS sürüm kimliği: `/etc/blueforce-release` + `bf-release` + +```text +KARAR: Cihazın Field OS sürüm kimliği tek komutla okunur: `bf-release` (kaynak sırası: `BF_RELEASE_MANIFEST` → `/etc/blueforce/release-manifest.yaml` → legacy `/etc/blueforce-release` → repo içi `provisioning/release/manifest.yaml`). Manifest `status: skeleton` ise komut sürümü **UNAPPROVED_SKELETON** olarak raporlar ve sıfırdan farklı döner; skeleton release DAĞITILABİLİR release sayılmaz. Sürüm kimliği, K-18'deki immutable release manifestiyle (ISO referansı, seed, offline APT snapshot, installer/Ansible commit'i, checksum'lar) eşleşmek zorundadır. `/etc/blueforce-release` nonsecret bir cihaz-üstü kimlik dosyasıdır: token, private key veya parola içermez. +GEREKÇE: Sahada "hangi Field OS sürümü çalışıyor?" sorusu secret sızdırmadan, tek komutla ve denetlenebilir biçimde yanıtlanmalıdır. Depodaki davranış bunu uygular: `17-healthcheck` kurulumda `provisioning/release/manifest.yaml` dosyasını `/etc/blueforce/release-manifest.yaml` olarak kurar; `bf-release` YAML ve legacy KEY=VALUE biçimini okuyabilir, okunamayan/`skeleton` manifestte exit≠0 verir ve `tests/check-specs.sh` bu ret davranışını (`UNAPPROVED_SKELETON`) zorunlu tutar. Böylece sahte/iskelet sürüm kimliği cihazda "release var" izlenimi üretemez. +ALTERNATİF: Yalnız `git tag` — sahada checkout yoktur, cihaz kendi sürümünü okuyamaz → elendi; yalnız `lsb_release`/`os-release` — yalnız Ubuntu tabanını gösterir, Field OS katmanını ve release bileşimini göstermez → elendi. +RİSK: AÇIK KONU: `/etc/blueforce-release` bugün yalnız OKUMA yoludur (legacy biçim için uyumluluk var) ve onu yazan bir release pipeline adımı depoda yok; bu dosya yazılmazsa cihaz kimliği `/etc/blueforce/release-manifest.yaml`'a (kurulum anındaki iskelet manifest) düşer ve `bf-release` UNAPPROVED_SKELETON döner. Azaltma: gerçek release üretildiğinde manifestin status'u `released` olacak ve yazma adımı 30-FIELD-OS-RELEASE-MANAGEMENT sahibi tarafından eklenmelidir. +MALİYET: Ücretsiz. +LİSANS: Git — https://git-scm.com/doc ; Ubuntu releases — https://releases.ubuntu.com/ ; Docker image sürümleme — https://docs.docker.com/reference/cli/docker/image/pull/ . +``` + ### K-10 — Docker restart politikası ve imaj disiplini ```text @@ -186,7 +303,7 @@ Tüm kararlarda aynı üç filtre uygulandı: (1) %100 ücretsiz/self-hosted — | Alternatif | Artı | Eksi | Sonuç | |---|---|---|---| | Ubuntu 24.04 LTS | Olgun donanım desteği | 26.04 doğrulandı, daha kısa destek penceresi | Yedek | -| XFCE | Hafif, düşük kaynak | Kullanıcı kararıyla elendi (saha standardı GNOME) | Elendi | +| XFCE | Hafif, düşük kaynak | Kullanıcı kararıyla saha standardı olmadı (GNOME baseline) | LAB A/B ölçüm adayı (K-23) | | AnyDesk | Tanınmış, kolay | Ticari lisans zorunlu (700 cihaz), kapalı kaynak | Elendi | | Tailscale/ZeroTier | Kolay mesh | Freemium, merkezi kimlik | Elendi | | AWX | Güçlü UI | K8s zorunlu, release'ler duraklatıldı | Elendi | @@ -203,15 +320,18 @@ Tüm kararlarda aynı üç filtre uygulandı: (1) %100 ücretsiz/self-hosted — ## 6. Avantajlar -- 14 kararın tamamı ücretsiz katmanda kanıtlı; zorunlu mimaride lisans bedeli yok. +- 24 kararın tamamı ücretsiz katmanda kanıtlı; zorunlu mimaride lisans bedeli yok. - Her kararın resmi kaynak URL'si + sürümü + doğrulama tarihi var; varsayıma dayalı karar yok (belirsizler LAB/PİLOT'a havale edildi). - 4 erişim kanalından ikisi WireGuard-bağımsız; tek tünel arızası filoyu kör etmez. +- Tek-USB Field OS medyası (K-20) ile tek-medya deneyimi sağlanırken otomatik disk silme yasağı (interactive storage) korunur. ## 7. Dezavantajlar - K-09 (golden image) Faz 1'de tam resmi-doğrulamalı değil; autoinstall sözdizimi LAB çıktısına bağımlı. - Semaphore Community'de OIDC/2FA/Vault yokluğu operasyonel disiplinle (VPN-arkası + Key Store) kapatılıyor; bu disiplin bozulursa güvenlik açığı doğar. - Prometheus 700 node işletme bilgisi ister; retention/kardinalite LAB'da ölçülmeden donanım siparişi verilmemeli. +- K-20 ile tek-USB Field OS ISO hedeflenir, fakat remaster boot zinciri LAB'da kanıtlanana kadar saha medyası upstream ISO + seed olarak kalır (ikili medya geçiş dönemi). +- K-24 açık konusu: `/etc/blueforce-release` dosyasını yazan release pipeline adımı henüz yok; cihaz sürüm kimliği iskelet manifest'e düşerse `bf-release` UNAPPROVED_SKELETON döner. ## 8. Riskler @@ -223,6 +343,9 @@ Tüm kararlarda aynı üç filtre uygulandı: (1) %100 ücretsiz/self-hosted — | 26.04.x sürücü farkı heterojen donanımda | Orta | Orta | LAB'da profil başına imaj testi (K-01/K-09) | | Tek VDS iki bağımsız kanalı da barındırırsa ortak arıza | Düşük | Yüksek | hbbs/hbbr + MeshCentral ayrı VDS veya yedek planı (K-04) | | 700 node Prometheus kardinalite patlaması | Orta | Orta | 60 sn scrape, 30–90 gün retention, `BF-` etiket şeması (K-07) | +| Remaster Field OS ISO boot etmez (UEFI/Legacy/Secure Boot) | Orta | Yüksek | F2B çıkış kapısı: 2 LAB cihazında assisted kurulum kanıtı; aksi halde F3 başlamaz, upstream ISO + seed yolu sıcak (K-20) | +| Skeleton manifest ile "release var" izlenimi | Düşük | Orta | `bf-release` skeleton manifestte exit≠0 (UNAPPROVED_SKELETON); check-specs bu davranışı zorunlu tutar (K-24) | +| GNOME/XFCE kararı kanıtsız revize edilir | Düşük | Orta | A/B ölçüm seti sabit; revizyon yalnız LAB verisiyle ve PR ile (K-23) | ## 9. Uygulama Planı @@ -248,6 +371,10 @@ hostnamectl set-hostname bf-12010193 | `docker inspect` restart politikası | `unless-stopped` tüm saha konteynerlerinde | LAB(2) | | Semaphore kapalı iken Ansible CLI run | Playbook başarıyla koşar | LAB(2) | | Scrape + 17 metrik + Uptime Push | Grafana + Kuma panoları dolu | P1(5) | +| `bf-gui-off` sonrası RDP oturumu | xRDP active kalır, GNOME oturumu açılır (K-22) | LAB(2) | +| `bf-release` çıktısı (skeleton manifest) | `UNAPPROVED_SKELETON` + exit≠0; sahte sürüm gösterilmez (K-24) | LAB(2) | +| Tek-USB Field OS ISO assisted kurulum | Autoinstall başlar, storage adımı operatör onayı bekler, disk kendiliğinden silinmez (K-20) | LAB(2) UEFI+Legacy | +| GNOME vs XFCE A/B ölçümü | 8 metrik tablosu dolar; karar LAB verisiyle verilir (K-23) | LAB(2) | ## 11. Rollback @@ -257,10 +384,12 @@ hostnamectl set-hostname bf-12010193 ## 12. Kontrol Listesi -- [ ] 14 kararın her birinde KARAR/GEREKÇE/ALTERNATİF/RİSK/MALİYET/LİSANS + kaynak URL var. +- [ ] 24 kararın her birinde KARAR/GEREKÇE/ALTERNATİF/RİSK/MALİYET/LİSANS + kaynak URL var. - [ ] Ücretli hiçbir araç zorunlu mimaride değil; AnyDesk hükmü resmi URL'li. - [ ] `unattended-upgrades`, `latest`, Watchtower yasakları üç dosyada da tutarlı (03/10/12). - [ ] Ek B'deki 27 sorunun her biri ≥1 karara bağlı (boş hücre yok). +- [ ] Ek B'ye eklenen S-28…S-37 (Field OS soruları) da ≥1 karara bağlı. +- [ ] RDP/durum modeli/medya/GNOME ölçümü/sürüm kimliği konularında ikinci tur kararları (K-20…K-24) ile K-02/K-03/K-15/K-16/K-17/K-18 arasında çelişki yok. ## 13. Açık Sorular @@ -270,6 +399,10 @@ hostnamectl set-hostname bf-12010193 - [ ] Digest pinleme (`@sha256`) kararı — PİLOT-2 değerlendirmesi (sahibi: 12-DOCKER yazarı). - [ ] Prometheus retention/kardinalite hesabı (700 × 60 sn) — LAB ölçümü (sahibi: 14-MONITORING yazarı). - [ ] Semaphore Global Runner sayısı + eşzamanlılık — 700 node yük testi (sahibi: 09-FLEET yazarı). +- [ ] GNOME vs XFCE A/B ölçüm seti ve sonuç tablosu (8 metrik, 24/72 saat) — LAB 2 cihaz (sahibi: platform ekibi + 03 yazarı) [K-23]. +- [ ] F2B tek-USB Field OS ISO: iso builder'ın gerçek üretime geçirilmesi + UEFI/Legacy/Secure Boot LAB matrisi — LAB (sahibi: provisioning/release yöneticisi) [K-20]. +- [ ] Offline APT repo somut paket pinleri (base-packages/remote-access/docker manifestleri) — LAB (sahibi: release yöneticisi) [K-20/K-16]. +- [ ] `/etc/blueforce-release` yazma adımının release pipeline'a eklenmesi — 30 release yönetimi (sahibi: release yöneticisi) [K-24]. --- @@ -309,7 +442,7 @@ hostnamectl set-hostname bf-12010193 ## Ek B: 27 Soru → Karar İzlenebilirliği -> NOT: `SUMMARY.md` Faz 4'te üretilecek; aşağıdaki S-01…S-27 listesi şartname + plandaki 27 kritik sorunun Faz 2 karşılığıdır. SUMMARY yazıldığında her cevap buradaki karara linklenecek. +> NOT: `SUMMARY.md` artık üretilmiştir (2026-09-15/16); aşağıdaki S-01…S-27 listesi şartname + plandaki 27 kritik sorunun Faz 2 karşılığıdır ve SUMMARY'deki aynı sorular bu kararlara linklenir. 2026-09-16 turunda SUMMARY'ye eklenen 10 Field OS sorusu S-28…S-37 olarak listelenmiştir. | # | Soru | Karar | Doküman | |---|---|---|---| @@ -341,18 +474,35 @@ hostnamectl set-hostname bf-12010193 | S-26 | Cihaz kimliği standardı ne? | K-12 (`BF-` / `bf-`) | 02 | | S-27 | Doküman platformu hangisi? | K-08 (Docusaurus) | 22 | +### Ek B-2: 10 Field OS sorusu → karar (2026-09-16 turu) + +| # | Soru | Karar | Doküman | +|---|---|---|---| +| S-28 | Blueforce Field OS nedir? | K-01 + K-02 + K-20 (Ubuntu LTS üzerinde GNOME katmanlı, tek-USB paketlenebilen saha OS standardı) | 00, 01, 26, 31 | +| S-29 | ISO nasıl build edilir? | K-20 (autoinstall motoru + Field OS ISO paketleme; `provisioning/iso/build-blueforce-iso.sh` — bugün iskelet, gerçek build açık) | 26, 31 | +| S-30 | Offline kurulum nasıl çalışır? | K-16 + K-20 (`apt.fallback=offline-install` + offline APT snapshot + `--offline`) | 27, 31 | +| S-31 | Device state'leri nedir? | K-21 (PROVISIONED_OFFLINE → ENROLLED → READY) | 27, 29 | +| S-32 | Internet olmadan ne olur? | K-16 + K-21 (yerel kurulum tamam, durum PROVISIONED_OFFLINE; READY iddiası yok) | 27 | +| S-33 | Internet gelince ne olur? | K-17 + K-21 (tek kullanımlık token → ENROLLED; WG/erişim/monitoring doğrulaması → READY) | 29 | +| S-34 | Enrollment nasıl olur? | K-17 (`BF-` bağlı, TTL'li, tek kullanımlık token; `bf-enroll --token-stdin`) | 29 | +| S-35 | Windows'tan geçmeden önce ne yedeklenir? | K-19 (envanter + hash doğrulamalı geri dönüş imajı + veri dışa aktarma) | 28 | +| S-36 | Rollback nasıl olur? | K-18 + K-20 + K-22 (önceki onaylı release manifesti; gerekirse upstream ISO + onaylı seed ile yeniden kurulum) | 30, 26 | +| S-37 | Field OS version nasıl takip edilir? | K-24 (`/etc/blueforce-release` + `bf-release`; skeleton manifest reddedilir) | 30 | + --- ## Ek: Karar Haritası (hangi soru hangi karara?) ```mermaid flowchart LR - SORU["27 soru (Ek B)
S-01…S-27"] --> ZEMIN["Zemin: K-01/K-02/K-03
K-09/K-10/K-11/K-12"] + SORU["37 soru
S-01…S-27 + S-28…S-37"] --> ZEMIN["Zemin: K-01/K-02/K-03
K-09/K-10/K-11/K-12"] SORU --> AG["Ağ + erişim: K-04/K-05/K-13"] SORU --> OPS["Operasyon: K-06/K-07/K-14"] SORU --> DOC["Docs: K-08"] + SORU --> FIELD["Field OS: K-15/K-16/K-17/K-18/K-19
K-20/K-21/K-22/K-23/K-24"] ZEMIN --> D1["03/04/05/10/12/02"] AG --> D2["07/08/06/11"] OPS --> D3["09/14/15"] DOC --> D4["22"] + FIELD --> D5["26/27/28/29/30/31
+ 24 bu dosya"] ``` diff --git a/docs/25-IMPLEMENTATION-ROADMAP.md b/docs/25-IMPLEMENTATION-ROADMAP.md index 352b747..3e130d6 100644 --- a/docs/25-IMPLEMENTATION-ROADMAP.md +++ b/docs/25-IMPLEMENTATION-ROADMAP.md @@ -1,28 +1,28 @@ # 25 — Uygulama Yol Haritası (Implementation Roadmap) -> Kısa özet: Blueforce 700 cihaz projesinin FAZ'lı yol haritası: FAZ 0 hazırlık → FAZ 1 merkez → FAZ 2 imaj → FAZ 3 LAB/P1 → FAZ 4 dalgalar → FAZ 5 PROD + işletme. Katılımsız provisioning (unattended) ayrı FAZ'dır; temel akış çalışmadan otomasyona geçilmez. +> Kısa özet: Blueforce 700 cihaz projesinin FAZ'lı yol haritası: F0 Windows + donanım keşfi → F1 merkez altyapı → F2A Blueforce bootstrap stabilizasyon → F2B Assisted Offline Field ISO → F3 LAB (2 cihaz) → F4 P1-P2-W1-W2 dalgaları → F5 production rollout → F6 gerçek zero-touch PXE. Katılımsız/PXE otomasyonu YALNIZ F6'dır; F2B assisted (operatör disk onaylı) tek-USB medyadır ve erken fazların hiçbirinde otomatik disk silme yoktur. - Dosya: `docs/25-IMPLEMENTATION-ROADMAP.md` -- İlgili kararlar: `24-DECISION-LOG.md#K-09` (imaj + unattended sırası), `#K-11` (dalgalar), tüm K-01…K-14 (fazlara dağılım) +- İlgili kararlar: `24-DECISION-LOG.md#K-01`…`#K-24`; özellikle `#K-09` (golden image), `#K-11` (dalgalar), `#K-15`/`#K-20` (medya stratejisi), `#K-16`/`#K-17`/`#K-21` (offline + durum modeli), `#K-18`/`#K-24` (release + sürüm kimliği) - Durum: [ ] Taslak --- ## 1. Amaç -Projeyi "ne, hangi sırada, hangi kapıdan geçerek" sorusuyla yönetmek. Her FAZ'ın girişi, çıkışı ve sahibi bellidir; bir FAZ kapanmadan sonrakine iş taşınmaz. +Projeyi "ne, hangi sırada, hangi kapıdan geçerek" sorusuyla yönetmek. Her FAZ'ın girişi, çıkışı ve sahibi bellidir; bir FAZ kapanmadan sonrakine iş taşınmaz. F2A/F2B ayrımı, "kurucu ve bootstrap çalışıyor" ile "tek USB'lik Field OS medyası üretildi" işlerinin farklı risk sınıfları olduğunu görünür kılar. ## 2. Kapsam -- Kapsam içi: FAZ tanımı, sıra ve bağımlılıklar, her FAZ'ın çıkış kriteri, unattended provisioning FAZ'ının yeri, doküman-faz eşleşmesi. +- Kapsam içi: FAZ tanımı, sıra ve bağımlılıklar, her FAZ'ın çıkış kriteri, assisted (F2B) ile zero-touch (F6) provisioning'in yeri, doküman-faz eşleşmesi. - Kapsam dışı: dalga cihaz sayıları detayı (18), update onay akışı (10), komut-seviyesi kurulum (03/04/05). ## 3. Kararlar ```text -KARAR: Yol haritası 7 FAZ'dır: F0 Hazırlık+Envanter → F1 Merkez VDS → F2 Golden image (katılımlı) → F3 LAB+P1 → F4 Dalgalar (P2/W1/W2) → F5 PROD+İşletme → F6 Katılımsız provisioning (unattended, ayrı FAZ). -GEREKÇE: Sıra bağımlılıkları zorlar: envanter olmadan imaj (K-09), imaj olmadan LAB, LAB olmadan dalga (K-11) olmaz. Unattended provisioning ayrı FAZ'dır çünkü katılımlı akış (USB→script→bayi no) sahada kanıtlanmadan PXE/preseed otomasyonu ek yük ve ek arıza yüzeyi demektir. -ALTERNATİF: Unattended'i en başa koyma — temel akıştaki hata otomasyonla çarpılır; elendi. Tek-faz "hepsini yap" planı — kapısız ilerleme, filo-çapı risk; elendi. +KARAR: Yol haritası 8 adımdır (7 FAZ + F2 ikiye ayrılır): F0 Windows + donanım keşfi → F1 Merkez altyapı → F2A Blueforce bootstrap stabilizasyon → F2B Assisted Offline Field ISO → F3 LAB (2 cihaz, uçtan uca) → F4 P1-P2-W1-W2 dalgaları → F5 Production rollout (~523) + işletme/release yönetimi → F6 Gerçek zero-touch PXE. F6 YALNIZ PXE / tam unattended / uzaktan (remote) provisioning içindir; assisted kurulum F2B'de biter ve otomatik disk silme hiçbir fazda yoktur. +GEREKÇE: Sıra bağımlılıkları zorlar: Windows/donanım keşfi olmadan imaj (K-09), merkez altyapı olmadan enrollment/WG (K-05/K-17), bootstrap stabil olmadan medya (F2A→F2B), medya kanıtlanmadan LAB kabulü (F2B→F3), LAB kabulü olmadan dalga (K-11) olmaz. F2A/F2B ayrımı riskleri ayırır: F2A "kurucu idempotent ve kapıları çalışıyor" iddiasıdır (upstream ISO + NoCloud seed ile sınanır), F2B ise "tek USB Field OS ISO assisted kurar" iddiasıdır (remaster boot zinciri riski buradadır). F6'nın ayrı ve son FAZ olması, zero-touch/PXE'nin iptal edilebilir (proje yine de biter) ama atlanamaz (sırası gelmeden yapılmaz) olduğunu söyler. +ALTERNATİF: Zero-touch PXE'i en başa koymak — kanıtlanmamış akışın otomasyonu hata çarpanı üretir, üstelik otomatik disk silme veri kaybı riski taşır → elendi (F6'ya taşındı). Tek-faz "hepsini yap" planı — kapısız ilerleme, filo-çapı risk → elendi. F2A/F2B'yi birleştirmek — medya riski ile kurucu riski aynı kapıya sıkışır ve hangi iddianın kanıtlandığı belirsizleşir → elendi. RİSK: FAZ atlama baskısı (takvim); azaltma: her FAZ çıkışı 21'deki onay yetkilisinin imzasına bağlıdır. MALİYET: Ücretsiz (işgücü planlaması; VDS kira hariç). LİSANS: Yok. @@ -30,25 +30,28 @@ LİSANS: Yok. ## 4. Neden Bu Karar? -Klasik hata sırası tersine çevirmektir: önce otomasyon, sonra temel. Bu harita bilerek "sıkıcı" sırayı seçer — önce elle çalışan, sonra kendiliğinden çalışan. F6'nın ayrı FAZ olması, unattended'in iptal edilebilir (proje yine de biter) ama atlanamaz (sırası gelmeden yapılmaz) olduğunu söyler. Her FAZ bir karar grubunu hayata geçirir; harita ile 24-DECISION-LOG arasındaki bağ tabloyla sabittir. +Klasik hata sırası tersine çevirmektir: önce otomasyon, sonra temel. Bu harita bilerek "sıkıcı" sırayı seçer — önce elle çalışan, sonra kendiliğinden çalışan. F6'nın ayrı FAZ olması zero-touch'un iptal edilebilir ama atlanamaz olduğunu söyler. F2A/F2B ayrımı ise aynı mantığı içeride uygular: **assisted** medya (teknisyen disk onayı verir, `interactive-sections: [storage]`) ile **unattended** medya (F6, operatör girdisi yok) karıştırılmaz. F2B'nin assisted kalması prompt'un "otomatik disk silme yok" şartının faz seviyesindeki karşılığıdır (K-20). Her FAZ bir karar grubunu hayata geçirir; harita ile 24-DECISION-LOG arasındaki bağ tabloyla sabittir. ## 5. Alternatifler | Alternatif | Artı | Eksi | Sonuç | |---|---|---|---| -| Unattended ilk FAZ | Erken otomasyon | Kanıtlanmamış akışın otomasyonu | Elendi (F6'ya taşındı) | +| Zero-touch/PXE ilk FAZ | Erken otomasyon | Kanıtlanmamış akışın otomasyonu + otomatik disk silme riski | Elendi (F6'ya taşındı) | +| Tek F2 (bootstrap + medya birlikte) | Daha az faz | Kurucu ve boot zinciri riski aynı kapıda karışır | Elendi (F2A/F2B) | | Az FAZ (3 büyük) | Kısa plan | Kapılar seyrek, risk büyük | Elendi | | Çok FAZ (12+) | Detaylı | Yönetim yükü, faz yorgunluğu | Elendi | ## 6. Avantajlar -- Her FAZ bağımsız kapanır; proje yarıda kalsa bile kazanım (örn. F1 merkezi) kalır. -- F6 opsiyoneldir: katılımlı akışla PROD bitebilir, unattended sonradan eklenir. +- Her FAZ bağımsız kapanır; proje yarıda kalsa bile kazanım (örn. F1 merkezi, F2A stabil kurucu) kalır. +- F6 opsiyoneldir: assisted akışla (F2B→F5) filo kurulabilir, zero-touch sonradan eklenir. +- Assisted/unattended ayrımı sayesinde "otomatik disk silme yok" güvenlik şartı faz yapısında görünür kalır. ## 7. Dezavantajlar -- FAZ disiplini yavaş görünür; paydaşa "neden F6 en sonda" açıklaması gerekir (gerekçe §4'tür). +- FAZ disiplini yavaş görünür; paydaşa "neden zero-touch en sonda" açıklaması gerekir (gerekçe §4'tür). - FAZ'lar arası bekleme (gözetim süreleri) takvimi uzatır. +- F2A ve F2B iki ayrı kapı demektir; tek kapılı plana göre bir faz daha fazla onay toplantısı gerekir. ## 8. Riskler @@ -56,25 +59,35 @@ Klasik hata sırası tersine çevirmektir: önce otomasyon, sonra temel. Bu hari |---|---|---|---| | FAZ çıkışı atlanır, sonrakine geçilir | Orta | Yüksek | Onay yetkilisi imzası zorunlu (21) | | Merkez VDS donanımı yetersiz çıkar (F1) | Orta | Orta | LAB yük testi; servisleri ikinci VDS'e bölme planı (01) | -| Donanım profili imajı kırar (F2→F3) | Orta | Orta | F0 envanteri + F3 profil matrisi | +| Windows keşfi eksik yapılır, geçişte sürpriz bağımlılık çıkar (F0→F3) | Orta | Yüksek | İş sahibi onayı + hash doğrulamalı geri dönüş imajı (28) | +| Donanım profili imajı kırar (F2B→F3) | Orta | Orta | F0 envanteri + F3 profil matrisi | +| Remaster Field OS ISO boot etmez (F2B) | Orta | Yüksek | UEFI/Legacy/Secure Boot matrisi; aksi halde F3 başlamaz ve upstream ISO + seed yolu sıcak tutulur (26) | +| F6'da otomatik provisioning veri siler | Düşük | Yüksek | F6 yalnız boş/teslim edilmiş disklere uygulanır; assisted (F2B) yol her zaman yedek | ## 9. Uygulama Planı | FAZ | Ad | Kararlar | Çıkış kriteri | Dokümanlar | |---|---|---|---|---| -| F0 | Hazırlık + donanım envanteri | K-12 (kimlik), K-09 (tarama-önce) | Profil listesi + envanter şeması onaylı | 02, 18 (FAZ 0), 00 | -| F1 | Merkez VDS kurulumu | K-04, K-05, K-06, K-07, K-08 | WG hub + hbbs/hbbr + MeshCentral + Semaphore + Prometheus/Kuma ayakta; L9 tatbikatı yapıldı | 06, 07, 08, 09, 14, 17-L9 | -| F2 | Golden image (katılımlı) | K-01, K-02, K-03, K-09, K-10, K-11 | `blueforce-install.sh` USB→READY akışı LAB donanımında çalışıyor; `unattended-upgrades` kapalı; tag/`unless-stopped` denetli | 03, 04, 05, 12 | -| F3 | LAB(2) + P1(5) | K-11 (dalga), K-14 (bundle), K-05 (kopma sayacı) | 3 akış yeşil; teknisyen desteksiz kurulum yaptı; kopma sayacı toplandı | 18, 19, 20, 10 | -| F4 | Dalgalar P2/W1/W2 | K-11, K-06, K-07 | Her dalga çıkış kriteri + halt kaydı; destek yükü hedefte | 18, 21, 14 | -| F5 | PROD (~523) + işletmeye alma | Tümü | Filo READY; rutinler (günlük/haftalık) devrede; `v1` doküman kesildi | 21, 22, 23 | -| F6 | Katılımsız provisioning (unattended) | K-09 (hedef) | Teknisyen girdisiz (veya minimum-girdili) kurulum P2 ölçeğinde kanıtlı; katılımlı akış yedekte duruyor | 04 (F6 eki) | +| F0 | Windows + donanım keşfi | K-12 (kimlik), K-19 (Windows envanteri), K-09 (tarama-önce) | Her saha PC için envanter (üretici/model/CPU/RAM/disk/NIC/GPU/seri no/bayi no) + Windows iş yükü ve geri dönüş imajı listesi onaylı | 02, 18, 28 | +| F1 | Merkez altyapı | K-04, K-05, K-06, K-07, K-08 | WG hub + hbbs/hbbr + MeshCentral + Semaphore + Prometheus/Grafana/Kuma ayakta; L9 tatbikatı yapıldı | 06, 07, 08, 09, 14, 17-L9 | +| F2A | Blueforce bootstrap stabilizasyon | K-01, K-03, K-09, K-10, K-11, K-12, K-13, K-22 | `blueforce-install.sh` upstream ISO + NoCloud seed ile idempotent ve tekrar çalıştırılabilir; 18 modül ve `--check` kapıları yeşil; `bf-status`/`bf-enrollment-status` beklenen durumu yazıyor; xRDP kalıcı aktif (K-22) | 03, 04, 05, 06, 11, 12, 16 | +| F2B | Assisted Offline Field ISO | K-15, K-16, K-20, K-21, K-24 | Tek USB `Blueforce-Field-OS--amd64.iso` UEFI+Legacy'de boot ediyor; autoinstall çalışıyor; storage adımı operatör onayı bekliyor (otomatik silme yok); offline APT snapshot'tan kurulum bitiyor; firstboot bayi no alıp `PROVISIONED_OFFLINE` yazıyor; `bf-release` gerçek release kimliği döndürüyor | 05, 26, 27, 30 | +| F3 | LAB (2 cihaz) | K-11, K-14, K-05, K-17, K-18, K-19, K-21, K-23 | 2 LAB cihazında uçtan uca: erişim + boot + update + enrollment + Windows geçiş kabulü + rollback yeşil; teknisyen desteksiz kurulum yapabildi; GNOME vs XFCE A/B ölçüm tablosu dolduruldu | 18, 19, 20, 10, 28, 29, 30, 31 | +| F4 | Dalgalar P1-P2-W1-W2 | K-11, K-06, K-07, K-14 | P1(5) → P2(20) → W1(50) → W2(100): her dalga çıkış kriteri + halt kaydı; destek yükü hedefte | 18, 21, 14 | +| F5 | Production rollout (~523) + işletme/release yönetimi | Tümü | Filo READY; rutinler (günlük/haftalık), immutable release manifest ve rollback kanıtı devrede; `v1` doküman kesildi | 21, 22, 23, 30 | +| F6 | Gerçek zero-touch PXE | K-20 (unattended kolu), K-18 | YALNIZ PXE / tam unattended / uzaktan provisioning: ağ üzerinden operatör girdisi olmadan kurulum + otomatik disk yapılandırması; assisted (F2B) yolu geri dönüş olarak sıcak | 26, 30 | -F6 notu: F6, F5'ten sonra başlar; F5'in kapanmasına engel değildir. F6 kapsamı (PXE/preseed derinliği, ağ gereksinimleri) F3 verisine göre F4 içinde netleştirilir. F6 başarısız olursa proje F5 haliyle tamam sayılır. +Akış/bağımlılık notları: + +- F2A → F2B kapısı: "kurucu stabil" kanıtı yoksa tek-USB medya üretimi başlamaz (medya, kurucunun test ortamıdır; tersi değil). +- F2B → F3 kapısı: assisted tek-USB medya iki LAB cihazında (UEFI + Legacy) kanıtlanmazsa LAB kabulü başlamaz. +- F6, F5'ten sonra başlar ve F5'in kapanmasına engel değildir; F6 başarısız olursa proje F5 haliyle tamam sayılır ve assisted akış yedek kalır. ```bash # örnek: FAZ kapısı kontrolü (merkez) -# F3 çıkışı için LAB doğrulama seti +# F2B/F3 çıkışı için LAB doğrulama seti +bf-release # gerçek release kimliği (skeleton değil) +bf-enrollment-status # PROVISIONED_OFFLINE → ENROLLED → READY ansible-playbook playbooks/verify-lab.yml --limit lab bf-support-bundle --out /tmp/f3-gate-bundle.tar.gz ``` @@ -83,21 +96,27 @@ bf-support-bundle --out /tmp/f3-gate-bundle.tar.gz | Test | Beklenen sonuç | Ortam | |---|---|---| +| F0 Windows/donanım keşfi ve imaj listesi | Her cihaz için profil + geri dönüş imajı kaydı var | Saha/LAB | | F1 L9 tatbikatı (boş VDS'e kur) | Tüm merkez servisleri ayağa kalkar | İzole VDS | -| F2 imaj denetim seti (K-10/K-11 kilitleri) | `latest` yok, timer'lar maskeli, politika `unless-stopped` | LAB(2) | -| F3 3-akış + rollback | Erişim/boot/update + geri alma yeşil | LAB(2)/P1(5) | -| F6 unattended prova | Minimum-girdi ile READY | P2 ölçeği | +| F2A kurucu idempotent çalışma | İkinci koşu hata üretmez, `--check` yeşil | LAB(2) | +| F2B tek-USB assisted kurulum | UEFI + Legacy boot; storage onayı beklenir; `PROVISIONED_OFFLINE` | LAB(2) | +| F2B offline kurulum (air-gapped) | Paketler yerel snapshot'tan kurulur, dış indirme yok | LAB(2) air-gapped | +| F3 3-akış + enrollment + rollback | Erişim/boot/update + ENROLLED/READY + geri alma yeşil | LAB(2) | +| F3 GNOME vs XFCE A/B | 8 metrik tablosu dolar (RAM/CPU/boot/RDP/RustDesk/login/dummy/24-72h) | LAB(2) | +| F6 zero-touch PXE prova | PXE ile operatör girdisi olmadan READY'ye kadar akış | F5 sonrası LAB | ## 11. Rollback 1. FAZ içi hata: ilgili dalga/iş 17-RECOVERY veya 10-UPDATE zinciriyle geri alınır; FAZ tekrarlanır. -2. FAZ kararı yanlışsa (örn. imaj yöntemi): 24-DECISION-LOG PR'ı + bağımlı FAZ'ların tekrarı. -3. F6 geri alınırsa katılımlı akışa dönülür (yedek her zaman sıcak tutulur). +2. FAZ kararı yanlışsa (örn. medya veya masaüstü yöntemi): 24-DECISION-LOG PR'ı + bağımlı FAZ'ların tekrarı. +3. F2B geri alınırsa upstream ISO + NoCloud seed yoluna dönülür (bu yol her zaman sıcak tutulur). +4. F6 geri alınırsa assisted (F2B) akışa dönülür; PXE'siz filo işletilmeye devam eder. ## 12. Kontrol Listesi - [ ] Her FAZ'ın çıkış kriteri + sahibi yazılı ve onaylı. -- [ ] F6'nın F5 sonrası ayrı FAZ olduğu paydaşça kabul edildi. +- [ ] F2A/F2B ayrımı ve F2B'nin assisted (otomatik disk silme yok) olduğu paydaşça kabul edildi. +- [ ] F6'nın yalnız PXE / tam unattended / uzaktan provisioning olduğu ve F5 sonrası ayrı FAZ olduğu kabul edildi. - [ ] Doküman-faz eşleşmesi (tablodaki son sütun) güncel. - [ ] Onay yetkilisi (asıl+vekil) tanımlı (21). @@ -105,7 +124,10 @@ bf-support-bundle --out /tmp/f3-gate-bundle.tar.gz - [ ] FAZ tarihleri ve teknisyen kapasite planı (sahibi: yönetim + 18). - [ ] F1 VDS donanım siparişi için LAB yük ölçümü (sahibi: 14-MONITORING yazarı). -- [ ] F6 kapsamı (PXE şart mı, USB-preseed yeter mi) — F3 verisi sonrası (sahibi: 04 yazarı). +- [ ] F2B gerçek ISO build'i ve UEFI/Legacy/Secure Boot matrisi — `provisioning/iso/build-blueforce-iso.sh` bugün iskelet (sahibi: provisioning/release yöneticisi). +- [ ] F2B offline APT snapshot için somut paket pinleri (base-packages/remote-access/docker manifestleri) (sahibi: release yöneticisi). +- [ ] F0 Windows keşif kapsamı: hangi uygulama/veri sınıfları zorunlu (sahibi: iş sahibi + 28 yazarı). +- [ ] F6 kapsamı (PXE şart mı, USB-preseed yeter mi, otomatik disk yapılandırması hangi onayla) — F5 verisi sonrası (sahibi: 04 yazarı). --- @@ -113,10 +135,12 @@ bf-support-bundle --out /tmp/f3-gate-bundle.tar.gz ```mermaid flowchart LR - F0["F0 Hazırlık
+ envanter"] --> F1["F1 Merkez VDS"] - F1 --> F2["F2 Golden image
(katılımlı)"] - F2 --> F3["F3 LAB 2 + P1 5"] - F3 --> F4["F4 P2/W1/W2"] - F4 --> F5["F5 PROD + işletme"] - F5 --> F6["F6 Unattended
(ayrı FAZ)"] + F0["F0 Windows +
donanım keşfi"] --> F1["F1 Merkez
altyapı"] + F1 --> F2A["F2A Blueforce
bootstrap stabil."] + F2A --> F2B["F2B Assisted Offline
Field OS ISO"] + F2B --> F3["F3 LAB
2 cihaz"] + F3 --> F4["F4 P1-P2-W1-W2"] + F4 --> F5["F5 Production
rollout ~523"] + F5 --> F6["F6 Zero-touch PXE
(ayrı FAZ)"] + F2B -. "geri dönüş" .-> F0 ``` diff --git a/docs/26-BLUEFORCE-FIELD-OS-ISO.md b/docs/26-BLUEFORCE-FIELD-OS-ISO.md new file mode 100644 index 0000000..234936e --- /dev/null +++ b/docs/26-BLUEFORCE-FIELD-OS-ISO.md @@ -0,0 +1,153 @@ +# 26 — Blueforce Field OS ISO + +> Kısa özet: Blueforce Field OS, Ubuntu LTS tabanında GNOME katmanı olan saha standardıdır. **Autoinstall kurulum motorudur; Blueforce Field OS ISO ise paketleme/dağıtım yöntemidir** — ikisi rakip değil, birlikte kullanılır (K-20). **V1 kurulum medyası TEK USB Field OS ISO'dur** (medya kökünde `autoinstall.yaml` + offline APT repo + firstboot; `interactive-sections: [storage]` korunur, otomatik disk silme yoktur). Resmi upstream ISO + ayrı NoCloud seed USB, tek yol değil **geri dönüş/fallback yolu** olarak sıcak tutulur (K-15 yedek yol olarak korunur). ISO ilk kez gerçekten üretilmiştir: `dist/Blueforce-Field-OS-1.0.0-amd64.iso` (6 481 917 952 byte, ISO SHA256 `cb0cc56f…d431296`); boot kabulü (UEFI/Legacy/Secure Boot) ve air-gapped kurulum hâlâ LAB işidir. +> +> - Dosya: `docs/26-BLUEFORCE-FIELD-OS-ISO.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-20` (medya stratejisi), `#K-15` (yedek medya yolu), `#K-16`/`#K-21` (offline + durum modeli), `#K-01`, `#K-02`, `#K-03`, `#K-09` +> - Durum: [ ] Taslak — ISO artefaktı üretildi; boot ve air-gapped kurulum kabulü LAB ile yapılmalıdır + +--- + +## 1. Amaç + +Field OS'un ne olduğunu, hangi ISO'nun sahaya gideceğini ve ISO'nun hangi sınırlar içinde üretilebileceğini sabitlemek. + +## 2. Kapsam + +- Kapsam içi: V1 tek-USB Field OS ISO, autoinstall motoru, offline APT repo, firstboot, NoCloud seed (geri dönüş yolu), medya bütünlüğü, üretilen artefaktın doğrulama kaydı ve LAB kabulü. +- Kapsam dışı: cihaz kimliği/enrollment işlemi (29), offline paket seti içeriği (27), sürüm yayını (30), PXE/tam unattended provisioning (F6, 25). + +## 3. Kararlar + +```text +KARAR: V1 kurulum medyası TEK USB Blueforce Field OS ISO'dur: `dist/Blueforce-Field-OS--amd64.iso`. ISO kökünde `/autoinstall.yaml` (kurulum motoru), `/blueforce-provisioning/` (autoinstall seed kopyası, firstboot, offline-repo araçları/pinleri, release) bulunur; boot konfigürasyonu her kernel satırında autoinstall ister. Autoinstall ile custom ISO birbirinin alternatifi DEĞİL tamamlayıcısıdır (K-20): ISO, autoinstall motorunu paketler. Güvenlik için `interactive-sections: [storage]` KORUNUR — kurulum katılımsız ilerler ama diski operatör seçip onaylar; otomatik disk silme YOKTUR. Resmi Ubuntu ISO + ayrı NoCloud seed USB, V1'in tek yolu değil geri dönüş/fallback yoludur ve her zaman sıcak tutulur (K-15 bu rolde korunur). +GEREKÇE: Tek USB, saha teknisyeni için tek medya demektir (yanlış seed takma hatası ortadan kalkar) ve offline APT repo ile firstboot aynı medyada taşınır. Subiquity resmi referansı autoinstall yapılandırmasını `/autoinstall.yaml` yolundan okur ("irrespective of how it was provided") ve `interactive-sections`'ı resmi olarak destekler; `apt.fallback` varsayılanı `offline-install`'dır. Remaster boot zinciri ise LAB kabulüne bağlanmıştır: ISO üretilse bile UEFI/Legacy/Secure Boot ve air-gapped kurulum kanıtı gelmeden saha medyası ilan edilmez. +ALTERNATİF: Yalnız upstream ISO + NoCloud seed USB — tek-medya deneyimi yok; V1'in tek yolu olmaktan çıktı, geri dönüş yolu olarak kalır. Yalnız remaster ISO + LAB kanıtsız saha — boot zinciri kanıtlanmadan dağıtım riski nedeniyle reddedildi. Otomatik disk wipe'lı unattended ISO — veri kaybı riski nedeniyle yasak (F6 kapsamı, onay kapılı). +RİSK: Remaster boot zinciri (UEFI/Legacy/Secure Boot) veya air-gapped kurulum kanıtlanmazsa medya sahaya çıkamaz; azaltma: F2B çıkış kapısı (iki LAB cihazında assisted kurulum kanıtı), aksi halde upstream ISO + seed yoluna dönülür. İkinci risk: ISO'ya secret/kimlik sızması; azaltma: builder'ın giriş taraması + artefakt secret/cihaz verisi taraması. +MALİYET: Ücretsiz. +LİSANS: Ubuntu resmi ISO — https://releases.ubuntu.com/26.04/ ; Subiquity autoinstall — https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html (doğrulama: 2026-09-16, HTTP 200); cloud-init NoCloud — https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html (doğrulama: 2026-09-16); xorriso (El Torito recipe) — https://www.gnu.org/software/xorriso/ . +``` + +> **Güncelleme (2026-09-16, K-20/K-21):** Bu dosyanın eski §3/§5/§12 ifadeleri ("V1 kurulum medyası upstream ISO + ayrı seed USB'dir", "yalnız custom remaster ISO V1 için elendi") geçersizdir. Kanonik karar: **V1 = tek USB Field OS ISO (autoinstall motoru olarak kullanılır); upstream ISO + seed = yedek/geri dönüş yolu.** Ayrıca ISO artık **gerçekten üretilmiştir** (bkz. §9 "Üretilen artefakt"). Ayrıntı: `24-DECISION-LOG.md#K-20`. + +## 4. Neden Bu Karar? + +Autoinstall kurulum otomasyonudur; Field OS ISO ise dağıtım **paketleme** biçimidir — ISO, autoinstall'ın yerine geçmez, onu taşır. Tek medya saha değişkenlerini (offline repo, firstboot, release manifesti) tek taşıma noktasında birleştirir ve yanlış seed takma hatasını ortadan kaldırır; cihaz-spesifik veri (bayi no, key, token) yine medyada tutulmaz, firstboot'ta toplanır. Ayrı seed USB ise tamamen kaldırılmaz: ISO boot etmezse aynı sonucu veren sıcak geri dönüş yoludur. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Tek USB Field OS ISO (remaster, autoinstall + offline repo + firstboot) | Tek medya; teknisyen hatası azalır; offline içerik yanında | UEFI/Legacy/Secure Boot LAB yükü | **V1 seçildi (F2B)** | +| Upstream ISO + NoCloud seed USB | En az özel build; imzalı upstream taban | İki medya taşınır | Geri dönüş/fallback yolu (sıcak) | +| Yalnız remaster ISO (LAB kanıtı olmadan) | — | Boot zinciri kanıtlanmamış medya sahaya çıkar | Reddedildi | +| Clonezilla | Hızlı | Donanım-fragil | Yedek yöntem | + +## 6. Avantajlar + +- İmzalı upstream taban; değişiklik alanı ISO kökü + `/blueforce-provisioning/` ile sınırlı, mevcut boot dosyaları (boot.catalog, eltorito.img, casper/*) bayt-özdeş bırakılır. +- Field OS sürümü, medya manifesti (`dist/-manifest.yaml`), release manifesti ve kurucu sürümü ayrı izlenebilir. +- Offline APT repo + firstboot + release dosyaları aynı medyada; internet olmayan sahada tek USB yeter (K-16). + +## 7. Dezavantajlar + +- Remaster boot zinciri UEFI/Legacy/Secure Boot'ta kanıtlanmadan medya sahaya çıkamaz; bu, F2B çıkış kapısını bloke edebilir. +- Medya tek noktada tüm saha değişkenlerini taşır: bozuk/kopyalanmış ISO yanlış sürüm yayar → SHA256 doğrulaması ve sürüm etiketi zorunludur. +- LAB medyası şu an Ubuntu **Desktop** flavour'ıyla üretildi; baseline (Server) ile sapma manifestte açıkça kayıtlıdır (bkz. §9). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| 26.04 autoinstall davranışı değişir | Orta | Yüksek | Her sürüm LAB kabulü | +| Remaster boot etmez (UEFI/Legacy/Secure Boot) | Orta | Yüksek | UEFI/Legacy/Secure Boot matrisi; aksi halde medya yayınlanmaz, upstream ISO + seed yoluna dönülür | +| ISO'ya secret/kimlik girer | Düşük | Yüksek | Builder giriş taraması + artefakt secret ve cihaz verisi taraması | +| Medya tabanı sapması (Desktop flavour) sahaya fark edilmeden çıkar | Orta | Orta | `manifest.yaml` içinde `base_flavor` + `baseline_deviation` bloğu, `media-info.yaml` ve `/etc/blueforce-release` aynasında; işletim öncesi Server ISO ile yeniden build kararı | +| Offline repo medyaya gömülmediği için kurulum `OFFLINE BLOCKED` ile durur | Yüksek (mevcut durum) | Orta | `offline_repo_status: not-included` açıkça raporlanır; `--require-offline-repo` release build'lerinde build'i durdurur | + +## 9. Uygulama Planı + +1. Base ISO'nun SHA256 bilgisini Canonical kaynak kaydından al ve yerel yayın kaydına yaz (`provisioning/iso/ubuntu-26.04.1-SHA256SUMS`). +2. Medyayı üret (`provisioning/iso/build-blueforce-iso.sh`); üretim sırasında girilen `autoinstall.yaml` + firstboot + offline-repo + release payload'ları medyaya yazılır, `interactive-sections: [storage]` ve `apt.fallback: offline-install` işaretleri korunur. +3. Medyaya bayi no, WireGuard private key, enrollment token veya SSH private key KOYMA; builder giriş taraması bunları bulursa durur. +4. Kurulumdan sonra cihaz `PROVISIONED_OFFLINE` olur; merkezi doğrulama tamamlanmadan `READY` olmaz (K-21). +5. Release yolunda medya manifesti + checksum + test raporu üretilir; cihaz verisi taşınmaz. + +```bash +# medyayı üret (base ISO ve checksum kaydı yerelde olmalı; hiçbir şey indirilmez) +provisioning/iso/build-blueforce-iso.sh \ + --upstream-iso /media/ubuntu-26.04.1-desktop-amd64.iso \ + --checksum-file provisioning/iso/ubuntu-26.04.1-SHA256SUMS +# üretilen artefaktı YAZMADAN doğrula (checksum + El Torito metadata) +provisioning/iso/test-iso.sh dist/Blueforce-Field-OS-1.0.0-amd64.iso +sha256sum -c dist/Blueforce-Field-OS-1.0.0-amd64.iso.sha256 +``` + +### Üretilen artefakt (2026-09-16 — gerçek üretim, mock değil) + +| Alan | Değer | +|---|---| +| ISO | `dist/Blueforce-Field-OS-1.0.0-amd64.iso` | +| ISO boyutu | 6 481 917 952 byte (~6.48 GB, 6.04 GiB) | +| ISO SHA256 | `cb0cc56f0442fbd1418e0d0f35521522f4ec9aa3f203779a67e96d587d431296` | +| Checksum dosyası | `dist/Blueforce-Field-OS-1.0.0-amd64.iso.sha256` (aynı değer, yerelde `sha256sum -c` ile doğrulandı) | +| Manifest | `dist/1.0.0-manifest.yaml` (`release_status: lab-artifact`, `build_date: 2026-09-16T08:00:29Z`) | +| Base medya | Ubuntu 26.04.1 **desktop** amd64 (`ubuntu-26.04.1-desktop-amd64.iso`, SHA256 `601e30fbf5d97759367c632e2c33630665039b7e2158fd068403da3ccf1bda1f` — build host'ta yeniden hesaplanıp doğrulandı) | +| Boot | El Torito **BIOS + UEFI** girdileri base ISO'dan korunmuş (builder build sonrası xorriso ile yeniden doğrular; eksikse artefaktı siler) | +| Live içerik bütünlüğü | `casper/minimal.squashfs` 3 432 136 704 byte ve SHA256 `b5ec27b9570e77abb62831a8b652b50eaf089d537e62046189a52231a4fa9bec` — base ISO'daki dosyayla **birebir aynı** | +| Autoinstall | Medya kökünde `/autoinstall.yaml`; `interactive-sections: [storage]` aktif, `apt_fallback: offline-install` | +| Payload | `/blueforce-provisioning/{autoinstall,firstboot,offline-repo,release}` medyada mevcut | +| Secret durumu | `device_identity_embedded: false`, `automatic_disk_wipe: false`; kimlik firstboot'ta toplanır | +| Baseline sapması | Medya **Desktop** flavour ile üretildi (baseline Server). `manifest.yaml` → `baseline_deviation` bloğu: gerekçe (build host'ta checksum'ı doğrulanmış tek medya Desktop ISO'ydu, build indirme yapmaz), etki (GNOME zaten gelir), gereken karar (LAB için Desktop tabanını kabul et **veya** üretim öncesi Server ISO ile yeniden build) | +| Henüz yapılmayanlar | UEFI/Legacy/Secure Boot boot kabulü, Secure Boot imza davranışı, air-gapped tam kurulum, offline APT repo gömülmesi (`offline_repo_status: not-included`, pinler `UNPINNED`) — hepsi LAB/F2B işi | + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Base ISO checksum | Yayın kaydıyla eşleşir (`601e30fb…`) | LAB(2) | +| Üretilen ISO checksum | `sha256sum -c dist/*.iso.sha256` geçer | LAB(2)/CI | +| ISO içerik bütünlüğü | `casper/*` dosyaları base ISO ile bayt-özdeş; `boot.catalog`/`eltorito.img` korunmuş | CI (xorriso) + LAB | +| Tek-USB assisted kurulum | Kimliksiz sistem kurulur; storage adımı operatör onayı bekler, otomatik silme yok | LAB(2) UEFI+Legacy | +| Field OS ISO UEFI/Legacy/Secure Boot | Her desteklenen profil boot eder | Zorunlu LAB (F2B çıkış kapısı) | +| Air-gapped kurulum (offline repo gömülüyken) | Dış indirme yok; `PROVISIONED_OFFLINE` | LAB(2) air-gapped | +| Geri dönüş yolu (upstream ISO + seed) | Aynı sonucu verir, sıcak tutulur | LAB(2) | +| Artefact taraması | Cihaz kimliği/secret yok; `device_identity_embedded: false` | Build CI + LAB | + +## 11. Rollback + +1. Field OS ISO başarısızsa (boot etmez, autoinstall almaz) derhal upstream ISO + son onaylı seed USB yoluna dönülür; bu yol her zaman sıcak tutulur (K-20). +2. Hatalı ISO/seed geri çekilir, önceki onaylı medya sürümü (sürüm etiketli USB) kullanılır. +3. Cihaz kurulmuş fakat kabul edilmemişse 27/29 prosedürüyle yeniden provision edilir. + +## 12. Kontrol Listesi + +- [ ] Base ISO checksum ve kaynak URL kaydı var; üretilen ISO checksum'ı yayımlanmış. +- [ ] Medya genel yapılandırma dışında cihaz verisi/secret içermiyor (kimlik firstboot'ta). +- [ ] V1 etiketi **tek USB Field OS ISO** olarak basıldı; upstream ISO + seed geri dönüş yolu olarak hazır. +- [ ] Medyada `interactive-sections: [storage]` aktif, otomatik disk silme yok. +- [ ] UEFI/Legacy/Secure Boot LAB kabulü tamam (F2B çıkış kapısı) — tamamlanmadan saha dağıtımı yok. +- [ ] Medya tabanı sapması (Desktop flavour) kayıtlı ve işletim öncesi karar verilmiş. +- [ ] Offline APT repo gömülü (yoksa `OFFLINE BLOCKED` davranışı bilerek kabul edilmiş ve release build'i bloke ediyor). + +## 13. Açık Sorular + +- [ ] Ubuntu 26.04.1'in kesin autoinstall/subiquity davranışı ve Desktop flavour'da autoinstall yolu — LAB (sahibi: platform ekibi) [K-20]. +- [ ] Desteklenecek Legacy BIOS donanım profilleri — sahibi: envanter ekibi. +- [ ] UEFI/Legacy/Secure Boot boot kabul matrisi ve Secure Boot imza davranışı — F2B (sahibi: provisioning/release yöneticisi) [K-20]. +- [ ] Medya tabanı: Desktop flavour kabul mü, Server ISO ile yeniden build mi — F2B çıkış kapısı (sahibi: release yöneticisi) [K-01/K-20]. +- [ ] Offline APT repo'nun medyaya gömülmesi (bugün `offline_repo_status: not-included`) — F2B (sahibi: release yöneticisi) [K-16/K-20]. + +--- + +## Ek: Medya ilişkisi (K-20) + +```mermaid +flowchart LR + CUSTOM["V1 medya: TEK USB Field OS ISO
/autoinstall.yaml (motor) + /blueforce-provisioning/
offline APT + firstboot + release
interactive-sections: [storage]"] --> OS["Kimliksiz Ubuntu zemin
(operatör disk onayı, otomatik silme YOK)"] + OS --> P["PROVISIONED_OFFLINE"] + P --> EN["ENROLLED (token) → READY (merkezi kanıt)"] + UP["Geri dönüş yolu: upstream Ubuntu ISO"] --> OS + SEED["NoCloud seed USB (user-data + meta-data)"] --> OS + UP -. "F2B geri alınırsa sıcak yol" .-> CUSTOM +``` \ No newline at end of file diff --git a/docs/27-OFFLINE-PROVISIONING.md b/docs/27-OFFLINE-PROVISIONING.md new file mode 100644 index 0000000..fe90c6c --- /dev/null +++ b/docs/27-OFFLINE-PROVISIONING.md @@ -0,0 +1,116 @@ +# 27 — Offline Provisioning + +> Kısa özet: İnternet yokken Field OS kurulumu ve yerel paket yüklemesi yapılabilir. Bu yol yalnız `PROVISIONED_OFFLINE` durumuna getirir; cihaz, tek kullanımlık enrollment token ve merkezi kanallar doğrulanmadan `ENROLLED` veya `READY` olmaz. +> +> - Dosya: `docs/27-OFFLINE-PROVISIONING.md` +> +> **Uygulama sözleşmesi:** `--offline`, varsayılan `/opt/blueforce/offline-repo` içindeki yalnız yerel `file:` APT kaynağını kullanır; uzak APT source, curl, wget, key download veya `apt download` çağrısı yapmaz. `Packages`, `Packages.gz`, `SHA256SUMS`, `packages.lock.tsv` ve her manifest için somut `package=version` pinleri eksikse paket modüllerinden önce `OFFLINE BLOCKED` ile durur. Bu repoda gerçek paket/pin bulunmadığından üretilen ISO LAB-only'dir ve provision edilmiş olarak temsil edilmez. +> - İlgili kararlar: `24-DECISION-LOG.md#K-16`, `#K-09`, `#K-11`, `#K-12` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Saha interneti kesik veya güvenilmezken tekrarlanabilir, denetlenebilir ve secretsiz ilk kurulumu tanımlamak. + +## 2. Kapsam + +- Kapsam içi: seed USB, offline APT repository/snapshot, kurucu, durum damgası ve sonradan enrollment. +- Kapsam dışı: merkezi peer atama ve token doğrulama (29), release imzalama (30). + +## 3. Kararlar + +```text +KARAR: Offline kurulum, imzalı/sürümlü medya ve ayrı offline APT kaynağıyla yapılır; sonuç durumu yalnızca PROVISIONED_OFFLINE'dır. +GEREKÇE: Paket indirme veya merkezi API'ye erişim yokken cihaz standarda getirilebilir; fakat merkezde doğrulanmamış kimlik ve kanallar READY iddiası yapamaz. +ALTERNATİF: İnternet gelene kadar kurulum yapmama — saha ziyareti ve bekleme maliyeti nedeniyle elendi. +RİSK: Eski veya karışık paket snapshot'ı; manifest, checksum ve release kimliği ile azaltılır. +MALİYET: Ücretsiz. +LİSANS: APT — https://manpages.ubuntu.com/manpages/noble/en/man8/apt.8.html ; dpkg — https://manpages.ubuntu.com/manpages/noble/en/man1/dpkg.1.html ; Ubuntu offline kurulum ayrıntısı 26.04 için LAB doğrulamalıdır. +``` + +## 4. Neden Bu Karar? + +Offline medya, işletim sistemi ve zorunlu paketleri taşır; merkezi sırları taşımamalıdır. Durum makinesi, "kuruldu" ile "merkezce güvenildi" arasındaki farkı görünür yapar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Sürümlü offline APT repo | Tekrarlanabilir | Medya yönetimi | **Seçildi** | +| Rastgele `.deb` dosyaları | İlk anda kolay | Bağımlılık/sürüm belirsiz | Elendi | +| İnternet bekleme | Basit | Saha blokajı | Elendi | + +## 6. Avantajlar + +- Bağlantısız sahada standarda yakın kurulum. +- Paket manifesti ile hangi sürümün yüklendiği kanıtlanır. + +## 7. Dezavantajlar + +- Medya sürümü ve paket snapshot'ı düzenli yenilenmelidir. +- Offline cihaz merkezi izleme ve erişim kanalları doğrulanana kadar operasyonel olarak hazır değildir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Eski güvenlik paketi | Orta | Orta | Süre sonu olan release medyası, hızlı enrollment kuyruğu | +| Secret USB'ye yazılır | Düşük | Yüksek | Secret taraması; token yalnız bir kez ve cihazda girilir | +| Yarım paket işlemi | Orta | Orta | `dpkg --configure -a`, idempotent kurucu | + +## 9. Uygulama Planı + +1. Medya manifesti: Field OS release, ISO checksum, seed checksum, APT snapshot kimliği, kurucu commit'i. +2. Kurulumda yalnız genel paketler ve `blueforce-install.sh --offline` kullanılır. +3. Kurucu yerel durum dosyasına `PROVISIONED_OFFLINE` yazar; `READY` yazması yasaktır. +4. İnternet geldiğinde 29'daki tek-kullanımlık token ile enrollment başlatılır; sonra merkezi kanallar test edilir. + +```bash +sudo mount /dev/sdX1 /mnt/blueforce-repo +sudo apt -o Dir::Etc::sourcelist=/mnt/blueforce-repo/sources.list update +sudo dpkg --configure -a +sudo ./blueforce-install.sh --offline --dealer-id 12010193 +bf-status # PROVISIONED_OFFLINE beklenir +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| İnternetsiz kurulum | Paketler manifestten kurulur | LAB(2) air-gapped | +| DNS/WAN kesik | Kurucu dış indirme istemez | LAB(2) | +| `bf-status` | `PROVISIONED_OFFLINE`; READY değil | LAB(2) | +| Bağlantı sonrası enrollment | ENROLLED → READY kapıları çalışır | LAB(2) | + +## 11. Rollback + +1. Paket işlemi bozuksa aynı onaylı snapshot ile `dpkg --configure -a` ve kurucu yeniden çalıştırılır. +2. Yanlış medya sürümü geri çekilir; önceki onaylı medya manifestiyle yeniden kurulur. +3. Kimlik/token hatasında cihaz resetlenmez; 29'daki token iptal/yenileme prosedürü uygulanır. + +## 12. Kontrol Listesi + +- [ ] Offline medya manifesti ve checksum'ları var. +- [ ] Medyada cihaz kimliği, private key veya kalıcı token yok. +- [ ] `PROVISIONED_OFFLINE` görünür ve READY kapısı kapalı. +- [ ] Sonradan enrollment sahibi/kuyruğu kayıtlı. + +## 13. Açık Sorular + +- [ ] Offline APT snapshot yenileme sıklığı — sahibi: release yöneticisi. +- [ ] Medyanın fiziksel zimmet ve imha süreci — sahibi: saha operasyonu. + +--- + +## Ek: Durum akışı + +```mermaid +flowchart LR + M["ISO + seed + offline APT"] --> P["PROVISIONED_OFFLINE"] + P --> T["Tek kullanımlık token"] + T --> E["ENROLLED"] + E --> V["WG + erişim + izleme doğrulama"] + V --> R["READY"] +``` \ No newline at end of file diff --git a/docs/28-WINDOWS-TO-LINUX-MIGRATION.md b/docs/28-WINDOWS-TO-LINUX-MIGRATION.md new file mode 100644 index 0000000..e2226d6 --- /dev/null +++ b/docs/28-WINDOWS-TO-LINUX-MIGRATION.md @@ -0,0 +1,145 @@ +# 28 — Windows'tan Linux'e Geçiş + +> Kısa özet: Windows saha cihazının Blueforce Field OS'a kontrollü dönüşümü: envanter, uygulama/veri kabulü, geri dönüş noktası, temiz kurulum ve teslim doğrulaması. Windows image'ı geri dönüş kanıtıdır; Linux kurulumu onu otomatik silmez. +> +> - Dosya: `docs/28-WINDOWS-TO-LINUX-MIGRATION.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-19`, `#K-01`, `#K-09`, `#K-11` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Windows'tan geçişte veri, uygulama bağımlılığı veya geri dönüş kanıtını kaybetmeden saha cihazını Field OS standardına almak. + +## 2. Kapsam + +- Kapsam içi: ön envanter (araçlar: `scripts/migration/windows/`), kabul kaydı, Windows geri dönüş imajı, Field OS kurulumu, fonksiyonel kabul, araç adı/konum eşlemesi. +- Kapsam dışı: Windows uygulamasını Wine/VM ile çalıştırma kararı; bu ancak ayrı kabul sonrası ele alınır. + +## 3. Kararlar + +```text +KARAR: Her Windows cihazı önce envanter ve geri dönüş kanıtı alır, sonra temiz Field OS kurulumu yapılır; Windows üzerinde yerinde yükseltme veya çift-boot V1 standardı değildir. +GEREKÇE: Temiz kurulum, taşınan Windows drift'ini ve lisans/driver belirsizliğini azaltır. Geri dönüş imajı kabul başarısızlığında hızlı geri sağlar. +ALTERNATİF: Yerinde dönüşüm/dual-boot — disk bölümü, bootloader ve işletim sorumluluğunu büyüttüğü için elendi. +RİSK: Gizli uygulama/veri bağımlılığı; saha kabul listesi ve imaj alma kapısıyla azaltılır. +MALİYET: Ücretsiz araçlar kullanılabilir; yedek disk/depoya altyapı maliyeti uygulanabilir. +LİSANS: Microsoft Windows geçiş lisans durumu kurum tarafından doğrulanır; Ubuntu — https://ubuntu.com/download/server ; Clonezilla — https://clonezilla.org/ (LAB gerekli). +``` + +## 4. Neden Bu Karar? + +İşletim sistemi dönüşümü yalnız disk yazma işlemi değildir. Önce çalışan iş akışı ve geri dönüş seçeneği belirlenir; Field OS ancak kabul senaryosu geçince kalıcı teslim sayılır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Envanter + image + temiz kurulum | Deterministik | İlk adım daha uzun | **Seçildi** | +| Yerinde yükseltme | Az ilk iş | Desteklenmeyen drift | Elendi | +| Dual-boot | Anında geri dönüş | Boot/disk karmaşıklığı | Elendi | + +## 6. Avantajlar + +- Her geçiş denetlenebilir ve geri alınabilir. +- Linux kabulü gerçek saha işi üzerinden ölçülür. + +## 7. Dezavantajlar + +- Image depolama ve saklama süresi gerekir. +- Uygulama sahibi kabul vermeden cihaz üretime çıkamaz. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Veri kaybı | Düşük | Yüksek | İmaj hash + restore prova | +| MEG/çevre birimi uyumsuzluğu | Orta | Yüksek | 13 kabul testi + LAB profil matrisi | +| Yanlış cihaz silinir | Düşük | Yüksek | `BF-` ve seri no çift doğrulama | + +## 9. Uygulama Planı + +1. Windows cihaz seri no, bayi no, disk durumu, çevre birimi ve kritik iş akışını kaydet. +2. Onaylı araçla geri dönüş imajı al, hash doğrula, saklama kaydı oluştur. +3. İş sahibi kesinti penceresini onaylar; ardından Field OS temiz kurulumu ve 27/29 akışı uygulanır. +4. 13-MEG kabulü, remote erişim, güç dönüşü ve monitoring doğrulanır; yalnız `READY` sonrası teslim yapılır. + +```bash +# Linux kurulum USB'sinden önce: imaj aracının kendi hash doğrulaması kayıt altına alınır. +# Kurulumdan sonra: +bf-status +systemctl is-active docker wg-quick@wg0 +``` + +### 9.1 Araç konumu (gerçek durum) + +Windows ön-geçiş araçları repo kökündeki `migration/` altında DEĞİL, **`scripts/migration/windows/`** altındadır (operasyonel scriptlerin tamamı `scripts/{install,maintenance,diagnostics,recovery,migration}` düzeninde tutulur): + +| İstenen ad/yol (brief) | Depodaki gerçek ad/yol | Not | +|---|---|---| +| `migration/windows/` | `scripts/migration/windows/` | Kökteki `migration/README.md` yalnız **işaret dosyasıdır** (pointer); aynı aracın iki gerçek kopyası drift üretir ve `tests/check-migration-static.sh` tek kanonik dizini zorunlu tutar | +| `bf-win-inventory.ps1` | `scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1` | Kimlik/donanım/OS/disk blokları | +| `bf-win-network-export.ps1` | aynı script (`network` + `serial_ports` + `usb` bölümleri) | Ayrı dosya değil, birleştirildi | +| `bf-win-software-export.ps1` | aynı script (`installed_software`/`services`/`scheduled_tasks`/`drivers`/`docker`/`vpn`/`remote_access`/`meg`; `-NoSoftware` ile atlanabilir) | Ayrı dosya değil, birleştirildi | +| `bf-win-device-export.ps1` | `scripts/migration/windows/BF-WindowsDataExport.ps1` | `BF--windows-inventory-export.json/.md` üretir; hiçbir veri kopyalamaz (metadata-only) | + +Kanonik eşleme/ayrıntı: `scripts/migration/windows/README.md` (yetkili ad sapma kaydı), konum denetim kaydı: `docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md` §B-6. **Dosyalar yeniden adlandırılmaz**; eşleme tablosu tek doğruluk kaynağıdır. + +```powershell +# LAB/atölye: araçları bu yoldan çalıştır (yönetici PowerShell) +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\migration\windows\BF-WindowsPreMigrationInventory.ps1 -DealerId 12010193 -OutputDirectory C:\Blueforce\migration +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\migration\windows\BF-WindowsDataExport.ps1 -DealerId 12010193 -OutputDirectory C:\Blueforce\migration +``` + +### 9.2 Araçların sınırları + +- İki araç da **okuma-only**'dir: veri kopyalamaz, disk bölmez, Windows ayarı değiştirmez, credential toplamaz (`tests/check-migration-static.sh` mutasyon/credential yasağını denetler). +- Çıktı `BF-` etiketiyle dosyalanır ve iş sahibi onayına eklenir; çıktıda private key/token/parola bulunmaz. +- Linux tarafındaki karşılığı `scripts/diagnostics/bf-live-hw-check` (kurulum sonrası salt-okuma donanım kapısı) ve `bf-hardware-inventory.sh`'tir. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Windows image restore provası | Kaynak iş akışı geri gelir | LAB temsilci cihaz | +| Field OS temiz kurulum | PROVISIONED_OFFLINE veya ENROLLED | LAB(2) | +| Ön-geçiş araçları (okuma-only) | Envanter + veri manifesti üretilir; mutasyon/credential yok | LAB temsilci cihaz | +| Araç yolu denetimi | Araçlar `scripts/migration/windows/` altından koşar; kökteki `migration/` yalnız pointer | CI (`check-migration-static`) | +| MEG/periferik kabul | 13 kriterleri geçer | Pilot | +| Geri dönüş kararı | Tanımlı RTO içinde uygulanır | Pilot tatbikat | + +## 11. Rollback + +1. Field OS kabulü başarısızsa cihaz üretime verilmez. +2. Onaylı Windows image geri yüklenir, hash ve iş akışı doğrulanır. +3. Hata kaydı cihaz profiline eklenir; düzeltilmiş Field OS release'i LAB'dan tekrar geçmeden yeni deneme yapılmaz. + +## 12. Kontrol Listesi + +- [ ] Windows envanteri ve iş sahibi onayı var. +- [ ] Geri dönüş image'ı hash ile doğrulandı. +- [ ] Cihaz/seri no iki kişi veya iki kayıtla eşleştirildi. +- [ ] Field OS yalnız kabul + READY sonrası teslim edildi. +- [ ] Envanter/veri manifesti `scripts/migration/windows/` araçlarıyla üretildi (ad eşlemesi §9.1'e göre doğrulandı). + +## 13. Açık Sorular + +- [ ] Windows image saklama süresi ve erişim yetkisi — sahibi: güvenlik/operasyon. +- [ ] Her periferik için Linux sürücü kabul matrisi — sahibi: MEG sahibi. +- [ ] Ön-geçiş araçlarının ad sapması (`bf-win-*` → `BF-Windows*`) `docs/32` §B-6 kaydıyla kapanmış sayılır mı, yoksa brief adlarıyla uyumluluk için sarmalayıcı mı gerekir — karar sahibi: 32 yazarı + operasyon. + +> **Güncelleme (2026-09-16):** §9.1/§9.2 eklendi. Eski metin araç adlarını/konumlarını belirtmiyordu; bu turda **gerçek durum** yazıldı: araçlar `scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1` ve `BF-WindowsDataExport.ps1`, kökteki `migration/` yalnız işaret dosyasıdır. Brief'te geçen `bf-win-inventory.ps1`, `bf-win-network-export.ps1`, `bf-win-software-export.ps1`, `bf-win-device-export.ps1` adları **eşleme tablosuyla** karşılandı; dosyalar yeniden adlandırılmadı (tek kanonik dizin kuralı). + +--- + +## Ek: Geçiş kapısı + +```mermaid +flowchart LR + W["Windows envanter + image"] --> A["İş sahibi onayı"] + A --> F["Field OS temiz kurulum"] + F --> C["MEG / erişim / güç kabulü"] + C -->|Geçti| R["READY + teslim"] + C -->|Kaldı| B["Windows image restore"] +``` \ No newline at end of file diff --git a/docs/29-DEVICE-ENROLLMENT.md b/docs/29-DEVICE-ENROLLMENT.md new file mode 100644 index 0000000..e7bb166 --- /dev/null +++ b/docs/29-DEVICE-ENROLLMENT.md @@ -0,0 +1,159 @@ +# 29 — Cihaz Enrollment + +> Kısa özet: Device enrollment, offline provision edilmiş cihazın merkezi envantere bir kez bağlanması ve merkezi kanallarca doğrulanmasıdır. Durum modeli **tek yönlüdür**: `PROVISIONED_OFFLINE → ENROLLED → READY`; geri dönüş kenarı ve durum atlama yoktur (`PROVISIONED_OFFLINE → READY` doğrudan olamaz). Tek kullanımlık token `ENROLLED` verir; yalnız WireGuard, erişim ve monitoring doğrulanınca `READY` verilir. `READY` sonrası kurtarma yolu **yeniden provision**'dır (27), durumu geri sarmak değil. +> +> - Dosya: `docs/29-DEVICE-ENROLLMENT.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-17` (enrollment), `#K-21` (tek durum kaynağı ve ileri-yönlü geçişler), `#K-12`, `#K-05`, `#K-07` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Cihaz kimliğinin, ağ kaydının ve merkezi gözlemin sahada güvenli sırayla birleştirilmesini tanımlamak. + +## 2. Kapsam + +- Kapsam içi: durum makinesi, token özellikleri, merkezi kayıt, WG/remote/monitoring kapıları, token iptal kaydı. +- Kapsam dışı: offline işletim sistemi kurulumu (27), SSH anahtar yaşam döngüsü (06), release seçimi (30). + +## 3. Kararlar + +```text +KARAR: Enrollment bir kez kullanılan, süreli ve cihaz kimliğine bağlı token ile yapılır. Durum modeli üç durumdur ve TEK YÖNLÜDÜR: PROVISIONED_OFFLINE → ENROLLED → READY. Geçişler ileri yönlüdür; **geri dönüş kenarı yoktur ve durum atlanamaz** (PROVISIONED_OFFLINE → READY doğrudan olamaz, READY → ENROLLED geri alınmaz). READY sonrası kurtarma yolu durumu geri sarmak değil, cihazı 27'ye göre YENİDEN PROVISION etmektir (yeni yaşam döngüsü: PROVISIONED_OFFLINE'dan başlar). Tek kaynak `/var/lib/blueforce/state.json` + `bf-enrollment-status` çıktısıdır. +GEREKÇE: Offline medya secret taşımaz; token yalnız bağlantı oluştuğunda kullanılır. READY, token kullanımından değil merkezi erişim ve health kanallarının gerçek doğrulamasından doğar. Geçmişi geri sarma kenarları "hangi cihaz ne zaman READY oldu" denetimini bulanıklaştırır ve yanlış teslim üretir; kurtarmayı yeniden provision'a bağlamak denetim izini tek yönlü ve kanıtlı tutar. Uygulanan kod bu sözleşmeyi zorlar: `bf-enrollment-status` yalnız üç durumu geçerli sayar, `bf-enroll` yalnız PROVISIONED_OFFLINE/ENROLLED fazından enrollment kabul eder. +ALTERNATİF: Kalıcı ortak bootstrap token — sızıntıda tüm filoyu etkiler; elendi. Geri alınabilir durum geçişleri (READY → ENROLLED) — denetim izini belirsizleştirir ve "READY gerçekten alındı mı" sorusunu cevapsız bırakır; reddedildi. Ayrı bir RETIRED/REVOKED durumu eklemek — açık soru olarak §13'te (bugünkü sözleşme `INVALID`/`PENDING` + exit≠0 ile fail-closed davranır). +RİSK: Token çalınması veya yanlış cihazda kullanılması; kısa TTL, tek kullanım, `BF-` bağlama ve anlık iptal ile azaltılır. READY sonrası kurtarmanın yeniden provision gerektirmesi saha süresi maliyeti doğurur; azaltma: 17-RECOVERY kademeleri (L1→L9) önce denenir, yeniden provision son çare. +MALİYET: Ücretsiz. +LİSANS: OpenSSH — https://www.openssh.com/ ; WireGuard — https://www.wireguard.com/ ; token API davranışı kurum içi uygulama ve LAB doğrulamasıdır. +``` + +> **Güncelleme (2026-09-16, K-21):** Bu dosyanın ek diyagramındaki iki geri dönüş kenarı (`ENROLLED → PROVISIONED_OFFLINE`, `READY → ENROLLED`) uygulanan kuralla çelişiyordu ve kaldırılmıştır. Uygulanan kural: **geçişler ileri yönlüdür; geri dönüş ve atlama yoktur.** READY sonrası kurtarma yolu yeniden provision'dır (27) — ayrıntı §11 ve Ek'teki ikinci diyagram. + +## 4. Neden Bu Karar? + +İlk kurulumdaki cihaz, merkez açısından henüz bilinmeyen bir uçtur. Token yalnız kimlik bağlama yetkisi verir; merkezi kanalların gerçekten çalıştığını ispatlamaz. Bu ayrım yanlış READY teslimini engeller. Durumun tek yönlü olması da aynı amaca hizmet eder: "kuruldu → bağlandı → doğrulandı" sırası kanıt zinciridir; geri sarmalı kenarlar bu zinciri koparır ve denetimde hangi durumun ne zaman gerçek olduğunu belirsizleştirir. Bir cihaz READY'den düşürülmesi gerekiyorsa yapılan iş **yeni bir provision + enrollment + READY kanıtı** üretmektir (27), eski durumu geri yazmak değil. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Tek-kullanımlık, TTL'li token | Küçük sızıntı alanı | Token üretim hizmeti gerekir | **Seçildi** | +| Ortak kalıcı token | Basit | Filo çapı sızıntı | Elendi | +| Manuel merkezi kayıt | Ek sistem yok | Hata/audit zayıf | Elendi | + +## 6. Avantajlar + +- Her kayıt hangi cihaz, kim ve ne zaman ile ilişkilidir. +- READY bağımsız sağlık kanıtına bağlıdır. + +## 7. Dezavantajlar + +- Cihazın internete kavuşması gerekir. +- Token hizmeti ve iptal kaydı işletilir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Token tekrar kullanımı | Düşük | Yüksek | Atomik tüketim + audit | +| Kimlik çakışması | Düşük | Yüksek | Envanter benzersizlik kapısı | +| WG var ama health yok | Orta | Orta | READY için üç kanal şartı | +| READY sonrası kurtarma yolu yanlış modellenir (durum geri sarma denemesi) | Orta | Orta | Geçişler ileri yönlü; kurtarma = 27 yeniden provision; `bf-enrollment-status` yalnız üç durumu geçerli sayar, aksi halde `INVALID`/`PENDING` + exit≠0 | +| `central_verification` alanları `pending` kalır, cihaz ENROLLED'da bekler | Orta | Orta | Fail-closed READY kapısı + enrollment yanıt sözleşmesinin zorunlu alanları | + +## 9. Uygulama Planı + +1. Merkezi admin, doğrulanmış bayi no için `BF-` bağlı, kısa ömürlü token üretir ve kayda açar. +2. Teknisyen tokenı yalnız cihaz ekranına girer; USB'ye, loga veya support bundle'a yazmaz. +3. Başarılı yanıtın tamamı doğrulanır; yalnız bundan sonra WireGuard yapılandırması atomik kurulur ve servis etkin/aktif ise durum `ENROLLED` olur. +4. `bf-check-ready`, kalıcı `READY` değerini okumaz: güncel WG handshake, etkin xRDP, RustDesk yapılandırması, isteğe bağlı Mesh ajanı ve merkezden gelen doğrulama kanıtını bağımsız denetler. Tümü geçerse `18-final-check` `READY` yazar. +5. **İleri-yönlü geçiş kuralı (uygulanan sözleşme):** durumlar yalnız `PROVISIONED_OFFLINE → ENROLLED → READY` sırasıyla ilerler. `PROVISIONED_OFFLINE → READY` doğrudan geçiş yoktur (merkezi kanal kanıtı token olmadan üretilemez); `ENROLLED → PROVISIONED_OFFLINE` veya `READY → ENROLLED` geri dönüş kenarı yoktur. Bir cihaz READY'den çıkarılması gerekiyorsa: + - önce 17-RECOVERY kademeleri (L1 servis restart → … → L8 USB ile sıfırdan imaj) denenir, + - kimlik/merkez kaydı ciddi biçimde bozuksa cihaz merkezi erişimden çekilir, token/peer iptal edilir ve **27'ye göre yeniden provision edilir**; yeniden provision yeni bir yaşam döngüsü başlatır (`PROVISIONED_OFFLINE` → yeni token → `ENROLLED` → yeni READY kanıtı). + - Yani kurtarma "durumu geri sarmak" değil, "kanıtı yeniden üretmek"tir. + +```bash +# BF_ENROLLMENT_ENDPOINT is supplied by the approved central control plane configuration. +printf '%s\n' '' | sudo -E bf-enroll --token-stdin +bf-enrollment-status # yalnız PROVISIONED_OFFLINE | ENROLLED | READY; aksi halde INVALID/PENDING + exit≠0 +bf-check-ready +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Geçerli token | PROVISIONED_OFFLINE → ENROLLED | LAB(2) | +| Aynı token ikinci kez | Reddedilir, audit kaydı | LAB(2) | +| Süresi geçmiş/yanlış cihaz tokenı | Reddedilir | LAB(2) | +| Merkezi kanallar eksik | ENROLLED kalır, READY olmaz | LAB(2) | +| Token olmadan READY denemesi | READY yazılmaz (atlama yok) | LAB(2) | +| READY sonrası kurtarma provası | Durum geri sarmak yerine 27 yeniden provision yolu çalışır | LAB(2) | + +## 11. Rollback + +1. Token yanlış kullanılırsa merkezi admin tokenı iptal eder ve geçici kayıtları kapatır (cihaz ENROLLED'da kalır; READY'ye ilerlemez). +2. Yanlış WireGuard peer/etiket, `ENROLLED` durumunda düzeltilir ve yeniden doğrulanır. +3. READY sonrası ciddi kimlik sorunu varsa cihaz merkezi erişimden çekilir, token/peer iptal edilir ve **27'den yeniden provision edilir**. Bu "rollback" bir durum geri alma değildir: yeni yaşam döngüsü PROVISIONED_OFFLINE'dan başlar ve READY yeniden kanıtlanır (geçişler ileri yönlüdür). + +## 12. Kontrol Listesi + +- [ ] Token tek kullanımlık, TTL'li ve `BF-` bağlı. +- [ ] Token hiçbir medya/log/support bundle içinde yok. +- [ ] ENROLLED ve READY farklı, görünür durumlar. +- [ ] WG + remote + monitoring doğrulaması kayda bağlı. +- [ ] Merkez yanıtındaki `central_verification` kanıtı olmayan veya `pending` olan cihaz READY olmaz. +- [ ] Diyagramda/metinde geri dönüş kenarı ve durum atlama yok; READY sonrası kurtarma 27 yeniden provision olarak yazılı. + +## 13. Açık Sorular + +- [ ] Token TTL süresi ve yetkili üretici rolleri — sahibi: güvenlik yöneticisi. +- [ ] Enrollment API audit saklama süresi — sahibi: merkezi admin. +- [ ] Geri dönüşsüz modelde "cihaz işletmeden çekildi" durumu için ayrı bir kalıcı damga (ör. `retired`) gerekir mi — karar sahibi: platform ekibi + 27 yazarı [K-21]. + +--- + +## Ek: Merkezi doğrulama yanıt sözleşmesi ve güven sınırı + +Enrollment yanıtı `central_verification` alanını **zorunlu** taşır. Alan secret, URL, erişim tokenı, RustDesk parolası veya WireGuard private key taşımaz; yalnız merkezde denetlenebilen referansları taşır: + +```json +{ + "central_verification": { + "status": "pending | verified", + "verification_id": "merkez-kayit-referansi", + "monitoring": {"status": "pending | verified", "verification_id": "izleme-referansi"}, + "remote": {"status": "pending | verified", "verification_id": "uzak-erisim-referansi"}, + "management": {"status": "pending | verified", "verification_id": "yonetim-referansi"} + } +} +``` + +İstemci merkez URL'si veya kanıt uydurmaz. `bf-remote-status` yalnız imzalanmış/kimliği doğrulanmış enrollment yanıtından kalıcılaştırılan bu referansların tümü `verified` olduğunda merkezi kanıtı geçer; alan eksik, biçimsiz veya `pending` ise `PENDING` ile fail-closed olur. Yerel xRDP, RustDesk, isteğe bağlı Mesh ve güncel WG handshake bu merkez kanıtından ayrı olarak kontrol edilir. + +## Ek: Durum makinesi (tek yönlü — geri dönüş kenarı yok) + +```mermaid +stateDiagram-v2 + [*] --> PROVISIONED_OFFLINE + PROVISIONED_OFFLINE --> ENROLLED: tek kullanımlık token (ileri) + ENROLLED --> READY: WG + remote + monitoring kanıtı (ileri) + note right of READY + Geri dönüş kenarı YOKTUR ve durum atlanamaz + (PROVISIONED_OFFLINE -> READY doğrudan olamaz). + READY sonrası kurtarma: 27'ye göre yeniden provision. + end note +``` + +## Ek: READY sonrası kurtarma yolu (yeni yaşam döngüsü) + +```mermaid +flowchart LR + RDY["READY cihaz: kimlik/merkez kaydı ciddi biçimde bozuk"] --> CEK["Merkezi erişimden çek + token/peer iptal"] + CEK --> RP["27'ye göre YENİDEN PROVISION
(yeni yaşam döngüsü başlar)"] + RP --> PO["PROVISIONED_OFFLINE"] + PO -->|yeni tek kullanımlık token| EN["ENROLLED"] + EN -->|yeni WG + remote + monitoring kanıtı| YENI["READY (yeniden kanıtlandı)"] + GERI["Durumu geri sarma (READY → ENROLLED vb.)"] -.->|"YASAK"| RDY +``` \ No newline at end of file diff --git a/docs/30-FIELD-OS-RELEASE-MANAGEMENT.md b/docs/30-FIELD-OS-RELEASE-MANAGEMENT.md new file mode 100644 index 0000000..2fb2c29 --- /dev/null +++ b/docs/30-FIELD-OS-RELEASE-MANAGEMENT.md @@ -0,0 +1,111 @@ +# 30 — Field OS Release Yönetimi + +> Kısa özet: Field OS release'i ISO/seed, offline APT snapshot, kurucu, Ansible içerikleri ve manifestten oluşan değişmez bir sette yönetilir. Her release LAB→P1→P2→W1→W2→PROD kapılarından geçer; rollback önceki onaylı release'e döner. +> +> - Dosya: `docs/30-FIELD-OS-RELEASE-MANAGEMENT.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-18`, `#K-11`, `#K-15`, `#K-16` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Field OS değişikliklerini reproducible, onaylı ve geri alınabilir release'lere dönüştürmek. + +## 2. Kapsam + +- Kapsam içi: release bileşenleri, sürüm/manifest, imza-checksum, dalga kapıları, support penceresi ve rollback. +- Kapsam dışı: tek paketin teknik upgrade komutları (10), cihaz enrollment (29). + +## 3. Kararlar + +```text +KARAR: Field OS release'i değişmez manifestli artefact setidir: upstream ISO referansı, seed, offline APT snapshot, kurucu/Ansible commit'i, paket sürümleri ve checksum'lar birlikte sürümlenir. +GEREKÇE: "26.04.1" tek başına çalışır sistemi anlatmaz; seed ve installer değişirse davranış değişir. Manifest, aynı release'in yeniden kurulmasını ve kanıtlanmasını sağlar. +ALTERNATİF: Yalnız Git branch veya latest ISO etiketi — hangi bileşenin sahaya gittiği belirsiz kalır; elendi. +RİSK: Artefact uyumsuzluğu veya imzasız medya; build kapısı, checksum ve LAB kabulü ile azaltılır. +MALİYET: Ücretsiz. +LİSANS: Git — https://git-scm.com/doc ; Ubuntu releases — https://releases.ubuntu.com/ ; Docker image tag ilkeleri — https://docs.docker.com/reference/cli/docker/image/pull/ (26.04 davranışı LAB gerektirir). +``` + +## 4. Neden Bu Karar? + +Release, bir kurulum medyası değil çalışan sistem sözleşmesidir. İmaj, seed, paket snapshot'ı ve kurucu farklı tarihlerden seçilirse hata tekrar üretilemez ve rollback güvenilmez olur. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Immutable manifest release | İzlenebilir/rollbackli | Release disiplini | **Seçildi** | +| `latest`/hareketli medya | Kolay görünür | Determinizm yok | Elendi | +| Sadece cihazda sürüm notu | Az merkez işi | Filo görünürlüğü yok | Elendi | + +## 6. Avantajlar + +- Bir cihazın hangi release ile kurulduğu kesin olarak bulunur. +- Önceki onaylı release, gerçek rollback hedefidir. + +## 7. Dezavantajlar + +- Artefact deposu ve release kaydı işletilir. +- Küçük değişiklik bile manifest/review gerektirir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Artefact eksikliği | Orta | Yüksek | Release gate: manifest + checksum zorunlu | +| Rollback test edilmez | Orta | Yüksek | LAB restore/rollback kanıtı | +| Eski release güvenlik riski | Orta | Orta | Support penceresi, hızlandırılmış dalga | + +## 9. Uygulama Planı + +1. Release adı örneği: `field-os-26.04.1-r001`; manifestte ISO/seed/APT/installer/Ansible/MEG sürümlerini açık yaz. +2. Her artefact için SHA256 ve kaynak/oluşturma kaydı üret; secret manifestte bulunmaz. +3. LAB'da fresh install, offline install, enrollment, erişim, power-loss ve rollback kanıtı üret. +4. Merkezi admin 10'daki dalga kapılarıyla yayını onaylar; başarısızlıkta yeni dalga açılmaz. + +```bash +sha256sum field-os-26.04.1-r001-manifest.json +# Manifest içerikleri release imzası/checksum süreciyle doğrulanır. +git tag -v field-os-26.04.1-r001 # imzalı tag kullanılıyorsa +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Manifest bütünlüğü | Tüm artefact hash'leri eşleşir | Build/LAB | +| Fresh + offline install | Aynı release kimliği görünür | LAB(2) | +| Rollback | Önceki onaylı release'e döner | LAB(2) | +| P1 gözlem | 24–48 saat sağlık kapısı temiz | P1(5) | + +## 11. Rollback + +1. Yayılım durdurulur; etkilenen dalga önceki onaylı manifestteki paket/config/artefact sürümüne döner. +2. `bf-status`, merkezi metrikler ve cihaz release kimliği doğrulanır. +3. İmaj seviyesinde sorun varsa 26'daki upstream ISO + önceki onaylı seed ile yeniden kurulum son çaredir. + +## 12. Kontrol Listesi + +- [ ] Release ID ve immutable manifest var. +- [ ] ISO, seed, APT snapshot, installer ve uygulama sürümleri kaydedildi. +- [ ] Checksums/imza ve LAB kanıtı var. +- [ ] Dalga onayı ve rollback kanıtı kaydedildi. + +## 13. Açık Sorular + +- [ ] Artefact depolama/retention ve imza anahtarı sahibi — sahibi: release yöneticisi. +- [ ] Release cadence ve EOL politikası — sahibi: merkezi admin. + +--- + +## Ek: Release kapısı + +```mermaid +flowchart LR + B["Build + manifest"] --> L["LAB: install/enroll/power/rollback"] + L --> P1["P1"] --> P2["P2"] --> W["W1/W2"] --> R["PROD"] + L -->|Kaldı| O["Önceki onaylı release rollback"] + P1 -->|Kaldı| O +``` \ No newline at end of file diff --git a/docs/31-LEARNING-GUIDE.md b/docs/31-LEARNING-GUIDE.md new file mode 100644 index 0000000..c5c9cc5 --- /dev/null +++ b/docs/31-LEARNING-GUIDE.md @@ -0,0 +1,373 @@ +# 31 — Blueforce Field OS Öğrenme Rehberi + +> Kısa özet: Bu rehber, Field OS'u güvenle işletmek için gereken Linux, ağ, otomasyon ve release kavramlarını öğretir. Her konu **Nedir / Neden / Nerede / Arıza belirtisi / Komutlar** biçimindedir; üretimde değişiklik yapmadan önce LAB'da uygulanır. +> +> - Dosya: `docs/31-LEARNING-GUIDE.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-01…K-24` (özellikle medya için `#K-20`, durum modeli için `#K-21`, RDP/yerel GUI için `#K-22`, GNOME vs XFCE ölçümü için `#K-23`) +> - Durum: [ ] Taslak — komutlar örnektir; 26.04 ve RustDesk headless ayrıntıları LAB'da doğrulanmalıdır + +--- + +## 1. Amaç + +Teknisyen ve merkezi operatörün, Field OS'un neden böyle tasarlandığını ve temel kontrolleri hangi sırayla yapacağını öğrenmesini sağlamak. + +## 2. Kapsam + +- Kapsam içi: filesystem, paket yönetimi, servis/log, Git/Bash, SSH/ağ, WireGuard, Docker/Ansible/Semaphore/Prometheus, uzak erişim, autoinstall/NoCloud/ISO/offline APT, enrollment, golden image, release ve rollback. +- Kapsam dışı: gizli anahtar üretimi, prod değişikliği veya 13/20/21 yerine geçecek işletim yetkisi. + +## 3. Kararlar + +```text +KARAR: Eğitim, komut ezberi yerine Nedir/Neden/Nerede/Arıza belirtisi/Komutlar çerçevesiyle ve yalnız LAB pratiğiyle verilir. +GEREKÇE: 700 cihazda yanlış komutun etkisi büyüktür; kavramı bilen operatör belirtileri doğru runbook'a yönlendirir ve onaysız değişiklik yapmaz. +ALTERNATİF: Sadece kısa komut listesi — bağlam olmadan yanlış kullanım riski yüksek olduğu için elendi. +RİSK: Doküman, sürüme özgü davranışı kesinmiş gibi gösterebilir; Ubuntu 26.04 ve RustDesk headless ayrıntıları açık LAB etiketiyle tutulur. +MALİYET: Ücretsiz. +LİSANS: Resmi kaynak URL'leri her konu altında verilmiştir; kurum içi runbook'lar kendi lisansına tabidir. +``` + +> **Güncelleme (2026-09-16, K-20/K-21/K-22/K-23):** Bu rehberdeki medya, RDP ve durum modeli anlatımları ikinci tur kararlarla hizalandı: (1) V1 medyası **tek USB Field OS ISO**, upstream ISO + seed **geri dönüş yolu**; (2) **xRDP her zaman hazır**, `bf-gui-*` yalnız yerel fiziksel GUI; (3) durum geçişleri **ileri yönlü**, READY sonrası kurtarma 27'ye göre yeniden provision; (4) LAB'da **GNOME vs XFCE A/B ölçümü** yapılır (XFCE saha standardı değil). + +## 4. Neden Bu Karar? + +Field OS; standart Linux araçlarını, merkezi otomasyonu ve katmanlı güvenlik kapılarını birlikte kullanır. Bir katmanın anlamını bilmek, örneğin `READY` ile yalnızca çalışan bir servisi karıştırmayı önler. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Kavram + güvenli komut + LAB | Kalıcı öğrenme | Başlangıçta daha uzun | **Seçildi** | +| Ezber komut kartı | Hızlı | Hata teşhisi zayıf | Elendi | +| Prod üzerinde deneme | Gerçek ortam | Filo riski | Yasak | + +## 6. Avantajlar + +- Runbook'ların ardındaki teknik bağ görünür olur. +- Her komutun beklenen sonucu ve arıza belirtisi vardır. + +## 7. Dezavantajlar + +- Rehber resmi ürün dokümanının yerine geçmez. +- Bazı davranışlar donanım ve Ubuntu nokta sürümüne bağlıdır. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Prod'da deney komutu | Orta | Yüksek | LAB-only etiketi, 20/21 yetki sınırı | +| Secret'ın komuta/loga girmesi | Orta | Yüksek | Placeholder kullan, secret yazma | +| Sürüm farkı | Orta | Orta | Resmi URL + LAB kayıtları | + +## 9. Uygulama Planı + +### 1. Filesystem ve Bash + +**Nedir:** Linux filesystem; `/etc` yapılandırma, `/var` değişen veri/log, `/usr` programlar, `/home` kullanıcı verisidir. Bash kabuk ve komut dili olarak bunlarla çalışır. + +**Neden:** Yanlış dosyayı düzenlemek veya alanı dolu diski kaçırmak servis arızası üretir. + +**Nerede:** `/etc/wireguard/wg0.conf`, `/var/log/`, `/var/lib/blueforce/`, `/usr/local/bin/`. + +**Arıza belirtisi:** `No space left on device`, izin reddi, servis config'i okumuyor. + +**Komutlar (LAB):** +```bash +pwd; ls -lah /etc /var/lib/blueforce +df -h; df -i +findmnt /; stat /etc/hostname +``` +Resmi kaynak: https://www.gnu.org/software/bash/manual/ ; https://www.freedesktop.org/software/systemd/man/latest/file-hierarchy.html + +### 2. APT ve dpkg + +**Nedir:** `apt` paket bağımlılıklarını çözen yüksek seviye araçtır; `dpkg` kurulu `.deb` paket veritabanını yönetir. + +**Neden:** Offline medya veya yarım kurulumda paketin kurulu olup olmadığını ve yarım işlemi anlamak gerekir. + +**Nerede:** `/etc/apt/`, `/var/lib/dpkg/`, offline APT snapshot (27). + +**Arıza belirtisi:** `dpkg was interrupted`, bağımlılık hatası, yanlış paket sürümü. + +**Komutlar (LAB):** +```bash +apt-cache policy xrdp +dpkg -l xrdp +dpkg -S /usr/sbin/xrdp +sudo dpkg --configure -a +``` +Resmi kaynak: https://manpages.ubuntu.com/manpages/noble/en/man8/apt.8.html ; https://manpages.ubuntu.com/manpages/noble/en/man1/dpkg.1.html + +### 3. systemd ve journalctl + +**Nedir:** systemd servisleri ve boot hedeflerini yönetir; journald olay günlüklerini tutar, `journalctl` okur. + +**Neden:** Field OS boot zinciri `multi-user.target` → network → WireGuard → Docker/ajanlar şeklindedir. + +**Nerede:** `/etc/systemd/system/`, drop-in `*.service.d/`, journal. + +**Arıza belirtisi:** `failed` servis, boot race, tekrar başlayan konteyner/ajan. + +**Komutlar (LAB):** +```bash +systemctl get-default +systemctl status wg-quick@wg0 --no-pager +journalctl -u wg-quick@wg0 -b --no-pager +systemd-analyze critical-chain docker.service +``` +Resmi kaynak: https://www.freedesktop.org/software/systemd/man/latest/systemctl.html ; https://www.freedesktop.org/software/systemd/man/latest/journalctl.html + +### 4. Git ve Bash çalışma disiplini + +**Nedir:** Git, doküman/config değişiklik geçmişidir; Bash scriptleri kurucu ve tanı komutlarını taşır. + +**Neden:** Release manifesti, seed ve Ansible değişiklikleri review ve geri alma olmadan sahaya gitmez. + +**Nerede:** Bu repo, `scripts/`, `ansible/`, release tag'leri. + +**Arıza belirtisi:** yanlış branch, temiz olmayan worktree, aynı dosyada iki farklı değişiklik. + +**Komutlar (LAB/yerel checkout):** +```bash +git status --short --branch +git log --oneline -5 +git diff --check +bash -n scripts/install/blueforce-install.sh +``` +Resmi kaynak: https://git-scm.com/docs ; https://www.gnu.org/software/bash/manual/ + +### 5. SSH ve anahtarlar + +**Nedir:** SSH şifreli yönetim bağlantısıdır; public key cihazda, private key yalnız yetkili admindedir. + +**Neden:** K-13'e göre parola/root SSH kapalı ve paylaşılan private key yasaktır. + +**Nerede:** `~/.ssh/`, `/etc/ssh/sshd_config.d/`, `authorized_keys`. + +**Arıza belirtisi:** `Permission denied (publickey)`, host key değişikliği, çok geniş yetki. + +**Komutlar (LAB):** +```bash +ssh -vvv blueforce@bf-12010193 +ssh-keygen -lf ~/.ssh/id_ed25519.pub +sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin' +``` +Resmi kaynak: https://www.openssh.com/manual.html ; https://man.openbsd.org/sshd_config + +### 6. Networking ve WireGuard + +**Nedir:** IP routing ağın yoludur; WireGuard, hub-spoke şifreli tüneldir. `PersistentKeepalive=25`, NAT arkasındaki spoke için varsayımdır. + +**Neden:** SSH/xRDP yalnız WireGuard içinden; RustDesk/MeshCentral WG-dışı yedek kanaldır. + +**Nerede:** `ip route`, `/etc/wireguard/wg0.conf`, hub peer envanteri. + +**Arıza belirtisi:** eski handshake, route yok, DNS/WAN hatası. + +**Komutlar (LAB):** +```bash +ip addr; ip route +sudo wg show wg0 latest-handshakes +ping -c 3 10.8.0.1 +ss -tlnp +``` +Resmi kaynak: https://www.wireguard.com/quickstart/ ; https://man7.org/linux/man-pages/man8/ip.8.html + +### 7. Docker + +**Nedir:** Docker uygulamaları container olarak paketler. Field OS'ta `unless-stopped`, sabit tag ve log rotasyonu zorunludur. + +**Neden:** Güç dönüşünde MEG kalkmalı; `latest` veya otomatik updater determinizmi bozar. + +**Nerede:** `/etc/docker/daemon.json`, compose dosyaları, Docker journal/logları. + +**Arıza belirtisi:** restart loop, disk dolumu, dışa açık port. + +**Komutlar (LAB):** +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' +docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' +docker logs --tail 100 +``` +Resmi kaynak: https://docs.docker.com/engine/containers/start-containers-automatically/ ; https://docs.docker.com/engine/logging/configure/ + +### 8. Ansible ve Semaphore + +**Nedir:** Ansible SSH üzerinden idempotent playbook çalıştırır; Semaphore bunun self-hosted operasyon arayüzüdür. + +**Neden:** 700 cihazda aynı değişiklik kontrollü dalgalarla uygulanır; UI bozulsa Ansible CLI yedektir. + +**Nerede:** `ansible/inventory/`, `ansible/playbooks/`, Semaphore projesi/key store. + +**Arıza belirtisi:** unreachable host, playbook drift'i, onaysız prod run denemesi. + +**Komutlar (LAB):** +```bash +ansible-inventory -i ansible/inventory/hosts.example.yml --graph +ansible-playbook -i ansible/inventory/hosts.example.yml ansible/playbooks/bf-ping.yml --check +``` +Resmi kaynak: https://docs.ansible.com/ansible/latest/ ; https://docs.semaphoreui.com/ + +### 9. Prometheus ve sağlık + +**Nedir:** Prometheus metrikleri scrape eder; Node Exporter host metriklerini verir; Grafana gösterir, Kuma last-seen sunar. + +**Neden:** `READY`, yalnız kurulum bitişi değil merkezi metrik/erişim kanıtıdır. + +**Nerede:** Node Exporter textfile dizini, Prometheus targets, Grafana/Kuma panoları. + +**Arıza belirtisi:** `up=0`, stale WG handshake, disk/RAM trendi, missing scrape. + +**Komutlar (LAB):** +```bash +systemctl is-active node_exporter +curl -fsS http://127.0.0.1:9100/metrics | grep '^node_boot_time_seconds' +ls -l /var/lib/node_exporter/textfile/ +bf-status +``` +Resmi kaynak: https://prometheus.io/docs/introduction/overview/ ; https://github.com/prometheus/node_exporter ; https://github.com/louislam/uptime-kuma + +### 10. RustDesk ve xRDP + +**Nedir:** xRDP GNOME'a WireGuard üzerinden RDP verir ve servisi **her zaman hazırdır** (`xrdp` + `xrdp-sesman` enable+active). `bf-gui-on` / `bf-gui-off` yalnız **yerel fiziksel** grafik katmanını (display-manager + default target) açar/kapatır; RDP'yi kontrol etmez. RustDesk, self-hosted `hbbs`/`hbbr` ile WireGuard'dan bağımsız grafik yedek erişimdir. + +**Neden:** RDP internete açılmaz; WG arızasında RustDesk/MeshCentral kurtarma yolu olur. "GUI kapalı ≠ RDP kapalı" ayrımı, yerel GUI kapatılmış bir saha cihazında uzaktan grafik kurtarmayı korur (K-22). + +**Nerede:** xRDP systemd birimleri (`xrdp`, `xrdp-sesman`), GNOME session ayarı, RustDesk client/server yapılandırması. + +**Arıza belirtisi:** RDP oturumu açılmıyor ama `systemctl is-active xrdp` "inactive" diyor (servis kapatılmış = sözleşme ihlali), boş GNOME oturumu, xRDP service hatası, RustDesk'te katılımsız erişim yok. + +**Komutlar (LAB):** +```bash +systemctl status xrdp xrdp-sesman --no-pager +sudo bf-gui-off; systemctl is-active xrdp xrdp-sesman # ikisi de "active" kalmalı (K-22 kanıtı) +ss -tlnp | grep ':3389' +journalctl -u xrdp -b --no-pager +``` +Resmi kaynak: https://github.com/neutrinolabs/xrdp ; https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ . **LAB gerekli:** Ubuntu 26.04.1 GNOME+xRDP ve RustDesk'in tam headless/katılımsız servis davranışı sürüm ve paketleme ile doğrulanmadan üretim sözü değildir. Aynı LAB'da **GNOME vs XFCE A/B ölçümü** yapılır: RAM, CPU, boot süresi, RDP güvenilirliği, RustDesk reboot sonrası davranışı, login screen erişimi, dummy display, 24/72 saat stabilite (K-23; XFCE saha standardı değildir, yalnız ölçüm koludur). + +### 11. Autoinstall, cloud-init, NoCloud ve ISO + +**Nedir:** Autoinstall Ubuntu kurulumunu otomatikleştiren **motordur**; cloud-init ilk boot yapılandırma çerçevesidir; NoCloud, `user-data`/`meta-data` sağlayan veri kaynağıdır. **Blueforce Field OS ISO ise paketleme/dağıtım yöntemidir**: autoinstall'ı, offline APT repo'yu ve firstboot'u tek medyada taşır — ikisi rakip değil, birlikte kullanılır (K-20). + +**Neden:** V1 medyası **tek USB Field OS ISO**'dur; teknisyen tek medya takar, kurulum katılımsız ilerler ve **yalnız disk adımı operatör onayı bekler** (`interactive-sections: [storage]` → otomatik disk silme YOK). Upstream ISO + ayrı NoCloud seed USB yolu kaldırılmadı: ISO boot etmezse aynı sonucu veren sıcak geri dönüş yoludur. + +**Nerede:** Medya kökü `/autoinstall.yaml`, `/blueforce-provisioning/{autoinstall,firstboot,offline-repo,release}`, seed USB kökü (`user-data`, `meta-data`), `/var/log/cloud-init*`. + +**Arıza belirtisi:** interaktif kurulum ekranı, schema hatası, seed bulunamadı, yanlış boot modu, ISO checksum uyuşmazlığı, `OFFLINE BLOCKED` (medyaya gömülü offline repo/pin yok). + +**Komutlar (LAB):** +```bash +provisioning/iso/test-iso.sh dist/Blueforce-Field-OS-1.0.0-amd64.iso # checksum + El Torito metadata +sha256sum -c dist/Blueforce-Field-OS-1.0.0-amd64.iso.sha256 +cloud-init status --long +cloud-init schema --config-file user-data +``` +Resmi kaynak: https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html ; https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html ; https://releases.ubuntu.com/ . **LAB gerekli:** 26.04.1'in kesin Subiquity/cloud-init söz dizimi, UEFI/Legacy/Secure Boot boot zinciri ve air-gapped kurulum. Not: LAB ISO'su Desktop flavour ile üretildi; baseline (Server) sapması `dist/1.0.0-manifest.yaml` → `baseline_deviation` bloğunda kayıtlıdır. + +### 12. Offline APT ve offline provisioning + +**Nedir:** Offline APT, sürümlü yerel repo/snapshot'tan paket kurmaktır; offline provisioning internetsiz cihazı `PROVISIONED_OFFLINE` durumuna getirir. + +**Neden:** Saha bağlantısı yokken kurulum yapılır; fakat merkezi doğrulama olmadan READY verilmez. + +**Nerede:** offline medya, `sources.list`, `/var/lib/dpkg/`, 27 prosedürü. + +**Arıza belirtisi:** paket bulunamadı, bağımlılık eksik, dpkg yarım kaldı, cihaz yanlışlıkla READY gösteriyor. + +**Komutlar (LAB):** +```bash +apt-cache policy +dpkg -l +sudo dpkg --configure -a +bf-status +``` +Resmi kaynak: https://manpages.ubuntu.com/manpages/noble/en/man8/apt.8.html ; https://manpages.ubuntu.com/manpages/noble/en/man1/dpkg.1.html + +### 13. Enrollment ve golden image + +**Nedir:** Golden image kimliksiz tekrar kurulabilir zemin (V1'de tek USB Field OS ISO ile paketlenir); enrollment merkezi kimlik bağlama ve kanal doğrulamasıdır. Durum modeli **tek yönlüdür**: `PROVISIONED_OFFLINE → ENROLLED → READY`; geri dönüş kenarı ve durum atlama yoktur (K-21). + +**Neden:** Seed/ISO, bayi no, private key veya kalıcı token taşımaz. `PROVISIONED_OFFLINE → ENROLLED → READY` birbirinden farklı güven durumlarıdır ve sıra kanıt zinciridir; `PROVISIONED_OFFLINE → READY` doğrudan geçiş (token atlama) yanlış teslim üretir. READY sonrası kurtarma durumu geri sarmak değil, 27'ye göre **yeniden provision** etmektir. + +**Nerede:** 26–29 dokümanları, `/var/lib/blueforce/state.json`, merkezi envanter, WireGuard peer, monitoring/remote kayıtları. + +**Arıza belirtisi:** token tekrar kullanımı, kimlik çakışması, WG çalışırken monitoring eksik ve yanlış READY, `bf-enrollment-status` çıktısında `INVALID`/`PENDING` (exit≠0). + +**Komutlar (LAB):** +```bash +bf-status +bf-enrollment-status # PROVISIONED_OFFLINE | ENROLLED | READY dışındaki her değer fail-closed +printf '%s\n' '' | sudo -E bf-enroll --token-stdin +bf-check-ready +``` +Resmi kaynak: https://cloudinit.readthedocs.io/ ; https://www.wireguard.com/ . Token API ve 26.04 kesin kurulum akışı kurum içi **LAB gerekli** konularıdır. + +### 14. Releases ve rollback + +**Nedir:** Release; ISO referansı, seed, offline APT snapshot, installer/Ansible commit'i ve checksum'ları içeren immutable manifesttir. Rollback önceki onaylı release'e dönüş kanıtıdır. + +**Neden:** `latest` veya yalnız OS sürümü hangi sistemin sahada olduğunu açıklamaz. Her değişiklik 10'daki LAB→P1→P2→W1→W2→PROD kapılarından geçer. Medya seviyesinde geri dönüş yolu upstream ISO + son onaylı seed'dir (K-20). + +**Nerede:** release manifest/tag, `dist/-manifest.yaml`, artefact deposu, 30 yönetim kaydı, monitoring ve change kaydı. + +**Arıza belirtisi:** hash uyuşmazlığı, rollback planı yok, dalga kapısı atlanmış, sürüm drift'i, `bf-release` çıktısında `UNAPPROVED_SKELETON`. + +**Komutlar (LAB):** +```bash +bf-release +sha256sum -c dist/Blueforce-Field-OS-1.0.0-amd64.iso.sha256 +git tag --list 'field-os-*' +bf-status +``` +Resmi kaynak: https://git-scm.com/docs ; https://releases.ubuntu.com/ ; https://docs.docker.com/ + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Her konu için komut pratiği | Beklenen durum/arıza farkı anlatılır | LAB | +| Air-gapped provisioning | `PROVISIONED_OFFLINE`, READY değil | LAB(2) | +| Enrollment kapısı | Tek kullanım + READY doğrulaması | LAB(2) | +| Durum modeli ileri yönlü | `PROVISIONED_OFFLINE → READY` atlaması reddedilir; READY sonrası kurtarma 27 yeniden provision | LAB(2) | +| Yerel GUI kapalıyken RDP | `xrdp`/`xrdp-sesman` active kalır (K-22) | LAB(2) | +| Medya doğrulama | `test-iso.sh` + `sha256sum -c` geçer | LAB/CI | +| Power/rollback tatbikatı | Önceki onaylı duruma dönüş | LAB/P1 | + +## 11. Rollback + +1. Eğitim sırasında yapılan LAB değişikliği snapshot veya yeniden kurulumla geri alınır. +2. Üretimde arıza görülürse bu rehber yerine 19, 20, 21, 10 ve 17 runbook'ları uygulanır. +3. Yanlış release/konfigürasyon 30'daki manifestli rollback ile geri alınır; medya seviyesinde geri dönüş upstream ISO + son onaylı seed yoludur (K-20). + +## 12. Kontrol Listesi + +- [ ] Katılımcı filesystem, paket, service ve log ayrımını gösterebiliyor. +- [ ] SSH private key/token'ın neden paylaşılmadığını açıklayabiliyor. +- [ ] `PROVISIONED_OFFLINE`, `ENROLLED`, `READY` farkını **ve geçişlerin ileri yönlü olduğunu** biliyor. +- [ ] "GUI kapalı ≠ RDP kapalı" ayrımını (K-22) ve `bf-gui-*` kapsamını açıklayabiliyor. +- [ ] Tek USB Field OS ISO ile upstream ISO + seed geri dönüş yolunu ayırt edebiliyor (K-20). +- [ ] Ubuntu 26.04/RustDesk headless maddelerinin LAB gerekli olduğunu işaretledi. +- [ ] Rollback ve onay kapısının prod değişiklikten önce zorunlu olduğunu biliyor. + +## 13. Açık Sorular + +- [ ] Rol bazlı eğitim süresi ve LAB cihaz sayısı — sahibi: merkezi admin. +- [ ] 26.04.1 autoinstall/xRDP/RustDesk headless LAB sonuçları — sahibi: platform ekibi. +- [ ] Release artefact imzalama standardı — sahibi: release yöneticisi. +- [ ] GNOME vs XFCE A/B ölçüm sonuçlarının eğitim materyaline işlenmesi (K-23) — sahibi: platform ekibi + bu rehberin yazarı. + +--- + +## Ek: Öğrenme sırası + +```mermaid +flowchart LR + L["Linux: filesystem/APT/systemd/log"] --> A["SSH + networking + WireGuard"] + A --> O["Docker + Ansible + monitoring"] + O --> P["Medya: Field OS ISO / autoinstall / NoCloud / offline
+ enrollment (ileri yönlü durum modeli)"] + P --> R["Release + rollback"] + R --> X["Uzak erişim derinleşme:
xRDP her zaman hazır + RustDesk headless (LAB)"] + X --> AB["LAB A/B: GNOME vs XFCE ölçümü (K-23)"] +``` \ No newline at end of file diff --git a/docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md b/docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md new file mode 100644 index 0000000..66fffce --- /dev/null +++ b/docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md @@ -0,0 +1,250 @@ +# 32 — Repository Denetimi ve Çelişki Raporu + +> Kısa özet: `Blueforce-Linux` deposunun Field OS provisioning hedefine karşı denetimi ve çelişki envanteri. Bulgular kanıta dayanır; B-1…B-7'nin bu turda (2026-09-16, ikinci tur) hangi kısmının giderildiği, hangisinin açık kaldığı ve kimin kapsamında olduğu burada dürüstçe kayıtlıdır. + +- Dosya: `docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-20` (medya stratejisi), `#K-21` (durum modeli), `#K-22` (RDP/`bf-gui-*`), `#K-23` (GNOME vs XFCE A/B), `#K-24` (sürüm kimliği); çelişki giderimi ayrıca `#K-01`…`#K-19` ile karşılaştırılmıştır +- Durum: [x] Rapor (denetim güncellemesi: 2026-09-16); [ ] Açık maddeler (`§9`) kapanmadan kapatılmaz + +--- + +## 1. Amaç + +İki amaç: (1) Field OS hedefiyle mevcut çalışan sistem arasındaki farkı kanıtla göstermek, (2) dokümanlar arasındaki **çelişkileri** görünür kılmak ve her birinin giderim durumunu izlemek. Doğrulanamayan hiçbir iddia "tamam" sayılmaz. + +## 2. Kapsam + +- Kapsam içi: dosya sistemi kanıtı, `git status`/`git log`, test sonuçları, resmi kaynak doğrulaması, doküman çelişkileri ve giderim durumu. +- Kapsam dışı: gerçek donanım/QEMU/Windows testleri, gerçek ISO ve offline paket deposunun üretimi (F2B işi), merkezi kontrol düzlemi kodlaması. + +## 3. Bulgular + +```text +KARAR: Bu doküman salt-okunur bir DENETİM raporudur; kendisi mimari karar üretmez, karar günlüğünü (24) ve yol haritasını (25) denetler ve çelişkileri kayıt altına alır. +GEREKÇE: Denetim bulgusunun karar yerine geçmesi, "kanıt" ile "tercih" ayrımını bozar; bu yüzden her bulgu ya bir K-kararına bağlanır ya da açık madde olarak yazılır. +ALTERNATİF: Bulguları doğrudan karar olarak yazmak — aynı konunun iki yerde farklı kararla anılması çelişkisi doğar → elendi. +RİSK: Rapor bayatlarsa yanlış "tamam" izlenimi üretir; azaltma: her turda tarih + giderim durumu tablosu güncellenir (§9). +MALİYET: Ücretsiz. +LİSANS: Yok (kurum içi denetim raporu). +``` + +### 3.1 Özet (ilk tur, 2026-09-16 — tarihsel kayıt) + +Depo, prompt'un büyük bölümünü **zaten uygulamış durumda**; ancak ilk turda iki kritik çıktı eksikti: gerçek bir Field OS ISO'su üretilmiyordu ve offline paket deposu boştu. V1 medya kararı (upstream ISO + seed USB) prompt'un istediği tek-medya deneyimiyle çelişiyordu. + +| Durum (ilk tur) | Sayı | +|---|---| +| Tüm statik testler | **GEÇTİ** (5/5) — *ikinci turda test seti 7 scripte çıktı ve 2 script kırmızı; bkz. §10* | +| Doküman | 32 (26–31 dahil) — *ikinci turda 33 (32 dahil)* | +| Commit edilmemiş değişiklik | 1 büyük çalışma ağacı | + +> **Anlık görüntü uyarısı:** Bu depo 2026-09-16 turunda **paralel çalışma akışlarıyla** aynı anda düzenlenmektedir (offline repo, firstboot, testler, playbook'lar ve bu rapor dahil). §9 ve §10'daki durumlar **10:52 itibarıyla** doğrulanmış anlık görüntüdür; aynı gün içinde başka bir akış tarafından değiştirilebilir. Bir maddeyi kapatmadan önce ilgili komutu yeniden çalıştırın. + +### 3.2 Git durumu + +``` +HEAD: 64a557f chore: keep local .hermes plans out of the repo + 0b56649 docs: Blueforce 700-device fleet architecture (TR) - GNOME baseline + 6402e85 first init. +``` + +- 14 doküman `modified` (README, SUMMARY, docs/01,03,04,05,07,08,14,16,20,21,24,25) +- ~70+ yeni dosya `untracked` (ansible, config, monitoring, provisioning, scripts, tests, rustdesk, docs-site, docs/26–31, IMPLEMENTATION-REPORT.md) +- **Hiçbiri commit edilmemiş.** Bu turda da commit atılmadı (bilinçli). + +### 3.3 Zaten var olanlar (korunacak — yeniden yazılmayacak) + +| Alan | Kanıt | +|---|---| +| `blueforce-install.sh` + 18 modül | `scripts/install/installer/modules/01..18` | +| `BF-<8>/bf-<8>` standardı | `blueforce-install.sh` bayi no kapısı `^[0-9]{8}$` | +| `bf-status`, `bf-diagnostics`, `bf-support-bundle`, `bf-hardware-inventory.sh` | `scripts/diagnostics/` | +| 3-aşamalı durum scriptleri | `bf-check-local`, `bf-check-enrollment`, `bf-check-ready` | +| Yeni komutlar | `bf-enroll`, `bf-enroll-now`, `bf-enrollment-status`, `bf-release`, `bf-remote-status` | +| Canlı HW kontrolü | `scripts/diagnostics/bf-live-hw-check` (91 satır, mutasyonsuz) | +| Windows geçiş araçları | `scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1`, `BF-WindowsDataExport.ps1` | +| Ansible | 16 playbook + 4 rol + inventory (dalga grupları `pilot_1/wave_1` Ansible-uyumlu) | +| Update kapıları | `bf-deploy-update.yml` dalga + `production_override` + `halt_on_fail` | +| WireGuard/SSH/xRDP/RustDesk | config/ + installer modülleri 06,07,08,09 | +| MeshCentral | erişim kanalı olarak dokümante (docs/07) | +| Docker politikaları | `config/docker/daemon.json`, `DOCKER-USER` kalıcı servis | +| Monitoring | `monitoring/` Prometheus + Grafana OSS + Uptime Kuma | +| Docusaurus | `docs-site/` (build PASS) | +| Firewall / logging / recovery | config/ + docs/15, 17 | +| Provisioning iskeleti | `provisioning/{autoinstall,iso,offline-repo,firstboot,enrollment,release}` | +| Testler | 5 script + QEMU planı | + +## 4. Neden Bu Karar? + +Denetim, "çalışıyor mu?" sorusunu **kanıtla** yanıtlamak için vardır. Bu depoda en pahalı hata tipi, "dokümanda yazıyor" ile "gerçekten üretildi" farkının karışmasıdır: ISO dosyası, offline paket deposu ve merkezi enrollment endpoint'i olmadan "offline kurulum çalışıyor" denemez. Aynı şekilde iki doküman aynı konuda farklı şey söylüyorsa (RDP bir yerde `bf-gui-on` gerektirir, başka yerde her zaman hazır; GNOME bir yerde tamamen kapanmış, başka yerde ölçüm adayı), saha teknisyeni yanlış prosedürü uygular. Bu rapor o farkı görünür tutar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Kanıt-temelli yazılı denetim (bu rapor) | Tekrarlanabilir, izlenebilir | Gerçek donanım kanıtı vermez | **Seçildi** | +| Yalnız test çıktısıyla denetim | Hızlı | Doküman çelişkilerini görmez | Yetersiz | +| Yalnız gözle denetim | Esnek | Kanıtsız, tekrar üretilemez | Elendi | + +## 6. Avantajlar + +- Her bulgu dosya/satır kanıtına bağlı; "tamam" iddiası yalnız çalıştırılan komutla veriliyor. +- Giderim durumu tur tur izlenebiliyor (§9), böylece kimse "bu madde kapandı mı?" diye tahmin etmiyor. +- Yazma yetkisi dışında kalan artıklar (örn. `docs/26`) açıkça sahiplendiriliyor; sessiz çelişki kalmıyor. + +## 7. Dezavantajlar + +- Rapor, doküman setinin "kanonik" sayısını artırır (33 numaralı dosya) ve `tests/check-specs.sh` sabitleriyle uyumsuzluk doğurur (§10). +- Bazı maddeler (B-1/B-2) kod gerektirir; rapor onları kapatamaz, yalnız işaretler. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Rapor bayatlar, kapanmış madde açık görünür | Orta | Orta | Her turda §9 tablosu ve tarih güncellenir | +| "Doküman kapandı" diye kod işi (ISO/offline repo) kapanmış sayılır | Orta | Yüksek | §9'da "doküman katmanı" ile "kod/artefact" ayrımı ayrı sütunlarda | +| Yazma yetkisi dışındaki dosyalarda çelişki kalır ve sahaya yansır | Orta | Yüksek | §9'daki "kalan artık + sahip" listesi; PR sahibi atanır | + +## 9. Uygulama Planı — Giderim Durumu (2026-09-16, ikinci tur) + +> Sütunlar: **Durum** = bu turda değişti mi · **Kalan artık** = hâlâ açık olan kısım ve sahibi. + +| ID | İş | Öncelik | Durum (ikinci tur) | Kalan artık | +|---|---|---|---|---| +| B-1 | Gerçek bootable ISO builder | KRİTİK | **KAPANDI (kod)** — builder gerçek medya üretiyor. Boot düzeni `xorriso -report_el_torito as_mkisofs` reçetesinden okunup `-as mkisofs` ile yeniden kuruluyor (sabit değer gömülü değil); üretilen artefakt doğrulandı: `dist/Blueforce-Field-OS-1.0.0-amd64.iso`, 6 481 917 952 byte, SHA256 `cb0cc56f…d431296`, El Torito BIOS+UEFI korunmuş, `/autoinstall.yaml` kökte, `interactive-sections:[storage]`, kimlik/secret yok, `casper/minimal.squashfs` base ile birebir aynı | LAB işi: UEFI/Legacy/Secure Boot boot kabulü, Desktop autoinstall kabulü, air-gapped kurulum (sahibi: LAB; F2B/F3) | +| B-2 | Offline paket manifestleri (somut pin) + repo üretimi | KRİTİK | **KISMEN İLERLEDİ** — manifestler artık yalnız yorum değil: pin biçim sözleşmesi + `requirements.tsv` denetim tablosu + `packages.lock.schema.tsv` yazıldı, `build-offline-repo.sh` `--dry-run-plan` ile sapma denetliyor, `tests/check-offline-repo-static.sh` **PASS**. **Ama pinler hâlâ `UNPINNED` sentinel** (base-packages 14, docker 7, remote-access 7 satır), `packages.lock.tsv` ve `.deb` havuzu yok, `provisioning/offline-repo/packages/` boş | Onaylanmış `.deb` havuzu + gerçek `package=version` pinleri + `packages.lock.tsv` + air-gapped kurulum kanıtı (sahibi: release yöneticisi; F2B) | +| B-3 | Medya kararını uyumlaştır (tek USB, yine assisted) | KRİTİK | **KISMEN KAPANDI (doküman katmanı)** — `docs/24` K-20 eklendi (autoinstall = motor, Field OS ISO = paketleme; `interactive-sections: [storage]` korunur), README/SUMMARY/`docs/25` bu karara göre yazıldı; K-15 silinmedi, yedek yol olarak korundu | `docs/26` hâlâ "V1 medya = upstream ISO + seed, custom ISO tamamlayıcı" diyor; `docs/26` K-20 ile hizalanmalı (sahibi: 26 yazarı). ISO'nun kendisi B-1'de | +| B-4 | Firstboot terminal wizard | YÜKSEK | **BÜYÜK ÖLÇÜDE İLERLEDİ** — `provisioning/firstboot/bf-firstboot` 23 → **381 satır**: Türkçe etiketli interaktif konsol wizard (konsol varsa varsayılan), `--interactive`, `--dealer-id`, `--dealer-id-file`, `--force-reidentity`, `--check` ve test modu. Statik kanıt kapısı bu tur içinde **yeşile döndü** (`tests/test-firstboot-static.sh` PASS, 10:52) | Gerçek konsol/LAB kanıtı (wizard + installer uçtan uca, kimlik değişim prosedürü) ve 3.5'teki `docs/28` bağlantısı (sahibi: provisioning; F2B) | +| B-5 | `--offline` bayrağı doğrula + uygula | YÜKSEK | **KAPANDI (kanıtlı)** — `scripts/install/blueforce-install.sh` `--offline` bayrağını gerçekten destekliyor: yalnız yerel `file:` APT kaynağı, uzak indirme yok, başarıda `state.json`'a `phase=PROVISIONED_OFFLINE` yazıyor; `bf-firstboot` bu bayrağı kullanıyor; `--offline` olmadan damga yazılmıyor | Yok (paket seti için B-2) | +| B-6 | Migration/hw-check konum ve adlarını dokümante et | ORTA | **KAPANDI (dokümante)** — eşleme aşağıda (3.4) yazılı; konum farkı `scripts/` tutarlılığı lehine kabul edildi | Yok | +| B-7 | Doküman çelişkilerini gider | ORTA | **KISMEN KAPANDI** — README başlığı/`Faz 3'te dolar` ifadesi düzeltildi; GNOME/XFCE çelişkisi `docs/24` K-02 güncellemesi + K-23 ile çözüldü; custom ISO/Field OS ISO çelişkisi K-20 ile çözüldü; SUMMARY'nin 11. cevabı (RDP) ve 4. cevabı (`bf-gui-*`) düzeltildi; `docs/24` Ek B notu ("SUMMARY Faz 4'te üretilecek") güncellendi | Aşağıdaki **6 artık** (§3.5) — hepsi yazma kapsamı dışındaki dosyalarda | + +### 3.4 B-6 kapanış kaydı: ad ve konum eşlemesi + +| Prompt'ta istenen | Depodaki gerçek | Not | +|---|---|---| +| `migration/windows/bf-win-inventory.ps1` | `scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1` | Konum `scripts/` altında kabul edildi (depo tutarlılığı); içerik mutasyonsuz/credential'sız (test edilir) | +| `migration/windows/bf-win-network-export.ps1` + `bf-win-software-export.ps1` + `bf-win-device-export.ps1` | `scripts/migration/windows/BF-WindowsDataExport.ps1` | Tek script aynı veri sınıflarını dışa aktarır | +| `scripts/live/bf-live-hw-check` | `scripts/diagnostics/bf-live-hw-check` | Canlı HW kontrolü tanı grubunda | +| `scripts/live/…` AnyDesk modülü | `scripts/install/optional/anydesk/install-anydesk.sh` | Opsiyonel modül; lisans onayı olmadan indirme/kurulum yapmaz | + +### 3.5 B-7 artıkları (bu turda giderilemeyen çelişkiler — yazma kapsamı dışı) + +| Konu | Dosya (düzeltilmeli) | Sorun | Öneri | +|---|---|---|---| +| GNOME/XFCE | `docs/03-UBUNTU-BASELINE.md` (§3 K-02 bloğu, §5 tablo satırı) | "XFCE elendi; yedekte tutulmaz" — `docs/24` K-02+K-23 ile artık ölçüme bağlı | §3'te ALTERNATİF satırını "LAB A/B ölçüm adayı (K-23), saha standardı GNOME" yap; §5 tablosunda sonucu "LAB A/B ölçümü" yaz | +| Custom ISO / Field OS ISO | `docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md` (§3 KARAR, §4, §5) | (a) "Custom ISO/Packer elendi" ve "Custom Field ISO → LAB artefact / **F6**" — K-20 ile assisted Field ISO **F2B**'ye taşındı (Packer hâlâ elenmiş durumda); (b) §3 KARAR "V1'de resmi upstream ISO + ayrı NoCloud seed USB kullanılır" — K-20'de birincil teslim medyası F2B tek-USB'dir, upstream ISO + seed **geri dönüş** yoludur | §4'ü "autoinstall motoru + Field OS ISO paketleme (K-20)" olarak ayır; §5 satırını "F2B assisted artefact; Packer elendi" yap; §3'te "V1'de resmi upstream ISO + seed" cümlesine "geri dönüş yolu" nitelemesi ekle | +| V1 medya (mimari) | `docs/01-ARCHITECTURE.md` (§3 KARAR) | "V1 medya = upstream ISO + ayrı NoCloud seed USB" — K-20/F2B ile birincil medya tek USB Field OS ISO, upstream+seed yedek yol | Cümleyi "birincil: F2B tek-USB Field OS ISO; yedek: upstream ISO + seed (K-20)" olarak güncelle | +| V1 medya (öğrenme rehberi) | `docs/31-LEARNING-GUIDE.md` (§9 konu 11) | "V1'de upstream ISO + ayrı NoCloud seed USB, custom Field ISO'dan daha az risklidir; ikisi tamamlayıcıdır" — F2B hedefiyle çelişir | "assisted tek-USB Field OS ISO (K-20) esas; upstream ISO + seed geri dönüş yolu" olarak düzelt | +| V1 medya (ISO dokümanı) | `docs/26-BLUEFORCE-FIELD-OS-ISO.md` (§3 KARAR, §5 tablo, §7, §12 kontrol listesi) | "V1 kurulum medyası = upstream ISO + ayrı NoCloud seed USB; custom ISO tamamlayıcı" ve "V1 etiketi upstream ISO + seed olarak basıldı" — K-20 ile birincil teslim F2B tek-USB assisted medyadır | K-15 bloğuna "K-20 ile birlikte okunur" notu ekle; §5 tablosunda V1/F2B eşlemesini düzelt; §12 kontrol listesini "F2B tek-USB ISO etiketi" olarak güncelle | +| RDP / `bf-gui-on` | `docs/07-REMOTE-ACCESS.md` (§3 ilk KARAR bloğu) | "GUI varsayılan kapalıdır, bakım penceresinde `bf-gui-on` ile açılır" ifadesi RDP'yi anahtara bağlı gösterir; aynı dosyanın ikinci KARAR bloğu ve `docs/16` xrdp'in her zaman ready olduğunu söyler | İlk blokta "GUI" yerine "**yerel fiziksel** GUI" yaz ve xRDP'in bundan bağımsız her zaman aktif olduğunu ekle (K-22) | +| Windows geçiş kapsamı | `docs/28` §9.2 "Onaylı araçla geri dönüş imajı al…" | Araç adı (`BF-Windows…ps1`) anılmıyor — B-6 kapanışıyla hizalanması iyi olur | §9.2'ye script adlarını ekle | + +### 3.6 Bu turda eklenen yeni bulgular + +| ID | Bulgu | Kanıt | Açık mı? | +|---|---|---|---| +| B-9 | `/etc/blueforce-release` **yazılmıyor**: yalnız `bf-release` okuma yolu + legacy KEY=VALUE uyumluluğu var; yazan bir release pipeline adımı depoda yok | `grep -rn blueforce-release` → sadece `bf-release` (okuma) ve `17-healthcheck`'in `/etc/blueforce/release-manifest.yaml` kopyası | **AÇIK** — sahibi 30-release; K-24 risk satırında kayıtlı | +| B-10 | `tests/check-specs.sh` sabitleri bayat: "exactly docs/00 through docs/31" (32 dosya) ve "expected 34 Mermaid blocks" — depo gerçekte 33 numaralı dosya ve 35 Mermaid bloğu içeriyor | `bash tests/check-specs.sh` → `FAIL: expected 32 numbered docs, found 33` / `FAIL: expected 34 Mermaid blocks, found 35` | **AÇIK** — düzeltme `tests/` kapsamında (bu raporun yazma kapsamı dışı) | +| B-11 | Kanonik doküman sayısı ikinci turda 32→33 oldu; bu rapor da numaralı namespace'te olduğu için B-10'un kök nedeni buradadır | `ls docs/[0-9][0-9]-*.md \| wc -l` = 33 | **AÇIK** — iki seçenek: sabitleri güncelle, veya bu raporu numaralı namespace dışına taşı | +| B-12 | **Paralel düzenleme çakışması:** aynı gün başka bir çalışma akışı bu dosyayı (10:37:21) düzenledi; bu raporun güncellemesi (10:39:32) onun sürümünün üzerine yazıldı ve **o sürüm kurtarılamadı** (dosya git'te untracked, yedek yok) | Hermes araç uyarısı: *"was modified by sibling subagent … after this agent's last read"*; `git status` → `?? docs/32-…`; session kayıtlarında içerik izi yok | **AÇIK (süreç)** — o akışın eklemek istediği bilgi varsa yeniden yazılmalı. Ders: aynı dosyaya iki akış yazacaksa tek sahip atanmalı veya yazmadan önce dosya yeniden okunmalı | + +### 3.7 Açık ve doğrulanmamış iddialar (bilgi amaçlı, değişmedi) + +- Hiç ISO build edilmedi (rapor da kabul ediyor). +- Hiç offline repo üretilmedi. +- Merkezi enrollment endpoint'i yok. +- Gerçek donanım/Windows/QEMU testi yapılmadı. +- `sudo` gerektiren komutlar bu turda çalıştırılmadı (parolasız sudo yok). + +## 10. Test Planı (bu turda çalıştırılanlar ve sonuçları) + +> Anlık görüntü: **2026-09-16 10:52**. Paralel çalışma akışları test setini aynı gün genişlettiği için bu tablo bir iddia değil, **zaman damgalı kanıttır**. + +| Komut | Sonuç | Not | +|---|---|---| +| `tests/check-specs.sh` | **FAIL** | (B-10) iki bayat sabit: `expected 32 numbered docs, found 33` ve `expected 34 Mermaid blocks, found 35`; ayrıca içindeki çağrıyla `test-field-os-lifecycle.sh` düşüyor (aşağıdaki satır) | +| `tests/check-offline-repo-static.sh` | **PASS** | Bu tur eklenen offline repo pin/biçim sözleşmesi denetimi | +| `tests/check-provisioning-static.sh` | **PASS** | — | +| `tests/check-migration-static.sh` | **PASS** | — | +| `tests/check-configs.sh` | **PASS** | — | +| `tests/test-field-os-lifecycle.sh` | **FAIL** | `ENROLLED state with independent local and central evidence must become ready-check eligible` — durum makinesi/READY kapısı üzerinde devam eden akış (K-21/K-22 ile ilişkili) | +| `tests/test-firstboot-static.sh` | **PASS** *(10:52)* | Firstboot wizard/installer bayrak sözleşmesi bu tur içinde yeşile döndü; gerçek konsol LAB kanıtı yine de gerekir (B-4) | +| Doküman yapı denetimi (`docs/[0-9][0-9]-*.md`) | **33/33 uyumlu** | Her dosyada §1–§13 sırası + ≥1 Mermaid (bu rapor bu turda §1–§13 + 1 diyagrama getirildi) | +| Üretim artefaktı denetimi | **YOK** | `*.iso` yok, `*.deb` yok, `packages.lock.tsv` yok, `provisioning/offline-repo/packages/` boş | +| URL doğrulaması (curl, HTTP 200) | **PASS** | Subiquity autoinstall referansı, cloud-init NoCloud, gnome.org, xfce.org, neutrinolabs/xrdp | + +**Test sonucu yorumu:** Altı script yeşil; tek kırmızı `test-field-os-lifecycle.sh` ve bu, K-21/K-22 (durum modeli + RDP/READY sözleşmesi) üzerinde hâlâ çalışılan akışa işaret ediyor. Bu rapor kırmızı testi olan sözleşmeyi **kapalı** saymaz. + +Resmi kaynak doğrulaması (canlı, 2026-09-16 — `canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html`, HTTP 200): + +1. **`/autoinstall.yaml` yolu sabit:** *"The autoinstall configuration is available at `/autoinstall.yaml` (irrespective of how it was provided)"* → ISO köküne konan autoinstall desteklenir. +2. **`interactive-sections` destekli:** *"A list of configuration keys to still show in the user interface (UI)"*. +3. **`storage` interactive olabilir:** *"can be interactive: true"* + varsayılan *"use the lvm layout on single-disk systems"* → storage interactive bırakılmazsa en büyük disk kendiliğinden bölümlenir; bu yüzden `interactive-sections: [storage]` hem tek-USB deneyimini hem "otomatik disk silme yok" şartını sağlar. +4. **Offline davranış resmi anahtar:** `apt.fallback` varsayılanı **`offline-install`** — *"revert to an offline installation"*. +5. **`seed` yolu ve NoCloud** resmi quickstart'ta belgeli (`cloud-localds`, `user-data` + `meta-data`, `ds=nocloud`). + +**Doğrulanamayan:** `xorriso`/remaster ile 26.04 UEFI/Legacy/Secure Boot davranışı — LAB testi gerekir (B-1). + +## 11. Rollback + +1. Bu rapor bir denetim kaydıdır; hatalı bir bulgu düzeltilecekse aynı dosyada tarihli düzeltme notu düşülür (silme yok). +2. Bu turda yapılan doküman değişiklikleri commit edilmediği için geri alma `git checkout -- README.md SUMMARY.md docs/24-*.md docs/25-*.md docs/32-*.md` ile tek adımda yapılabilir. +3. Karar seviyesindeki geri alma (örn. K-20 medya stratejisi) `docs/24` PR'ı ile yapılır ve bağımlı dosyalar (README, SUMMARY, 25, 26, 31) aynı PR'da güncellenir. + +## 12. Kontrol Listesi + +- [x] B-1…B-7'nin her biri için bu turdaki durum yazıldı (kapandı / kısmen / açık). +- [x] Kapanan maddeler kanıtla desteklendi (B-5: `--offline` + `state.json`; B-6: eşleme tablosu). +- [x] Giderilemeyen çelişkiler dosya + öneri ile listelendi (§3.5). +- [x] Yeni bulgular (B-9…B-12) kayda geçti. +- [ ] B-1 (ISO), B-2 (gerçek paket pinleri/pool) ve B-4 (firstboot testleri yeşil) kapanmadan bu rapor "tamam" sayılmaz. +- [ ] §3.5 artıkları (docs/03, 04, 26, 07, 28) doküman PR'ıyla kapatılmalı. +- [ ] `tests/check-specs.sh` sabitleri güncellenmeli (B-10) veya bu rapor numaralı namespace dışına taşınmalı (B-11). +- [ ] Kırmızı testler (`test-field-os-lifecycle.sh`, `test-firstboot-static.sh`) yeşile dönmeli; kapanış ancak bundan sonra ilan edilir. +- [ ] Bu rapora aynı gün içinde tek sahip atanmalı (B-12: paralel yazım çakışması bir tur kaybettirdi). + +## 13. Açık Sorular + +- [ ] Remaster edilmiş Field OS ISO'nun UEFI/Legacy/Secure Boot matrisi — LAB (sahibi: provisioning/release, F2B). +- [ ] Offline APT snapshot'ın yenileme sıklığı ve paket onay süreci (sahibi: release yöneticisi, F2B). +- [ ] `docs/32` kanonik doküman sayılsın mı, yoksa numaralı namespace dışına mı taşınsın (sahibi: doküman sahibi + test sahibi). +- [ ] Bu raporun `docs-site`'a dahil edilip edilmeyeceği (numaralı dosya olduğu için şu an sitede yayınlanıyor) (sahibi: 22 yazarı). + +--- + +## Ek: Çelişki ve giderim haritası + +```mermaid +flowchart TB + subgraph KONU["Çelişki konuları"] + G["GNOME / XFCE"] + M["Custom ISO / Field OS ISO"] + R["RDP / bf-gui-on"] + S["READY / 3 durum"] + F["Unattended F6 / Assisted F2"] + O["Offline / network zorunlu mu"] + end + subgraph KARAR["Karar günlüğü (kilit kaynak)"] + K02["K-02 + K-23"] + K20["K-20 (K-15 ile)"] + K22["K-22 (K-03 ile)"] + K21["K-21 (K-16/K-17 ile)"] + K25["Roadmap F2A/F2B + F6"] + K16["K-16 + K-21"] + end + subgraph DURUM["Bu turdaki durum"] + D1["Giderildi: README/SUMMARY/24/25
Artık: docs/03"] + D2["Giderildi: 24/README/SUMMARY/25
Artık: docs/04, docs/26"] + D3["Giderildi: SUMMARY/24
Artık: docs/07"] + D4["Giderildi: 24/25/README/SUMMARY
Artık yok"] + D5["Giderildi: 25 F2A/F2B/F6
Artık yok"] + D6["Giderildi: 24/SUMMARY
Artık yok"] + end + G --> K02 --> D1 + M --> K20 --> D2 + R --> K22 --> D3 + S --> K21 --> D4 + F --> K25 --> D5 + O --> K16 --> D6 +``` diff --git a/docs/_TEMPLATE.md b/docs/_TEMPLATE.md index 2ac8103..29ff46c 100644 --- a/docs/_TEMPLATE.md +++ b/docs/_TEMPLATE.md @@ -6,6 +6,16 @@ - İlgili kararlar: `24-DECISION-LOG.md#` - Durum: [ ] Taslak / [ ] İncelemede / [ ] Onaylı +**Şablon kuralları (bu repoda zorunlu):** + +- Bölümler `## 1.` … `## 13.` sırasıyla ve atlanmadan doldurulur; ek bölümler yalnız `## Ek: ` başlığıyla açılır (numaralı bölüm eklenmez). +- Her numaralı dokümanda **en az bir Mermaid** diyagram bloğu bulunur (akış, katman, durum veya kapı diyagramı). +- Dil: açıklamalar Türkçe; komut, dosya, servis, paket ve terim adları İngilizce kalır. +- Her teknik iddia kaynaklıdır: KARAR bloklarında resmi kaynak URL + sürüm + doğrulama tarihi yazılır; doğrulanamayan davranış "LAB gerekli" etiketiyle açık bırakılır. +- Dosya numarası bir kez verilir ve yeniden kullanılmaz; kararlar (`24-DECISION-LOG.md`) **append-only**dir, mevcut karar silinmez/yeniden numaralanmaz. +- Secret yasak: örneklerde yer tutucu kullanılır (parola, private key, token, cihaz-spesifik kimlik yazılmaz). +- Bu dosya (`_TEMPLATE.md`) numaralı değildir ve `docs-site`'a alınmaz. + --- ## 1. Amaç diff --git a/migration/README.md b/migration/README.md new file mode 100644 index 0000000..9835ef4 --- /dev/null +++ b/migration/README.md @@ -0,0 +1,30 @@ +# `migration/` — location pointer + +This directory exists only as a **pointer**. The migration tooling is not here. + +- **Actual location:** `scripts/migration/windows/` + (`BF-WindowsPreMigrationInventory.ps1`, `BF-WindowsDataExport.ps1`, `README.md`) +- **Requested location:** `migration/windows/` (the layout the original brief + described). + +The repository keeps every operational script under `scripts/` +(`scripts/{install,maintenance,diagnostics,recovery,migration}`), so the Windows +pre-migration tooling lives in `scripts/migration/windows/` for consistency with +`blueforce-install.sh`, `bf-status`, `bf-hardware-inventory.sh` and the +diagnostics set. The same rule placed `bf-live-hw-check` under +`scripts/diagnostics/` instead of `scripts/live/`. + +## Where to go + +| Need | Path | +|---|---| +| Windows inventory / data-export scripts + full usage | `scripts/migration/windows/README.md` | +| Requested-name ↔ repo-name mapping table | `scripts/migration/windows/README.md` | +| Migration procedure, acceptance gate, rollback | `docs/28-WINDOWS-TO-LINUX-MIGRATION.md` | +| Location/name deviation audit entry (§B-6) | `docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md` | +| Post-migration read-only hardware gate | `scripts/diagnostics/bf-live-hw-check` | +| Static safety tests for the migration tooling | `tests/check-migration-static.sh` | + +No script, wrapper or symlink is placed here on purpose: two real locations for +the same tooling would drift, and the static test asserts the single canonical +directory (`scripts/migration/windows/`). diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 0000000..7467700 --- /dev/null +++ b/monitoring/README.md @@ -0,0 +1,22 @@ +# Central monitoring stack (Prometheus + Grafana OSS + Uptime Kuma) + +## Start + +Create a root-owned password file whose contents are the Grafana first-admin password (no newline is preferred), then start with its absolute path: + +```bash +install -m 600 -o root -g root /dev/null /etc/blueforce/grafana-admin-password +# Write the generated password directly to /etc/blueforce/grafana-admin-password. +cd monitoring +GRAFANA_ADMIN_PASSWORD_FILE=/etc/blueforce/grafana-admin-password docker compose up -d +``` + +`GRAFANA_ADMIN_PASSWORD_FILE` is mandatory. Compose mounts that host file as a Docker secret; it is not stored in the repository or printed in the compose environment. The process owner must keep the file root-owned and mode `0600`. Grafana is available at `http://127.0.0.1:3001`; Prometheus at `:9090`; Uptime Kuma at `:3002`. + +## Fleet targets + +Field devices are scraped via file-based service discovery in `prometheus/targets/*.json` (see `prometheus/targets/README.md`). + +## Alerts + +Validate changes with `promtool check rules prometheus/alerts.yml` and `promtool check config prometheus/prometheus.yml`. \ No newline at end of file diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000..dc4372b --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,47 @@ +# Central monitoring stack for the Blueforce fleet. +# Grafana requires GF_SECURITY_ADMIN_PASSWORD__FILE on first startup; no default credential exists. +services: + prometheus: + image: prom/prometheus:v3.1.0 + container_name: bf-prometheus + restart: unless-stopped + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + - ./prometheus/targets:/etc/prometheus/targets:ro + - prometheus-data:/prometheus + command: [--config.file=/etc/prometheus/prometheus.yml, --storage.tsdb.path=/prometheus, --storage.tsdb.retention.time=90d, --web.enable-lifecycle] + ports: ["127.0.0.1:9090:9090"] + networks: [monitoring] + grafana: + image: grafana/grafana-oss:11 + container_name: bf-grafana + restart: unless-stopped + depends_on: [prometheus] + environment: + GF_SECURITY_ADMIN_USER: "${GF_SECURITY_ADMIN_USER:-admin}" + GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password + secrets: [grafana_admin_password] + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + ports: ["127.0.0.1:3001:3000"] + networks: [monitoring] + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: bf-uptime-kuma + restart: unless-stopped + volumes: [uptime-kuma-data:/app/data] + ports: ["127.0.0.1:3002:3001"] + networks: [monitoring] +secrets: + grafana_admin_password: + file: "${GRAFANA_ADMIN_PASSWORD_FILE:?Set GRAFANA_ADMIN_PASSWORD_FILE to a root-owned password file}" +volumes: + prometheus-data: + grafana-data: + uptime-kuma-data: +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/monitoring/grafana/dashboards/fleet-overview.json b/monitoring/grafana/dashboards/fleet-overview.json new file mode 100644 index 0000000..b7e06fd --- /dev/null +++ b/monitoring/grafana/dashboards/fleet-overview.json @@ -0,0 +1 @@ +{"annotations": {"list": []}, "editable": false, "graphTooltip": 0, "panels": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Devices answering scrapes (up==1) vs silent (up==0).", "fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"text": "OFFLINE"}, "1": {"text": "ONLINE"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": 0}, {"color": "green", "value": 1}]}}, "overrides": []}, "gridPos": {"h": 4, "w": 8, "x": 0, "y": 0}, "id": 1, "options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "up{job=\"node\",device_id=\"$device\"}", "refId": "A"}], "title": "Online / Offline", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "100 - available% across reporting devices.", "fieldConfig": {"defaults": {"max": 100, "min": 0, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 70}, {"color": "red", "value": 90}]}, "unit": "percent"}, "overrides": []}, "gridPos": {"h": 4, "w": 8, "x": 8, "y": 0}, "id": 2, "options": {"colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "100 * (1 - node_memory_MemAvailable_bytes{job=\"node\",device_id=\"$device\"} / node_memory_MemTotal_bytes{job=\"node\",device_id=\"$device\"})", "refId": "A"}], "title": "RAM used %", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Average CPU over 5m from node_cpu_seconds_total.", "fieldConfig": {"defaults": {"max": 100, "min": 0, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 70}, {"color": "red", "value": 90}]}, "unit": "percent"}, "overrides": []}, "gridPos": {"h": 4, "w": 8, "x": 16, "y": 0}, "id": 3, "options": {"colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "100 * (1 - avg(rate(node_cpu_seconds_total{job=\"node\",device_id=\"$device\",mode=\"idle\"}[5m])) / count(rate(node_cpu_seconds_total{job=\"node\",device_id=\"$device\",mode=\"idle\"}[5m])))", "refId": "A"}], "title": "CPU used %", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Root filesystem usage %.", "fieldConfig": {"defaults": {"max": 100, "min": 0, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 75}, {"color": "red", "value": 90}]}, "unit": "percent"}, "overrides": []}, "gridPos": {"h": 6, "w": 12, "x": 0, "y": 4}, "id": 4, "options": {"colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "100 * (1 - node_filesystem_avail_bytes{job=\"node\",device_id=\"$device\",mountpoint=\"/\"} / node_filesystem_size_bytes{job=\"node\",device_id=\"$device\",mountpoint=\"/\"})", "refId": "A"}], "title": "Disk / used %", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Seconds since the last WireGuard handshake; rising values mean a stale tunnel. Source: textfile metric wireguard_last_handshake_seconds.", "fieldConfig": {"defaults": {"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 180}, {"color": "red", "value": 600}]}, "unit": "s"}, "overrides": []}, "gridPos": {"h": 6, "w": 12, "x": 12, "y": 4}, "id": 5, "options": {"colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "time() - wireguard_last_handshake_seconds{job=\"node\",device_id=\"$device\"}", "refId": "A"}], "title": "WireGuard handshake age", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Running containers (1) vs stopped (0), including MEG. Source: textfile metric docker_container_running.", "fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"text": "STOPPED"}, "1": {"text": "RUNNING"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": 0}, {"color": "green", "value": 1}]}}, "overrides": []}, "gridPos": {"h": 6, "w": 24, "x": 0, "y": 10}, "id": 6, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "docker_container_running{job=\"node\",device_id=\"$device\"}", "legendFormat": "{{name}}", "refId": "A"}], "title": "Docker containers", "type": "timeseries"}], "refresh": "30s", "schemaVersion": 39, "tags": ["blueforce", "fleet"], "templating": {"list": [{"current": {}, "datasource": {"type": "prometheus", "uid": "prometheus"}, "definition": "label_values(up{job=\"node\"}, device_id)", "includeAll": false, "multi": false, "name": "device", "options": [], "query": {"query": "label_values(up{job=\"node\"}, device_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery"}, "refresh": 1, "regex": "", "type": "query"}]}, "time": {"from": "now-6h", "to": "now"}, "timepicker": {}, "timezone": "", "title": "Blueforce Fleet Overview", "uid": "bf-fleet-overview", "version": 1} \ No newline at end of file diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..e6d786d --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +# Auto-provision the fleet dashboard from disk (no manual import). +apiVersion: 1 + +providers: + - name: blueforce-fleet + orgId: 1 + folder: Blueforce + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /etc/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..2a573db --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +# Auto-provisioned Prometheus datasource for Grafana OSS. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 0000000..4dd5654 --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -0,0 +1,54 @@ +# Fleet alert rules. Severity: critical pages the on-call admin (14-MONITORING). +# Notification endpoints (SMS/e-mail/webhook) are wired in the central +# Grafana Alerting / Alertmanager setup, not in this file. +# All expressions assume 60s scrape interval. + +groups: + - name: fleet + interval: 60s + rules: + # Device does not answer scrapes for 5 minutes -> presumed offline. + - alert: FleetOffline + expr: up{job="node"} == 0 + for: 5m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} is offline" + description: "No successful scrape for 5m (instance {{ $labels.instance }}). Check Uptime Kuma last-seen and field power/network." + + # Root filesystem less than 10% available for 10 minutes. + - alert: FleetDiskFull + expr: | + ( + node_filesystem_avail_bytes{job="node", mountpoint="/", fstype!~"tmpfs|overlay"} + / + node_filesystem_size_bytes{job="node", mountpoint="/", fstype!~"tmpfs|overlay"} + ) < 0.10 + for: 10m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} disk almost full" + description: "Root filesystem <10% available for 10m on {{ $labels.instance }}." + + # Boot time changed recently -> unexpected reboot (power loss or crash). + - alert: FleetUnexpectedReboot + expr: time() - node_boot_time_seconds{job="node"} < 15 * 60 + for: 5m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} rebooted unexpectedly" + description: "{{ $labels.instance }} booted less than 15m ago. Correlate with power-loss recovery (16-POWER-LOSS)." + + # unattended-upgrades must stay DISABLED (K-11); 1 means the lock was broken. + # Source: textfile metric unattended_upgrade_active. + - alert: FleetSecurityUpdatesViolation + expr: unattended_upgrade_active{job="node"} == 1 + for: 5m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} has unattended-upgrades enabled" + description: "Update policy lock (K-11) violated on {{ $labels.instance }}. Disable unattended-upgrades per 10-UPDATE." diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..71076d7 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,41 @@ +# Prometheus configuration for the Blueforce fleet. +# Scrape interval 60s keeps cardinality/storage tractable at 700 nodes. +# Fleet targets scale via file-based service discovery: add/remove entries +# in targets/fleet.json without reloading Prometheus (refresh_interval 30s). + +global: + scrape_interval: 60s + scrape_timeout: 10s + evaluation_interval: 60s + external_labels: + fleet: blueforce + +rule_files: + - alerts.yml + +scrape_configs: + # Prometheus self-monitoring. + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + # Field devices: Node Exporter (:9100) + textfile collector metrics. + # Identity label contract: device_id="BF-" on every target. + - job_name: node + file_sd_configs: + - files: ["/etc/prometheus/targets/*.json"] + refresh_interval: 30s + relabel_configs: + - source_labels: [__address__] + target_label: __address__ + regex: (.+) + replacement: ${1}:9100 + - source_labels: [device_id] + target_label: device_id + regex: (.+) + replacement: ${1} + metric_relabel_configs: + # Enforce the device_id label contract; drop series without it. + - source_labels: [device_id] + regex: .+ + action: keep diff --git a/monitoring/prometheus/targets/README.md b/monitoring/prometheus/targets/README.md new file mode 100644 index 0000000..9aa1729 --- /dev/null +++ b/monitoring/prometheus/targets/README.md @@ -0,0 +1,17 @@ +# File-based service discovery for the 700-device fleet. +# +# Prometheus watches this directory (refresh 30s); adding or removing a +# `*.json` file takes effect without a reload or restart. +# +# Per-device entry template (WireGuard address as target, identity label +# contract `device_id="BF-"` on every target): +# +# [ +# { +# "targets": ["10.7.0.11"], +# "labels": {"device_id": "BF-12010193", "site": "pilot", "job": "node"} +# } +# ] +# +# Scale-out: generate one file per site/region (e.g. `site-ankara.json`) +# from the Ansible inventory; keep each file under a few thousand targets. diff --git a/monitoring/prometheus/targets/fleet.json b/monitoring/prometheus/targets/fleet.json new file mode 100644 index 0000000..72799b1 --- /dev/null +++ b/monitoring/prometheus/targets/fleet.json @@ -0,0 +1,10 @@ +[ + { + "targets": ["10.7.0.11"], + "labels": { + "device_id": "BF-12010193", + "site": "pilot", + "job": "node" + } + } +] diff --git a/provisioning/README.md b/provisioning/README.md new file mode 100644 index 0000000..26fbc66 --- /dev/null +++ b/provisioning/README.md @@ -0,0 +1,19 @@ +# Blueforce OS provisioning + +Bu dizin işletim sistemi provisioning kapsamının sahibidir. Mevcut `scripts/install/` uygulama bootstrap'ı olarak kalır ve burada taşınmaz. + +Güvenlik sınırları: + +- Bu iskelet cihaz kimliği, parola, private key, token veya bayiye özel veri içermez. +- Autoinstall V1 disk seçmez ve silmez; `interactive-sections: [storage]` teknisyen seçimini zorunlu tutar. +- Upstream ISO ve paketler örtük indirilmez. Her ISO işleminden önce açık SHA256 doğrulaması gerekir. +- Remaster araçları `xorriso` yoksa veya boot metadatası desteklenmiyorsa başarısız olur. +- Firstboot sadece sekiz haneli bayi numarası biçimini denetler; kurucu veya enrollment çağrısı yapmaz. + +Kontrol: + +```bash +tests/check-provisioning-static.sh +``` + +LAB doğrulaması, hedef Ubuntu sürümü, boot menüsü entegrasyonu, gerçek identity storage ve enrollment taşıma güvenliği sonraki fazların açık işleridir. \ No newline at end of file diff --git a/provisioning/autoinstall/README.md b/provisioning/autoinstall/README.md new file mode 100644 index 0000000..e6ce675 --- /dev/null +++ b/provisioning/autoinstall/README.md @@ -0,0 +1,29 @@ +# Autoinstall + +Ubuntu Subiquity autoinstall v1 baseline for the Blueforce Field OS medium. It is +identity-free and stays operator-assisted. + +- `autoinstall.yaml` is copied to the root of the installation medium as + `/autoinstall.yaml`, which is where Subiquity reads configuration from the medium + itself; `build-blueforce-iso.sh` also ships a copy under + `/blueforce-provisioning/autoinstall/`. +- `user-data` is the NoCloud transport for the V1 "official ISO + seed USB" flow. Its + autoinstall block is kept identical to `autoinstall.yaml` so both flows behave the same. +- `interactive-sections: [storage]` is mandatory. The technician selects and confirms the + target disk on the installer screen; V1 never wipes a disk automatically. +- `apt.fallback: offline-install` is explicit: when no primary mirror is reachable, + Subiquity reverts to an offline installation from the medium instead of aborting. +- `late-commands` only copy reviewed medium content into the target: the offline APT + repository to `/opt/blueforce/offline-repo`, the firstboot payload to + `/usr/local/sbin/bf-firstboot`, its service unit to `/etc/systemd/system/`, the release + identity to `/etc/blueforce-release`, and the service is enabled. It stays inert until + an operator supplies the dealer ID and the authorised public key on the installed + system (`ConditionPathExists=` guards in the unit). +- No `identity` block, user, password, SSH key, dealer id or disk directive belongs here; + device identity is collected at firstboot (K-09/K-12). The static test suite enforces + this, and `build-blueforce-iso.sh` re-scans its inputs before it writes a medium. + +Flavour note: this configuration is written for the Ubuntu installer's autoinstall +contract. The LAB build base is currently the Desktop flavour ISO (see `../iso/README.md`); +the autoinstall path on that flavour must be accepted in the LAB matrix before production +use. diff --git a/provisioning/autoinstall/autoinstall.yaml b/provisioning/autoinstall/autoinstall.yaml new file mode 100644 index 0000000..1ca61ab --- /dev/null +++ b/provisioning/autoinstall/autoinstall.yaml @@ -0,0 +1,28 @@ +# Ubuntu Subiquity autoinstall v1 for the Blueforce Field OS installation medium. +# +# This file is copied to the root of the installation medium as /autoinstall.yaml, +# which is where Subiquity looks for configuration on the medium itself. No separate +# NoCloud seed USB is needed, and the same content is shipped under +# /blueforce-provisioning/autoinstall/ for the seed-based V1 flow. +# +# Storage stays interactive on purpose: the operator selects and confirms the target +# disk on the installer screen. This file contains no layout, match, wipe, path or +# identity directive, and device identity is collected at firstboot. +autoinstall: + version: 1 + interactive-sections: [storage] + # No primary mirror is reachable at an air-gapped site: install from the medium. + apt: + fallback: offline-install + late-commands: + # Medium contents: /autoinstall.yaml plus /blueforce-provisioning/{autoinstall, + # firstboot,offline-repo,release}. These commands only copy reviewed medium + # content into the target; no device identity is created or embedded here. + - mkdir -p /target/opt/blueforce + - cp -a /cdrom/blueforce-provisioning/offline-repo /target/opt/blueforce/offline-repo + - "curtin in-target -- sh -c 'cp -a /opt/blueforce/offline-repo/built/. /opt/blueforce/offline-repo/ 2>/dev/null || true'" + - "curtin in-target -- sh -c 'rmdir /opt/blueforce/offline-repo/built 2>/dev/null || true'" + - install -D -m 0755 /cdrom/blueforce-provisioning/firstboot/bf-firstboot /target/usr/local/sbin/bf-firstboot + - install -D -m 0644 /cdrom/blueforce-provisioning/firstboot/blueforce-firstboot.service /target/etc/systemd/system/blueforce-firstboot.service + - install -D -m 0644 /cdrom/blueforce-provisioning/release/blueforce-release /target/etc/blueforce-release + - curtin in-target -- systemctl enable blueforce-firstboot.service diff --git a/provisioning/autoinstall/meta-data b/provisioning/autoinstall/meta-data new file mode 100644 index 0000000..ec124c5 --- /dev/null +++ b/provisioning/autoinstall/meta-data @@ -0,0 +1,2 @@ +# Intentionally empty NoCloud metadata. No instance or machine identity is embedded. +{} \ No newline at end of file diff --git a/provisioning/autoinstall/user-data b/provisioning/autoinstall/user-data new file mode 100644 index 0000000..df246e8 --- /dev/null +++ b/provisioning/autoinstall/user-data @@ -0,0 +1,20 @@ +#cloud-config +# NoCloud payload for the V1 "official ISO + seed USB" flow. The autoinstall block is +# kept identical to provisioning/autoinstall/autoinstall.yaml: the custom Field OS +# medium carries the same configuration at /autoinstall.yaml, so both flows behave the +# same way and storage always stays operator-confirmed. +autoinstall: + version: 1 + interactive-sections: [storage] + # No primary mirror is reachable at an air-gapped site: install from the medium. + apt: + fallback: offline-install + late-commands: + - mkdir -p /target/opt/blueforce + - cp -a /cdrom/blueforce-provisioning/offline-repo /target/opt/blueforce/offline-repo + - "curtin in-target -- sh -c 'cp -a /opt/blueforce/offline-repo/built/. /opt/blueforce/offline-repo/ 2>/dev/null || true'" + - "curtin in-target -- sh -c 'rmdir /opt/blueforce/offline-repo/built 2>/dev/null || true'" + - install -D -m 0755 /cdrom/blueforce-provisioning/firstboot/bf-firstboot /target/usr/local/sbin/bf-firstboot + - install -D -m 0644 /cdrom/blueforce-provisioning/firstboot/blueforce-firstboot.service /target/etc/systemd/system/blueforce-firstboot.service + - install -D -m 0644 /cdrom/blueforce-provisioning/release/blueforce-release /target/etc/blueforce-release + - curtin in-target -- systemctl enable blueforce-firstboot.service diff --git a/provisioning/enrollment/README.md b/provisioning/enrollment/README.md new file mode 100644 index 0000000..2b2e7a7 --- /dev/null +++ b/provisioning/enrollment/README.md @@ -0,0 +1,143 @@ +# Enrollment sözleşmesi (Field OS yaşam döngüsü) + +Bu dizin **sözleşmedir, uç nokta değildir**: istemci davranışı, istek/yanıt şemaları ve +güven modeli burada tanımlanır. Merkezi enrollment API'si bu repoda uygulanmaz; +uygulanması ayrı bir güvenlik incelemesi gerektirir (prompt §18, `docs/29` §13). + +İlgili dosyalar: `docs/29-DEVICE-ENROLLMENT.md` (karar K-17), `docs/26` (K-15), +`schemas/enrollment-request.schema.json`, `schemas/enrollment-response.schema.json`. + +## 1. Durum makinesi (ileri yönlü, atlama yok) + +```text +(yok) --yerel kurulum--> PROVISIONED_OFFLINE --tek kullanımlık token--> ENROLLED --tüm kanallar doğrulandı--> READY +``` + +| Aşama | Ne kanıtlanır | Gerekli kanıt | İnternet | +|---|---|---|---| +| `PROVISIONED_OFFLINE` | Yerel kurulum tamam; cihaz çevrimdışı işe hazır | `bf-check-local`: yerel sistem durumu, xRDP, WireGuard anahtar çifti | Gerekmez | +| `ENROLLED` | Merkez cihazı tanıdı, WireGuard peer atandı, monitoring/inventory/RustDesk yapılandırması uygulandı | Doğrulanmış enrollment yanıtı + `central_verification` referansları + yeni WG handshake | Gerekir | +| `READY` | Yerel sağlık **ve** üç merkezi kanal doğrulandı | `bf-check-ready`: `bf-check-local` + `bf-check-enrollment` + `bf-remote-status --check` | Gerekir | + +Geçiş kuralları (yazıcı ve okuyucu tarafında birlikte uygulanır): + +- **Geriye dönüş yok.** `ENROLLED -> PROVISIONED_OFFLINE` ve `READY -> ENROLLED` yazılmaz; + `bf-enroll` READY cihazda çalışmayı reddeder. READY sonrası ciddi kimlik sorunu + `docs/27` akışıyla yeniden provision edilir. +- **Atlama yok.** `PROVISIONED_OFFLINE -> READY` doğrudan yazılamaz. READY yalnız + `bf-check-ready` geçtikten sonra final gate tarafından yazılır; okuma tarafında + `bf-enrollment-status` böyle bir durumu `INVALID` ve sıfırdan farklı çıkış kodu ile + bildirir (fail-closed). +- **Atomik yazım.** Her geçiş aynı dizinde `tmp + rename` ile yapılır, dosya modu `0600`, + `fsync` uygulanır. Yazıcılar `.state.lock` üzerinde `flock` ile serileştirilir ve geçiş + kilit altında yeniden doğrulanır (boot timer ile teknisyen yarışı). +- **Yetkili yazıcılar.** `PROVISIONED_OFFLINE` yerel kurulum, `ENROLLED` `bf-enroll`, + `READY` final kurulum kapısı. Başka hiçbir araç yaşam döngüsü durumunu değiştirmez: + `bf-status`, `bf-enrollment-status`, `bf-check-*` yalnız okur. + +Durum dosyası sözleşmesi (`/var/lib/blueforce/state.json`, `chmod 600`, `BF_STATE_FILE` +ile geçersiz kılınabilir): `phase`, `enrollment_status`, `fleet_status`, `device_id` +(`BF-`), `provisioning_id`. Bilinmeyen ek alanlar korunur ve yok sayılır. + +## 2. İstek / yanıt sözleşmesi + +**İstek** (`bf-enroll`, `schemas/enrollment-request.schema.json`): `device_id`, +`wireguard_public_key`, `idempotency_key`, `hardware`, `release`. İstek gövdesinde +secret yoktur: WireGuard private key, token değeri ve diğer kimlik bilgileri gövdeye +girmez. Tek kullanımlık token `Authorization` başlığında, argv'ye düşmeyen anonim bir +process-substitution fd'si üzerinden taşınır; başlık dosyaya, state'e veya loga yazılmaz. + +**Yanıt** (`schemas/enrollment-response.schema.json`): `status` (`accepted`), `device_id`, +`idempotency_key` (istekle birebir aynı olmalı), isteğe bağlı `enrollment_id`, +`wireguard` (hub public key + adres + endpoint + allowed_ips) ve **zorunlu** +`central_verification` (`status`, `verification_id` ve her biri `status` + +`verification_id` taşıyan `monitoring`, `remote`, `management`). Yanıt bütünüyle +doğrulanır: fazla alan, uyuşmayan kimlik ya da `idempotency_key` tüm yanıtı reddettirir. +Yerel WireGuard yapılandırması yalnız bu doğrulamadan sonra kurulur; private key +merkezden **kabul edilmez**, cihazda kalır. + +`central_verification` referansları istemci tarafından üretilmez; yalnız doğrulanmış +enrollment yanıtından kalıcılaştırılır. Eksik/biçimsiz kayıt ENROLLED kapısını, `pending` +kanal ise READY kapısını fail-closed düşürür (cihaz ENROLLED kalır). + +## 3. Güven modeli: alternatifler + +| Seçenek | Artı | Eksi | 700 cihaz ölçeği | Sonuç | +|---|---|---|---|---| +| **Tek kullanımlık, TTL'li token** | Küçük sızıntı alanı; kayıp başına iptal edilebilir; cihaz bağlama (`BF-`) | Token üretim/i̇ptal hizmeti gerekir | Cihaz başına ayrı kayıt izi | **Seçildi** (K-17) | +| İmzalı bootstrap bundle (merkez imzalı dosya, cihaz sadece tüketir) | Çevrimiçi token hizmeti gerekmez; imza ile kimlik doğrulama | İmza anahtarı rotasyonu, bundle dağıtım kanalı ve çevrimdışı "eski bundle" riski; geri çekme zor | Bundle sürüm/iptal yönetimi filo çapında iş yükü | Elendi (V1) | +| Paylaşılan kalıcı bootstrap secret | Kurulumu basit | Tek sızıntı tüm filoyu etkiler; cihaz başına iptal imkânsız | Kabul edilemez blast-radius | Elendi | +| Manuel merkezi kayıt | Ek sistem yok | Denetim izi zayıf, insan hatası | 700 kayıt için sürdürülemez | Elendi | + +Hiçbir seçenekte **700 cihaza ortak kalıcı secret konmaz**. Karar kaydı: `docs/24` K-17, +alternatif analizi `docs/29` §5. + +### 3.1 Çevrimdışı → çevrimiçi geçiş (katılım) mekanizması + +Teknisyen tokenı cihaz başına bir kez iletir: + +```bash +# İnternet varken anında enrollment (önerilen): +printf '%s\n' '' | sudo -E bf-enroll --token-stdin + +# İnternet yokken: token'ı cihazda bırakmadan boot denemesine hazırla: +printf '%s\n' '' | sudo -E bf-enroll-now --stage +``` + +`--stage` gerekçesi: saha cihazı çoğu kez önce çevrimdışı kurulur. İnternet geldiğinde +"kendiliğinden katılma" için tek kullanımlık token'ın cihazda **sınırlı ömürlü** bir +bekleme dosyasında durması gerekir. Uygulanan sınırlar: + +- Dosya `/enrollment.token`, mod `0600`, sahibi çalıştıran kullanıcı (root), + TTL `BF_ENROLLMENT_TOKEN_MAX_AGE` (varsayılan 1800 sn, süre sonunda silinir). +- Token argv'ye, state dosyasına, support bundle'a ve kurulum medyasına (USB/ISO) hiç + yazılmaz; yalnız bu tek dosyada ve yalnız boot denemesi okuyabilir. +- Token başarılı enrollment'ta veya kesin (HTTP 4xx) reddedilmede **hemen** silinir. + Yalnız geçici hata (DNS/TCP/TLS/zaman aşımı) token'ı sınırlı yeniden deneme için korur. +- Support bundle bu dosyayı toplamaz; `--stage` yalnız root tarafından kullanılabilir. + +Bu mekanizma tek kullanımlık token'ın replay engelini değiştirmez: aynı token merkezde +yalnız bir kez tüketilir, `idempotency_key` retry'leri birleştirir. + +## 4. Başarısızlık davranışı (reboot loop yok) + +1. HTTP isteği başarısız: yaşam döngüsü önceki aşamada kalır (`PENDING` / + `PROVISIONED_OFFLINE`); hiçbir yapılandırma kurulmaz. +2. Yanıt doğrulaması başarısız: durum dosyası değişmez, geçici dosyalar silinir. +3. WireGuard etkinleştirmesi başarısız: yeni `wg0.conf` geri alınır (öncesi varsa eski + hali geri yüklenir, yoksa unit devre dışı bırakılır), durum dosyası yazılmaz. +4. Cihaz reboot edilmez: `blueforce-enroll.service` `Restart=on-failure` + + `RestartSec=5min` + `StartLimitBurst=6` / `StartLimitIntervalSec=1h` ile en fazla + saati 6 deneme yapar, sonrası `blueforce-enroll.timer` kadansına döner. İnternet yokken + agresif deneme yapılmaz; internet gelince cihaz kendiliğinden katılır. +5. Token yoksa veya durum `READY`/kurulmamış ise deneme hiçbir iş yapmadan `0` ile çıkar + (`PENDING` mesajı ile): yeniden deneme fırtınası oluşmaz. + +## 5. Araçlar ve aşama kapıları + +| Araç / unit | Aşama | Not | +|---|---|---| +| `bf-check-local` | `PROVISIONED_OFFLINE` | İnternet, handshake ve merkez kanıtı **gerekmez**; eksik `wg0.conf` yalnız uyarıdır | +| `bf-enroll` | `PROVISIONED_OFFLINE -> ENROLLED` | Token stdin'den ya da sahnelenmiş dosyadan; `--check` dry-run | +| `bf-check-enrollment` | `ENROLLED` | Merkez kaydı + atanmış peer + canlı tünel + yeni handshake; handshake yokluğu **başarısızlık** | +| `bf-check-ready` | `READY` | Yerel sağlık + tüm merkezi kanallar; kalıcı READY'a güvenmez | +| `bf-enrollment-status` | tümü (salt-okunur) | Sözleşme doğrulayıcı; `INVALID` durumda çıkış kodu ≠ 0 | +| `bf-status` | tümü (salt-okunur) | `Provisioning` / `Enrollment` / `Fleet State` özet bloğu | +| `blueforce-enroll.service` + `blueforce-enroll.timer` | otomatik deneme | Boot +10 dk, sonra günlük; sınırlı `Restart=` | + +`blueforce-enrollment.timer` **ayrıca oluşturulmaz**: kurucu modül (`17-healthcheck`) yalnız +`blueforce-enroll.service` ve `blueforce-enroll.timer` dosyalarını cihaza kurar; ikinci bir +timer kurulmadığı için ölü yapılandırma olurdu. Periyodik kadans mevcut timer ve +servisin sınırlı `Restart=` davranışından gelir. + +## 6. Doğrulama + +```bash +bash tests/test-field-os-lifecycle.sh # sözleşme, aşama geçişleri, fail-closed davranışı +bash tests/check-provisioning-static.sh # şema/secret/cihaz-kimliği taraması +``` + +Şemalar JSON Schema 2020-12'dir ve `bf-enroll`'in gerçek davranışıyla birebir hizalanır +(zorunlu alanlar, `wireguard` alt alanları, `central_verification` biçimi). Endpoint +uygulaması, kimlik doğrulama, imzalama, iptal ve deneme politikası ayrı güvenlik +incelemesi olmadan eklenmeyecektir. diff --git a/provisioning/enrollment/schemas/enrollment-request.schema.json b/provisioning/enrollment/schemas/enrollment-request.schema.json new file mode 100644 index 0000000..71cf61f --- /dev/null +++ b/provisioning/enrollment/schemas/enrollment-request.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Blueforce enrollment V1 request", + "description": "Sent by bf-enroll from a PROVISIONED_OFFLINE device. Carries public facts only: the WireGuard private key, the one-time token value and every other credential stay on the device. The single-use token travels in the Authorization header, never in this document.", + "type": "object", + "additionalProperties": false, + "required": ["device_id", "wireguard_public_key", "idempotency_key", "hardware", "release"], + "properties": { + "device_id": { + "type": "string", + "pattern": "^BF-[0-9]{8}$", + "description": "Fleet identity derived from the validated dealer number." + }, + "wireguard_public_key": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]{43}=$", + "description": "Device public key only. The matching private key never leaves the device." + }, + "idempotency_key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "sha256(provisioning_id | device_id | wireguard_public_key). Stable across retries so central can deduplicate retries while the one-time credential stays single-use." + }, + "hardware": { + "type": "object", + "additionalProperties": false, + "required": ["machine_id", "product_uuid", "sys_vendor"], + "properties": { + "machine_id": {"type": "string"}, + "product_uuid": {"type": "string"}, + "sys_vendor": {"type": "string"} + } + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": ["os_release", "kernel"], + "properties": { + "os_release": {"type": "string"}, + "kernel": {"type": "string"} + } + } + } +} diff --git a/provisioning/enrollment/schemas/enrollment-response.schema.json b/provisioning/enrollment/schemas/enrollment-response.schema.json new file mode 100644 index 0000000..6b41f64 --- /dev/null +++ b/provisioning/enrollment/schemas/enrollment-response.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Blueforce enrollment V1 response", + "description": "Accepted enrollment response. It carries the assigned WireGuard peer, the deterministic idempotency key and mandatory central_verification evidence references. It never carries a WireGuard private key, a credential, a URL, a RustDesk credential or any other confidential value. The device assembles its own configuration and keeps its own private key.", + "type": "object", + "additionalProperties": false, + "required": ["status", "device_id", "idempotency_key", "wireguard", "central_verification"], + "properties": { + "status": { + "const": "accepted", + "description": "Any other value must be treated as a rejection; the device stays PENDING." + }, + "device_id": { + "type": "string", + "pattern": "^BF-[0-9]{8}$" + }, + "idempotency_key": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "Echo of the request key. A mismatch rejects the whole response." + }, + "enrollment_id": { + "type": "string", + "minLength": 1, + "description": "Central audit reference for this enrollment record." + }, + "wireguard": { + "type": "object", + "additionalProperties": false, + "required": ["address", "endpoint", "server_public_key", "allowed_ips"], + "properties": { + "address": { + "type": "string", + "pattern": "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}/(?:[0-9]|[12][0-9]|3[0-2])$", + "description": "Device tunnel address with prefix, for example 10.20.0.17/32." + }, + "endpoint": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9.-]*:[1-9][0-9]{0,4}$", + "description": "Central hub endpoint as host:port. Placeholder values are rejected locally." + }, + "server_public_key": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]{43}=$", + "description": "Hub public key only; a private key is never accepted here." + }, + "allowed_ips": { + "type": "string", + "minLength": 1, + "description": "Comma-separated tunnel routes; must be a single line." + } + } + }, + "central_verification": { + "type": "object", + "additionalProperties": false, + "required": ["status", "verification_id", "monitoring", "remote", "management"], + "properties": { + "status": {"enum": ["pending", "verified"]}, + "verification_id": {"type": "string", "minLength": 1}, + "monitoring": {"$ref": "#/$defs/verification"}, + "remote": {"$ref": "#/$defs/verification"}, + "management": {"$ref": "#/$defs/verification"} + }, + "description": "Central evidence references. The device never invents them: without a well-formed record the ENROLLED gate fails closed, and READY additionally requires every channel to be verified." + } + }, + "$defs": { + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["status", "verification_id"], + "properties": { + "status": {"enum": ["pending", "verified"]}, + "verification_id": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/provisioning/firstboot/README.md b/provisioning/firstboot/README.md new file mode 100644 index 0000000..b9a88ea --- /dev/null +++ b/provisioning/firstboot/README.md @@ -0,0 +1,154 @@ +# First boot — terminal wizard, kimlik koruması ve yarıda kalan kurulum + +Bu dizin, cihazın ilk açılışında sahada çalışan giriş noktasını barındırır: + +- `bf-firstboot` — bayi numarasını toplayan/doğrulayan ve yerel offline kurucuyu bir kez çalıştıran script. +- `blueforce-firstboot.service` — boot'ta script'i bir kez çalıştıran systemd unit'i. +- Bu doküman — gerçek davranış, otomasyon yolu, kimlik değişimi prosedürü ve secret kuralları. + +İlk boot, yalnız Subiquity'nin assisted storage onayıyla tamamlanan kurulumdan ve yeniden başlatmadan sonra çalışır. +Kimlik formatı `^[0-9]{8}$` (`docs/02-DEVICE-NAMING-AND-INVENTORY.md`) ve merkezi durumlar +(`PROVISIONED_OFFLINE` → `ENROLLED` → `READY`, `docs/29-DEVICE-ENROLLMENT.md`) bu adımın dışındadır: +firstboot yalnız offline provision'ı yapar, enrollment ve READY ayrı adımlardır. + +## 1. Wizard (teknisyen ekranı) + +Konsolda (TTY mevcut ve otomasyon istenmemiş) teknisyen şu ekranı görür: + +```text +# BLUEFORCE FIELD OS +8 Haneli Bayi Numarası: +> _ +``` + +Davranış: + +- Girdi `^[0-9]{8}$` ile doğrulanır. **Hatalı girdi kabul edilmez**; hata satırı ve `(deneme N/3)` ile yeniden sorulur. + Yalnız baştaki/sondaki boşluk kırpılır (yapıştırma artığı); değerin içinde boşluk veya rakam dışı karakter varsa geçersiz sayılır. +- 3 hatalı denemeden sonra script net bir hatayla `exit 2` verir ve hiçbir değişiklik yapmaz + (`BF_FIRSTBOOT_MAX_ATTEMPTS` ile deneme sayısı değiştirilebilir). +- Doğru girdide onay ekranı gösterilir; onay `e`/`evet`/`y`/`yes` ile alınır: + +```text +Device ID (cihaz kimliği): BF- +Hostname (makine adı) : bf- +Kurulum bu kimlikle başlatılsın mı? [e/H]: +``` + +- Onay verilmezse kurulum başlamaz; deneme hakkı tükenirse `exit 2`. +- `read` zaman aşımına uğrarsa (varsayılan 300 sn, `BF_FIRSTBOOT_INPUT_TIMEOUT`) script + "no terminal input arrived" hatasıyla `exit 2` verir; konsolda kimse olmadığında sonsuza kadar bloklamaz. + +TTY seçimi: stdin zaten bir terminal ise (systemd `StandardInput=tty-force` durumu) fd 0 kullanılır; +değilse `/dev/tty` açılır; o da açılamıyorsa wizard atlanır ve argüman/dosya yoluna düşülür. + +## 2. Kimlik çözümleme sırası + +| # | Kaynak | Mod | +|---|---|---| +| 1 | `--dealer-id <8hane>` | non-interactive (otomasyon) | +| 2 | `--dealer-id-file ` | non-interactive (otomasyon) | +| 3 | `--interactive` | wizard (TTY zorunlu) | +| 4 | `BF_FIRSTBOOT_NONINTERACTIVE=1` | non-interactive; seed dosyası (`/etc/blueforce/dealer-id`) okunur | +| 5 | Seed dosyası `/etc/blueforce/dealer-id` geçerliyse | non-interactive; wizard atlanır (log satırı yazılır) | +| 6 | TTY mevcut | wizard | +| 7 | Hiçbiri | net hata + `exit 2` (argüman/dosya önerilir) | + +Otomasyon yolu (1, 2, 4) hiçbir soru sormaz: seed/USB ile kurulan cihazda teknisyen girdisi gerekmez. +1. ve 2. maddeler açıkça verildiği için onay ekranı çıkmaz; bu bilinçli bir otomasyon kapısıdır. + +### Komut arayüzü + +```bash +sudo bf-firstboot # wizard (konsol) +sudo bf-firstboot --dealer-id <8hane> # otomasyon +sudo bf-firstboot --dealer-id-file /path/to/dealer-id +sudo bf-firstboot --check --dealer-id <8hane> # salt-okunur denetim +sudo bf-firstboot --interactive # seed dosyası olsa bile wizard +BF_FIRSTBOOT_NONINTERACTIVE=1 sudo bf-firstboot # boot/otomasyon +``` + +Ortam değişkenleri: `BF_FIRSTBOOT_MARKER` (marker yolu), `STATE_DIR` (varsayılan `/var/lib/blueforce`), +`BF_INSTALLER_PATH` (kurucu yolu), `BF_ADMIN_PUBLIC_KEY_FILE`, `BF_FIRSTBOOT_MAX_ATTEMPTS`, +`BF_FIRSTBOOT_INPUT_TIMEOUT`, `BF_FIRSTBOOT_TEST_MODE`, `BF_FIRSTBOOT_NONINTERACTIVE`. + +### Çıkış kodları + +| Kod | Anlam | +|---|---| +| 0 | Kurulum tamam (marker yazıldı) veya zaten tamamlanmış (idempotent) veya `--check` raporu | +| 1 | Offline önkoşul eksik (admin public key), root yok, kurucu yok, veya kurucu hata verdi | +| 2 | Girdi hatası: geçersiz bayi numarası, terminal yok, onay verilmedi, deneme tükendi | + +## 3. İdempotentlik ve yarıda kalan kurulum + +- Marker: `/var/lib/blueforce/firstboot-complete` (mode `600`, içinde bayi numarası). +- Marker varsa script **hiçbir şey yapmaz**: sormaz, yazmaz, kurucuyu çağırmaz, `exit 0`. Wizard bu durumda asla açılmaz. +- Marker varken farklı bir bayi numarası verilirse identity değişmez; yalnız uyarı yazılır ve admin prosedürüne + (§4) işaret edilir. Sıradan kullanıcı yoluyla kimlik değiştirilemez. +- Marker yalnız kurucu başarıyla bittikten sonra yazılır (`set -euo pipefail` sayesinde hata hâlinde yazılmaz). +- Önceki koşu yarıda kaldıysa (`$STATE_DIR/install-state` dolu) kurucu `--resume` ile çağrılır: + kurucu `--offline --dealer-id --yes` (+ gerekirse `--resume`) argümanlarıyla koşar. +- Script her zaman kurucuyu `--offline` ile çağırır; kurucu `PROVISIONED_OFFLINE` durumuna tamamlar. +- `--check` hiçbir dosya/dizin/log üretmez ve kurucuyu çağırmaz. + +## 4. Kimlik değişimi prosedürü (admin / recovery — varsayılan KAPALI) + +Bayi numarası değişimi **sıradan kullanıcı yolu değildir**. Wizard ve normal koşu bu yolu içermez. +Yalnız root + açık operatör kapısı + yazılı onay + audit kaydı ile çalışır: + +1. Fiziksel konsolda root oturumu açılır ve operatör kapısı açıkça verilir: `BF_ALLOW_REIDENTITY=1`. +2. Komut: `BF_ALLOW_REIDENTITY=1 sudo -E bf-firstboot --force-reidentity --dealer-id `. +3. Ekranda eski/yeni kimlik ve "dokuz merkezi sistem güncellenmeli" uyarısı gösterilir. +4. Onay, **yeni bayi numarasının yeniden yazılması** ile alınır; eşleşmezse hiçbir değişiklik yapılmaz (`exit 2`). +5. Kurucu yeni kimlikle koşar, eski marker `firstboot-complete.reidentified-` olarak saklanır, + yeni marker yazılır. +6. Her adım `/var/log/blueforce-reidentity.log` (mode `600`, UTC zaman + `uid=`) ve journal'a yazılır: + kim, ne zaman, eski → yeni. Audit log yazılamıyorsa değişim **reddedilir** (fail-closed). + +Kapılar: root değilse `exit 1`; `BF_ALLOW_REIDENTITY=1` yoksa `exit 1`; TTY yoksa `exit 2`. +Değişimden sonra `docs/02-DEVICE-NAMING-AND-INVENTORY.md` §9'daki yeniden-numaralandırma adımları +(WireGuard peer, RustDesk/MeshCentral etiketi, Ansible inventory, monitoring etiketi) uygulanır. + +## 5. Secret kuralları + +- `BF_ADMIN_PUBLIC_KEY_FILE` **yalnız public SSH key** içerebilir; root sahipliğinde ve grup/dünya + tarafından yazılamaz olmalıdır (`root:600` veya `root:640`). Private key, inline anahtar veya + boş/okunamayan dosya `OFFLINE BLOCKED` ile reddedilir. +- Bu kural değiştirilmedi: dosya `ssh-ed25519`/`ssh-rsa`/`ssh-ecdsa`/`sk-ssh-ed25519` public key satırı + ile başlamalı ve `PRIVATE KEY` içermemelidir. +- Script secret **loglamaz**: mesajlarda yalnız dosya yolları ve cihaz kimlikleri geçer; anahtar içeriği, + token, parola hiçbir satıra yazılmaz (`--force-reidentity` audit kaydı da yalnız kimlikleri yazar). +- Wizard girdisi ekrana yankılanır (teknisyen yazar); bu ekran token/parola istemez, yalnız bayi numarası alır. +- ISO'da geçerli yerel APT indeksleri, checksum/lock ve tüm somut pinli paketler yoksa işlem + `OFFLINE BLOCKED` ile durur ve cihaz provision edilmiş sayılmaz. Yalnız offline bundle + public-key + önkoşulları geçerse `PROVISIONED_OFFLINE` yazılır. + +## 6. systemd unit ve boot'u kilitlememe + +`blueforce-firstboot.service`: + +- `ConditionPathExists=/etc/blueforce/admin-authorized-key.pub` — zorunlu seed önkoşulu yoksa unit hiç başlamaz. +- `ConditionPathExists=!/var/lib/blueforce/firstboot-complete` — provision edilmiş cihazda atlanır (başarısız sayılmaz). +- `StandardInput=tty-force` + `TTYPath=/dev/tty1` — wizard sahada konsolda görünür. +- TTY kurulamazsa (konsolsuz/headless sistem) systemd unit'i başarısız sayar, **boot devam eder**; + script de TTY yoksa argüman/dosya yoluna düşer. +- `TimeoutStartSec=45min` — konsolda kimse yoksa job sonsuza kadar açık kalmaz. +- `Type=oneshot`, `RemainAfterExit=yes`, `WantedBy=multi-user.target` — boot'ta bir kez çalışır. +- `NoNewPrivileges=yes`, `PrivateTmp=yes` korunur. `ProtectSystem=strict` ve `ProtectHome=yes` + **bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve `/home/blueforce` altına yazar. + +Not: unit çıktısı konsol TTY'sine gider; aynı satırlar `logger -t bf-firstboot` ile journal'a da yazılır, +kurucu kendi logunu `/var/log/blueforce-install.log` içinde tutar. + +## 7. Test + +```bash +bash tests/test-firstboot-static.sh +``` + +Statik test, gerçek systemd/apt işlemi yapmadan şunları doğrular: bayi numarası regex'i, hatalı girdinin +reddi, wizard ekranı ve onay akışı (pty ile), marker idempotentliği, kimlik değişiminin kapalı olması, +public-key önkoşulu ve secret sızıntısı olmadığı, `--check` ve `BF_FIRSTBOOT_TEST_MODE` modlarının +mutasyonsuzluğu. Root gerektiren senaryolar `unshare -r` ile ayrı bir kullanıcı ad alanında koşar; +bu izin yoksa ilgili testler `SKIP` olarak raporlanır. diff --git a/provisioning/firstboot/bf-firstboot b/provisioning/firstboot/bf-firstboot new file mode 100755 index 0000000..4e0bf70 --- /dev/null +++ b/provisioning/firstboot/bf-firstboot @@ -0,0 +1,390 @@ +#!/usr/bin/env bash +# Blueforce Field OS first-boot entry point. +# +# Role: obtain the 8-digit dealer number (console wizard for a technician, or a flag/file +# for automation), validate it, verify the offline prerequisites, run the local offline +# installer exactly once, then stamp the completion marker. +# +# Every comment and identifier in this file is English. The only Turkish strings are the +# technician-facing wizard labels, because the field console UI is Turkish. +# +# Guarantees: +# * idempotent: when the marker exists the run changes nothing and exits 0; +# * identity is write-once: the device identity cannot be changed through the normal +# path or through the wizard; changing it needs the separate admin procedure +# (--force-reidentity), which stays disabled unless BF_ALLOW_REIDENTITY=1; +# * fail-closed: an invalid dealer number, a missing terminal, or an unacceptable +# administrator public key aborts with an explicit message and a non-zero exit; +# * no secret material is ever printed, logged, or copied. Only key paths, never key +# content, are used in messages. +set -euo pipefail + +PROG='bf-firstboot' +DEALER_ID_RE='^[0-9]{8}$' +DEFAULT_DEALER_FILE='/etc/blueforce/dealer-id' +DEFAULT_ADMIN_KEY_FILE='/etc/blueforce/admin-authorized-key.pub' +DEFAULT_MARKER='/var/lib/blueforce/firstboot-complete' +DEFAULT_INSTALLER='/usr/local/sbin/blueforce-install.sh' +AUDIT_LOG="${BF_REIDENTITY_AUDIT_LOG:-/var/log/blueforce-reidentity.log}" + +MARKER="${BF_FIRSTBOOT_MARKER:-$DEFAULT_MARKER}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +INPUT_TIMEOUT="${BF_FIRSTBOOT_INPUT_TIMEOUT:-300}" +MAX_ATTEMPTS="${BF_FIRSTBOOT_MAX_ATTEMPTS:-3}" + +DEALER_ID='' +DEALER_FILE="$DEFAULT_DEALER_FILE" +DEALER_FILE_GIVEN=0 +CHECK_ONLY=0 +FORCE_REIDENTITY=0 +FORCED_INTERACTIVE=0 +NONINTERACTIVE="${BF_FIRSTBOOT_NONINTERACTIVE:-0}" +TTY_FD='' +WIZ_OUT_FD='1' +INSTALLER='' +KEY_FILE='' + +usage() { + cat <<'EOF' +Usage: bf-firstboot [--dealer-id 8DIGITS] [--dealer-id-file PATH] + [--interactive] [--non-interactive] [--check] + +Interactive (default when a console is present): the wizard prints the field console +banner, asks for the 8-digit dealer number, shows the resulting Device ID and hostname, +and asks for confirmation before anything is installed. + +Automation: --dealer-id or --dealer-id-file (or BF_FIRSTBOOT_NONINTERACTIVE=1 with the +seeded dealer id file) never prompts, so the offline installer can run unattended. + + --check validate the inputs and report; creates no state and no marker + --interactive force the wizard, even when a seeded dealer id file exists + --non-interactive never prompt; use --dealer-id-file or the seeded dealer id file + +Identity changes are not part of this command. They use the separate admin-only +--force-reidentity path, which is documented in provisioning/firstboot/README.md and +stays disabled unless BF_ALLOW_REIDENTITY=1 is set by an operator at the console. +EOF +} + +# Every message carries dealer ids, device ids and paths only. Secret material (key +# contents, tokens, passwords) is never formatted into them, so printing them is safe. +# The systemd unit sends stdout/stderr to the console TTY, so each line is also mirrored +# to the journal through logger when that tool is present. +journal() { + command -v logger >/dev/null 2>&1 || return 0 + printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*" | logger -t "$PROG" 2>/dev/null || true +} +log() { printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*"; journal "$*"; } +warn() { printf '%s [%s] WARNING: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*" >&2; journal "WARNING: $*"; } +err() { printf '%s [%s] ERROR: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*" >&2; journal "ERROR: $*"; } + +bf_device_id() { printf 'BF-%s' "$1"; } +bf_hostname() { printf 'bf-%s' "$1"; } +valid_dealer_id() { [[ "$1" =~ $DEALER_ID_RE ]]; } + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --dealer-id) + [[ -n "${2:-}" ]] || { err '--dealer-id needs a value: exactly 8 digits'; usage >&2; exit 2; } + DEALER_ID="$2"; shift 2;; + --dealer-id-file) + [[ -n "${2:-}" ]] || { err '--dealer-id-file needs a path'; usage >&2; exit 2; } + DEALER_FILE="$2"; DEALER_FILE_GIVEN=1; shift 2;; + --check) CHECK_ONLY=1; shift;; + --force-reidentity) FORCE_REIDENTITY=1; shift;; + --interactive) FORCED_INTERACTIVE=1; shift;; + --non-interactive) NONINTERACTIVE=1; shift;; + --help|-h) usage; exit 0;; + *) err "unknown argument: $1"; usage >&2; exit 2;; + esac + done + [[ "$MAX_ATTEMPTS" =~ ^[1-9][0-9]*$ ]] || { err 'BF_FIRSTBOOT_MAX_ATTEMPTS must be a positive integer'; exit 2; } + [[ "$INPUT_TIMEOUT" =~ ^[0-9]+$ ]] || { err 'BF_FIRSTBOOT_INPUT_TIMEOUT must be a number of seconds'; exit 2; } + [[ "$NONINTERACTIVE" == 0 || "$NONINTERACTIVE" == 1 ]] || { err 'BF_FIRSTBOOT_NONINTERACTIVE must be 0 or 1'; exit 2; } +} + +read_dealer_id_file() { + local path="$1" value + [[ -f "$path" && -r "$path" ]] || { err "dealer id file is missing or unreadable: $path"; exit 2; } + value="$(tr -d '[:space:]' < "$path" || true)" + [[ -n "$value" ]] || { err "dealer id file is empty: $path"; exit 2; } + printf '%s' "$value" +} + +marker_exists() { [[ -e "$MARKER" ]]; } + +marker_device_id() { + [[ -f "$MARKER" ]] || return 0 + tr -d '[:space:]' < "$MARKER" || true +} + +write_marker() { + local tmp + install -d -m 700 "$(dirname "$MARKER")" + tmp="$MARKER.tmp.$$" + printf '%s\n' "$DEALER_ID" > "$tmp" + chmod 600 "$tmp" + mv -f "$tmp" "$MARKER" + log "marker written: $MARKER (device $(bf_device_id "$DEALER_ID"))" +} + +# Attach the wizard to a terminal. stdin is used when it already is a terminal (this is +# the systemd StandardInput=tty-force case); otherwise /dev/tty is opened, and when that +# fails too the caller falls back to the automation input path. +open_tty() { + if [[ -t 0 ]]; then TTY_FD=0; WIZ_OUT_FD=1; return 0; fi + if { exec 3<>/dev/tty; } 2>/dev/null; then TTY_FD=3; WIZ_OUT_FD=3; return 0; fi + return 1 +} + +require_tty() { + open_tty && return 0 + err 'a terminal is required for the interactive wizard, but none is available; pass --dealer-id or --dealer-id-file instead' + exit 2 +} + +wizard_out() { printf '%s\n' "$*" >&"$WIZ_OUT_FD"; } +wizard_write() { printf '%s' "$*" >&"$WIZ_OUT_FD"; } + +wizard_read() { + local __name="$1" __value='' + read -r -t "$INPUT_TIMEOUT" -u "$TTY_FD" __value || return 1 + printf -v "$__name" '%s' "${__value%$'\r'}" +} + +# Trim surrounding whitespace only: a pasted value keeps working, while any other +# deviation from the 8-digit identity is rejected and asked again. +trim_space() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +# Interactive identity collection. A malformed number or a refused confirmation is never +# accepted: after MAX_ATTEMPTS the run aborts without changing anything. +wizard_collect() { + local attempt=1 line answer + wizard_out '# BLUEFORCE FIELD OS' + while (( attempt <= MAX_ATTEMPTS )); do + wizard_out '8 Haneli Bayi Numarası:' + wizard_write '> ' + if ! wizard_read line; then + err 'no terminal input arrived (timed out); first boot aborted without changes' + exit 2 + fi + wizard_out '' + line="$(trim_space "$line")" + if valid_dealer_id "$line"; then + wizard_out "Device ID (cihaz kimliği): $(bf_device_id "$line")" + wizard_out "Hostname (makine adı) : $(bf_hostname "$line")" + wizard_write "Kurulum bu kimlikle başlatılsın mı? [e/H]: " + if ! wizard_read answer; then + err 'no terminal input arrived (timed out); first boot aborted without changes' + exit 2 + fi + wizard_out '' + if [[ "$answer" =~ ^([eE]|[eE]vet|y|Y|yes|YES)$ ]]; then + DEALER_ID="$line" + log "dealer number accepted at the console as $(bf_device_id "$DEALER_ID") on $(bf_hostname "$DEALER_ID")" + return 0 + fi + wizard_out "Onay verilmedi; yeniden soruluyor. (deneme $attempt/$MAX_ATTEMPTS)" + else + wizard_out "Geçersiz giriş; 8 hane rakam gerekli. (deneme $attempt/$MAX_ATTEMPTS)" + fi + attempt=$((attempt + 1)) + done + err "dealer number was not accepted after $MAX_ATTEMPTS attempts; first boot aborted without changes" + exit 2 +} + +# Identity resolution order: explicit flag, explicit file, forced wizard, explicit +# non-interactive mode, seeded dealer id file, wizard, hard failure. +resolve_dealer_id() { + local value + if [[ -n "$DEALER_ID" ]]; then + log 'dealer number supplied on the command line (non-interactive)' + return 0 + fi + if [[ "$DEALER_FILE_GIVEN" == 1 ]]; then + DEALER_ID="$(read_dealer_id_file "$DEALER_FILE")" + log "dealer number read from $DEALER_FILE (non-interactive)" + return 0 + fi + if [[ "$FORCED_INTERACTIVE" == 1 ]]; then + require_tty + wizard_collect + return 0 + fi + if [[ "$NONINTERACTIVE" == 1 ]]; then + DEALER_ID="$(read_dealer_id_file "$DEALER_FILE")" + log "non-interactive mode: dealer number read from $DEALER_FILE" + return 0 + fi + if [[ -f "$DEALER_FILE" && -r "$DEALER_FILE" ]]; then + value="$(tr -d '[:space:]' < "$DEALER_FILE" || true)" + if valid_dealer_id "$value"; then + DEALER_ID="$value" + log "seeded dealer id file used: $DEALER_FILE (wizard skipped)" + return 0 + fi + warn "seeded dealer id file exists but is not acceptable: $DEALER_FILE" + fi + if open_tty; then + wizard_collect + return 0 + fi + err "no terminal is available for the wizard and no dealer number was supplied; pass --dealer-id, --dealer-id-file, or seed $DEALER_FILE" + exit 2 +} + +require_dealer_id() { + [[ -n "$DEALER_ID" ]] || { err 'a dealer number is required (8 digits)'; exit 2; } + valid_dealer_id "$DEALER_ID" || { err 'invalid dealer number: exactly 8 digits are required'; exit 2; } +} + +# The administrator public key is a mandatory offline prerequisite. Only a public key in +# a root-owned, not group/world writable file is accepted; private or inline key material +# is refused and never echoed. +validate_admin_key() { + KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" + if [[ -z "$KEY_FILE" && -r "$DEFAULT_ADMIN_KEY_FILE" ]]; then KEY_FILE="$DEFAULT_ADMIN_KEY_FILE"; fi + [[ -n "$KEY_FILE" && -f "$KEY_FILE" && -r "$KEY_FILE" ]] || { echo 'OFFLINE BLOCKED: seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required.' >&2; exit 1; } + [[ "$(stat -c '%U:%a' "$KEY_FILE")" =~ ^root:6[04]0$ ]] || { echo 'OFFLINE BLOCKED: administrator public-key file must be root-owned and not group/world writable.' >&2; exit 1; } + grep -qE '^(ssh-(ed25519|rsa|ecdsa)|sk-ssh-ed25519)' "$KEY_FILE" && ! grep -q 'PRIVATE KEY' "$KEY_FILE" || { echo 'OFFLINE BLOCKED: BF_ADMIN_PUBLIC_KEY_FILE must contain only a public SSH key.' >&2; exit 1; } + export BF_ADMIN_PUBLIC_KEY_FILE="$KEY_FILE" + log "administrator public key file accepted: $KEY_FILE" +} + +resolve_installer() { + INSTALLER="${BF_INSTALLER_PATH:-$DEFAULT_INSTALLER}" + if [[ ! -x "$INSTALLER" ]]; then + INSTALLER="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../scripts/install" 2>/dev/null && pwd || true)/blueforce-install.sh" + fi + [[ -x "$INSTALLER" ]] || { err "offline installer is unavailable: $INSTALLER"; exit 1; } + log "offline installer: $INSTALLER" +} + +# The installer is always called offline with the validated dealer number and --yes, +# because this script already obtained the operator decision (wizard) or runs in an +# explicitly requested automation mode. A previous partial run is resumed. +run_installer() { + local -a args=(--offline --dealer-id "$DEALER_ID" --yes) + if [[ -s "$STATE_DIR/install-state" ]]; then + args+=(--resume) + log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting" + fi + if [[ "${BF_FIRSTBOOT_TEST_MODE:-0}" == 1 ]]; then + echo "TEST: would run $INSTALLER ${args[*]}" + exit 0 + fi + "$INSTALLER" "${args[@]}" +} + +audit_log() { + local message="$1" + install -d -m 700 "$(dirname "$AUDIT_LOG")" 2>/dev/null || true + printf '%s uid=%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(id -u)" "$message" >> "$AUDIT_LOG" || { + err "identity change refused: audit log is not writable ($AUDIT_LOG)" + return 1 + } + chmod 600 "$AUDIT_LOG" 2>/dev/null || true + log "AUDIT: $message" +} + +# Admin/recovery path for changing the device identity. Disabled by default: it needs +# root, the BF_ALLOW_REIDENTITY=1 operator gate, a warning, a typed confirmation of the +# new dealer number, and a written audit record. The normal path and the wizard never +# reach this function. +reidentity() { + local existing new_id typed stamp + existing="$(marker_device_id)" + [[ "$(id -u)" -eq 0 ]] || { err '--force-reidentity is an administrative procedure and requires root'; exit 1; } + [[ "${BF_ALLOW_REIDENTITY:-0}" == 1 ]] || { + err 'identity change is disabled by default; an operator must set BF_ALLOW_REIDENTITY=1 and run --force-reidentity from the console as root' + exit 1 + } + resolve_dealer_id + require_dealer_id + new_id="$DEALER_ID" + warn 'IDENTITY CHANGE: this machine will be re-identified and re-provisioned.' + warn "old identity: ${existing:-} new identity: $(bf_device_id "$new_id") / $(bf_hostname "$new_id")" + warn 'All nine central systems that carry the identity must be updated after this step.' + require_tty + wizard_out '' + wizard_out "DİKKAT: kimlik değişimi. Eski: ${existing:-yok} Yeni: $(bf_device_id "$new_id")" + wizard_write "Onaylamak için yeni bayi numarasını yeniden yazın: " + if ! wizard_read typed; then + err 'no terminal input arrived (timed out); identity change aborted without changes' + exit 2 + fi + wizard_out '' + typed="$(trim_space "$typed")" + [[ "$typed" == "$new_id" ]] || { err 'confirmation did not match; identity change aborted without changes'; exit 2; } + + audit_log "identity-change requested old=${existing:-none} new=$(bf_device_id "$new_id")" || exit 1 + validate_admin_key + resolve_installer + run_installer + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + if [[ -f "$MARKER" ]]; then + cp -p "$MARKER" "$MARKER.reidentified-$stamp" || true + chmod 600 "$MARKER.reidentified-$stamp" 2>/dev/null || true + fi + write_marker + audit_log "identity-change applied old=${existing:-none} new=$(bf_device_id "$new_id") marker-archive=$MARKER.reidentified-$stamp" + log "identity change complete; update the remaining central systems before the device is used" +} + +main() { + local existing + parse_args "$@" + + # Check mode is read-only: it never prompts, never writes state, and never installs. + if [[ "$CHECK_ONLY" == 1 ]]; then + if marker_exists; then + existing="$(marker_device_id)" + log "check: first boot already completed${existing:+ for $existing}; nothing would change" + exit 0 + fi + if [[ -z "$DEALER_ID" ]]; then + if [[ -f "$DEALER_FILE" && -r "$DEALER_FILE" ]]; then + DEALER_ID="$(read_dealer_id_file "$DEALER_FILE")" + else + err 'check mode needs a dealer number: pass --dealer-id or --dealer-id-file (no prompt is used in check mode)' + exit 2 + fi + fi + require_dealer_id + log "check: dealer number $(bf_device_id "$DEALER_ID") is acceptable on $(bf_hostname "$DEALER_ID"); no changes were made" + exit 0 + fi + + # Idempotency: a stamped device is finished. Nothing is prompted, written, or installed. + if marker_exists && [[ "$FORCE_REIDENTITY" != 1 ]]; then + existing="$(marker_device_id)" + log "first boot already completed${existing:+ for $existing}; nothing to do" + if [[ -n "$DEALER_ID" && -n "$existing" && "$DEALER_ID" != "$existing" ]]; then + warn "requested dealer number differs from the provisioned identity ($existing); identity changes use the admin procedure described in provisioning/firstboot/README.md" + fi + exit 0 + fi + + if [[ "$FORCE_REIDENTITY" == 1 ]]; then + reidentity + exit 0 + fi + + resolve_dealer_id + require_dealer_id + [[ "$(id -u)" -eq 0 ]] || { err 'first boot provisioning must run as root'; exit 1; } + validate_admin_key + resolve_installer + run_installer + write_marker + log "first boot finished for $(bf_device_id "$DEALER_ID"); enrollment and READY are separate steps" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi diff --git a/provisioning/firstboot/blueforce-firstboot.service b/provisioning/firstboot/blueforce-firstboot.service new file mode 100644 index 0000000..d30c051 --- /dev/null +++ b/provisioning/firstboot/blueforce-firstboot.service @@ -0,0 +1,36 @@ +[Unit] +Description=Blueforce Field OS first-boot offline provisioning (terminal wizard) +Documentation=file:/usr/share/doc/blueforce/firstboot/README.md +After=multi-user.target +# Prerequisites that must exist before the wizard may run: the seed-provided administrator +# public key is mandatory for the offline installer. The dealer number is NOT a condition, +# because a technician at the field console enters it through the wizard. +ConditionPathExists=/etc/blueforce/admin-authorized-key.pub +# Run once per machine: once the completion marker exists the unit is skipped (not failed). +ConditionPathExists=!/var/lib/blueforce/firstboot-complete + +[Service] +Type=oneshot +Environment=BF_ADMIN_PUBLIC_KEY_FILE=/etc/blueforce/admin-authorized-key.pub +ExecStart=/usr/local/sbin/bf-firstboot +# The wizard reads and writes the field console. If no TTY can be set up (headless unit, +# no console attached) systemd fails this unit and boot continues; the script itself also +# falls back to the --dealer-id/--dealer-id-file path when no terminal is available. +StandardInput=tty-force +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +# Upper bound so a console that nobody answers can never hold the boot job open. The +# wizard's own input read is bounded too (BF_FIRSTBOOT_INPUT_TIMEOUT). +TimeoutStartSec=45min +RemainAfterExit=yes +# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes +# and similar sandboxing are deliberately absent: the installer legitimately writes /etc, +# /usr and /home/blueforce, and those options would break apt/dpkg and the user module. +NoNewPrivileges=yes +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target diff --git a/provisioning/iso/README.md b/provisioning/iso/README.md new file mode 100644 index 0000000..2eb0734 --- /dev/null +++ b/provisioning/iso/README.md @@ -0,0 +1,159 @@ +# ISO builder + +`build-blueforce-iso.sh` produces the blueforce field-installation medium from a local, +SHA256-verified Ubuntu ISO. Nothing is downloaded: the base ISO and its checksum record +must already exist on the build host. + +```bash +# validate every precondition, write nothing +./build-blueforce-iso.sh \ + --upstream-iso /media/ubuntu.iso \ + --checksum-file ./ubuntu-26.04.1-SHA256SUMS \ + --check + +# real build (writes dist/Blueforce-Field-OS--amd64.iso and its manifest) +./build-blueforce-iso.sh \ + --upstream-iso /media/ubuntu.iso \ + --checksum-file ./ubuntu-26.04.1-SHA256SUMS +``` + +## Output contract + +| Artifact | Path | +|---|---| +| Bootable medium | `dist/Blueforce-Field-OS--amd64.iso` | +| ISO checksum | `dist/Blueforce-Field-OS--amd64.iso.sha256` | +| Release manifest | `dist/-manifest.yaml` | + +The manifest carries `field_os_version`, `base_os`, `base_flavor`, `base_iso_filename`, +`ubuntu_version`, `ubuntu_iso_sha256`, `git_commit`, `build_date`, `architecture`, +`iso_sha256` and the `packages` block (`rustdesk`, `docker`, `xrdp`, `wireguard`, +`meg_package_version`, `blueforce_installer_version`). `ubuntu_iso_sha256` is always +recomputed from the real base ISO file; no digest is hard-coded anywhere in the tooling. +Package versions are read from the embedded offline APT lock file; when no built +offline repository is embedded they are reported as `unknown` on purpose instead of +being invented. + +## Media layout + +```text +/autoinstall.yaml assisted autoinstall, read from the medium itself +/boot/grub/grub.cfg boot config: every kernel line requests autoinstall +/boot/grub/grub.cfg.blueforce-orig pristine copy of the original boot config +/blueforce-provisioning/autoinstall/ same configuration in NoCloud/seed form +/blueforce-provisioning/firstboot/ bf-firstboot + blueforce-firstboot.service +/blueforce-provisioning/offline-repo/ offline APT tooling, pin manifests, optional built repo +/blueforce-provisioning/release/ blueforce-release, media-info.yaml, release manifest +``` + +`interactive-sections: [storage]` is preserved, so the medium installs unattended but the +operator still selects and confirms the target disk on the installer screen. There is no +automatic wipe. Device identity (`BF-`), dealer id, private keys and passwords are +never embedded in the medium; they are collected at firstboot, and the builder refuses to +run when its input scan finds them. + +## Boot layout: why the boot structure is rebuilt, not replayed + +Ubuntu live ISOs keep part of their boot information outside the ISO 9660 tree, so +`xorriso -boot_image any replay` cannot re-establish it: + +1. the **GRUB2 MBR boot code** lives in the first 16 sectors of the source image, and +2. the **EFI system partition is an appended partition**, i.e. a byte range after the ISO + filesystem rather than a file in it. + +Replaying that layout fails with `Cannot enable EL Torito boot image #1 because it is not +a data file in the ISO filesystem` and with the GRUB2 MBR error that it cannot refer to +data outside the ISO 9660 filesystem. + +The builder therefore asks the base ISO for its own recipe — `xorriso -indev +-report_el_torito as_mkisofs` — and writes the new medium with `xorriso -as mkisofs` plus +that recipe (`--grub2-mbr`, `-partition_offset`, `-append_partition 2 `, +`-appended_part_as_gpt`, the BIOS `-b /boot/grub/i386-pc/eltorito.img` entry with +`--grub2-boot-info`, and the alternate UEFI entry). Interval references in the recipe are +rewritten to point at the base ISO, never at the output, and the builder fails closed when +the recipe has no `-append_partition` line. Nothing about the layout is hard-coded, so a +Server ISO or a later Ubuntu revision works without editing the script. + +The extracted tree is only ever *added to*: `autoinstall.yaml`, `/blueforce-provisioning/` +and the patched boot configuration (with its `.blueforce-orig` backup). Existing boot +files (`boot.catalog`, `eltorito.img`, `casper/*`) are left byte-identical. + +## Flavour-agnostic base ISO (and the recorded baseline deviation) + +`--upstream-iso` accepts an Ubuntu Server **or** Ubuntu Desktop ISO. The flavour is read +from the ISO volume label (`Ubuntu-Server 26.04.1 LTS amd64` → `server`, +`Ubuntu 26.04.1 LTS amd64` → `desktop`), recorded as `base_flavor`, and never guessed +from the file name. + +The architecture documents fix the baseline as *Ubuntu Server 26.04.1+ LTS* +(`docs/24` K-01, `docs/26`). The verified installation medium available on this build +host is the **Desktop** flavour ISO, so builds made from it deviate from that baseline. +The deviation is not hidden: it is written into `manifest.yaml` as `base_flavor: desktop` +plus a `baseline_deviation` block (reason, impact, decision needed, `lab_required: true`), +and mirrored into `media-info.yaml` and `/etc/blueforce-release` on the medium. + +* The Desktop medium already ships GNOME, so the desktop/GNOME layer needs no extra + installation step on the target (`docs/24` K-02, `docs/07` xrdp + GNOME). +* A Server-based build remains a single flag away: + `--upstream-iso /path/ubuntu-26.04.1-live-server-amd64.iso` produces a medium whose + manifest reports `base_flavor: server` and `baseline_deviation: none`. +* The autoinstall path on the Desktop flavour still requires LAB acceptance before any + production use; see `tests/qemu-field-os-test-plan.md`. + +## Offline APT repository + +* `--offline-repo DIR` embeds a built repository (`Packages`, `Packages.gz`, `SHA256SUMS`, + `packages.lock.tsv` are mandatory; an incomplete directory is rejected with a clear + error). Without the flag, `provisioning/offline-repo/build` is auto-detected. +* The repository is placed at `/blueforce-provisioning/offline-repo/built/` on the medium + and late-commands merge it into `/opt/blueforce/offline-repo` on the installed system, + which is where `blueforce-install.sh --offline` expects it. +* When no built repository is available the medium still ships the pin manifests and + tooling, `media-info.yaml` records `offline_repo_status: not-included`, and the target + keeps failing closed with `OFFLINE BLOCKED` until a repository is built and re-embedded. + `--require-offline-repo` turns that condition into a build failure for release builds. + +## Scratch space and determinism + +* The build extracts the base ISO and writes a new one, so it needs about **2x the base + ISO size** free. `--work-dir` overrides the scratch location; otherwise the output + filesystem, `$TMPDIR` and `/var/tmp` are tried in order and the build stops with an + explicit error when none has room. It never downloads anything to work around space. +* No build timestamp is written into the medium (the date lives in the dist manifest + only) and generated files get a fixed modification time. `--reproducible` pins the + volume-level date through the mkisofs-compatible `--modification-date=` option; + the base ISO's own `--modification-date` recipe line is deliberately dropped so the + stamp stays under this script's control. +* The ISO volume label defaults to the base ISO's label, which keeps live-media discovery + untouched; `--volid` overrides it (xorriso warns when a label does not follow ISO 9660 + rules, for example spaces) and the value is recorded in the manifest. + +## Verification steps performed by the builder + +1. xorriso is present and can read the base ISO's El Torito boot metadata. +2. Base ISO SHA256 is recomputed and matched against `--checksum-file`. +3. Media inputs are scanned for device identity, private keys and credentials. +4. The pristine GRUB configuration is kept as `grub.cfg.blueforce-orig` before injection; + injection is idempotent and `autoinstall` is placed ahead of the `---` separator. +5. After writing, the produced ISO is re-opened with xorriso: BIOS **and** UEFI El Torito + entries must still exist, `/autoinstall.yaml` must contain the interactive-storage and + `offline-install` markers, the GRUB configuration must request autoinstall, and the + firstboot/release/offline-repo payloads must be present. Any failure removes the broken + output instead of leaving a half-good artifact behind. + +## Still LAB work + +Booting the medium with UEFI, Legacy BIOS and Secure Boot, verifying that the autoinstall +configuration is picked up on the chosen flavour, and a full air-gapped install are LAB +tasks; no VM is started by the tooling or by this repository's tests. Use +`tests/qemu-field-os-test-plan.md`. + +## Files + +| File | Role | +|---|---| +| `build-blueforce-iso.sh` | the builder (real build, `--check` for dry runs) | +| `verify-upstream-iso.sh` | SHA256 verification of a local ISO against a checksum record | +| `test-iso.sh` | non-destructive checks of an existing ISO artifact | +| `field-os-version` | Field OS version used in artifact names (`--version` overrides) | +| `ubuntu-26.04.1-SHA256SUMS` | checksum record fetched verbatim from `https://releases.ubuntu.com/26.04.1/SHA256SUMS`; the Desktop ISO used for LAB builds matches its `ubuntu-26.04.1-desktop-amd64.iso` line | diff --git a/provisioning/iso/build-blueforce-iso.sh b/provisioning/iso/build-blueforce-iso.sh new file mode 100755 index 0000000..2e9b491 --- /dev/null +++ b/provisioning/iso/build-blueforce-iso.sh @@ -0,0 +1,871 @@ +#!/usr/bin/env bash +# Build the bootable Blueforce Field OS installation ISO from a local, verified upstream ISO. +# +# Output contract (written next to --output): +# dist/Blueforce-Field-OS--amd64.iso +# .sha256 SHA256 of the produced ISO +# -manifest.yaml release manifest (field_os_version, base_os, ubuntu_version, ...) +# +# The build is flavour-agnostic: --upstream-iso may point at an Ubuntu Server or an +# Ubuntu Desktop ISO. The flavour is detected from the ISO volume label, recorded in +# the manifest as base_flavor, and never inferred from the file name alone. +# +# Safeguards that must not be weakened: +# * Nothing is downloaded. The upstream ISO and its SHA256 record must already exist +# locally, and the digest is always recomputed from the real file. +# * Device identity, dealer id, private keys and passwords are never embedded. The +# builder refuses to run when it finds them in the media inputs. +# * The media root carries autoinstall.yaml with `interactive-sections: [storage]`, +# so the operator still selects and confirms the target disk. No automatic wipe. +# * Existing boot files are never destroyed: every patched boot configuration keeps a +# pristine copy on the media as .blueforce-orig. +# +# --check validates every precondition and writes nothing. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +VERIFY_SCRIPT="$SCRIPT_DIR/verify-upstream-iso.sh" +VERSION_FILE="$SCRIPT_DIR/field-os-version" +PROVISIONING_DIR="$REPO_ROOT/provisioning" +AUTOINSTALL_DIR="$PROVISIONING_DIR/autoinstall" +FIRSTBOOT_DIR="$PROVISIONING_DIR/firstboot" +RELEASE_DIR="$PROVISIONING_DIR/release" +OFFLINE_REPO_DIR="$PROVISIONING_DIR/offline-repo" +OFFLINE_AUTODETECT="$OFFLINE_REPO_DIR/build" +DEFAULT_DIST="$REPO_ROOT/dist" +MEDIA_PREFIX="/blueforce-provisioning" +FIRSTBOOT_PATH="$MEDIA_PREFIX/firstboot" +ARCH="amd64" +REPRO_STAMP="2024010100000000" + +# Identity and credential patterns that must never reach the media. Written with +# bracket expressions so this scanner cannot match its own source text. +IDENTITY_PATTERN='(BEGIN[[:space:]]+(OPENSSH|RSA|EC|DSA|PGP)[[:space:]]+PRIVATE[[:space:]]+KEY|WG_PRIVATE[[:space:]]+KEY|dealer[-_ ]?id[[:space:]]*[:=][[:space:]]*[0-9]{8}|BF-[0-9]{8}|bf-[0-9]{8})' +CREDENTIAL_PATTERN='(password|passwd|credential|api[_-]?key|access[_-]?token)[[:space:]]*[:=][[:space:]]*[^[:space:]<#]' + +usage() { + cat <<'USAGE' +Usage: build-blueforce-iso.sh --upstream-iso PATH --checksum-file PATH [options] + +Builds the bootable Blueforce Field OS installation medium from a local, SHA256 +verified Ubuntu ISO (Server or Desktop flavour). No image is downloaded. + +Required: + --upstream-iso PATH Local Ubuntu ISO used as the base medium + --checksum-file PATH SHA256SUMS record for that ISO (alias: --checksum) + +Options: + --output PATH ISO path (default: dist/Blueforce-Field-OS--amd64.iso) + --version VERSION Field OS version (default: provisioning/iso/field-os-version) + --offline-repo DIR Built offline APT repository to embed (Packages, Packages.gz, + SHA256SUMS, packages.lock.tsv). Default: auto-detect + provisioning/offline-repo/build, else build without it. + --require-offline-repo Fail closed when no built offline repository is available + --volid LABEL ISO volume label (default: the upstream label, preserved so the + live-media discovery path stays untouched; recorded in the manifest) + --work-dir DIR Scratch directory (default: the output filesystem when it has + room, else $TMPDIR, else /var/tmp; needs about 2x the base ISO) + --keep-work Do not delete the scratch directory (debugging) + --reproducible Ask xorriso for fixed volume and file dates (byte-stable rebuilds) + --allow-remaster Deprecated no-op: this command always performs a real build + --check Validate every precondition, write nothing, exit 0/1 + --help Show this help + +Media layout produced inside the ISO: + /autoinstall.yaml assisted autoinstall (root of the medium) + /blueforce-provisioning/firstboot/ first boot payload (fixed contract path) + /blueforce-provisioning/autoinstall/ NoCloud seed copy of the same configuration + /blueforce-provisioning/offline-repo/ offline APT tooling, manifests and built repo + /blueforce-provisioning/release/ release and media metadata +USAGE +} + +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +ok() { printf 'OK: %s\n' "$*"; } +note() { printf '%s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } +warn() { printf 'WARNING: %s\n' "$*" >&2; } + +UPSTREAM="" +CHECKSUM_RECORD="" +OUTPUT="" +OUTPUT_DIR="" +ISO_NAME="" +SHA_FILE="" +MANIFEST="" +VERSION="" +OFFLINE_REPO="" +OFFLINE_MODE="none" +REQUIRE_OFFLINE=0 +VOLID="" +VOLID_SOURCE="upstream-preserved" +CHECK_ONLY=0 +REPRODUCIBLE=0 +KEEP_WORK=0 +OPT_WORK_DIR="" +WORK_DIR="" +WORK_ROOT="" +MEDIA="" +UP_IS_SIZE_KB=0 +BASE_FLAVOR="unknown" +UBUNTU_VERSION="unknown" +UP_VOLID="" +UPSTREAM_SHA256="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --upstream-iso) UPSTREAM="${2:-}"; shift 2 ;; + --checksum|--checksum-file) CHECKSUM_RECORD="${2:-}"; shift 2 ;; + --output) OUTPUT="${2:-}"; shift 2 ;; + --version) VERSION="${2:-}"; shift 2 ;; + --offline-repo) OFFLINE_REPO="${2:-}"; shift 2 ;; + --require-offline-repo) REQUIRE_OFFLINE=1; shift ;; + --volid) VOLID="${2:-}"; VOLID_SOURCE="explicit"; shift 2 ;; + --work-dir) OPT_WORK_DIR="${2:-}"; shift 2 ;; + --keep-work) KEEP_WORK=1; shift ;; + --reproducible) REPRODUCIBLE=1; shift ;; + --allow-remaster) warn "--allow-remaster is deprecated and ignored: this command always builds a real ISO."; shift ;; + --check) CHECK_ONLY=1; shift ;; + --help|-h) usage; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ -n "$UPSTREAM" ]] || { printf 'Missing required argument: --upstream-iso\n' >&2; usage >&2; exit 2; } +[[ -n "$CHECKSUM_RECORD" ]] || { printf 'Missing required argument: --checksum-file\n' >&2; usage >&2; exit 2; } + +# ----------------------------------------------------------------- small tools -- + +free_kb() { + df -Pk -- "$1" 2>/dev/null | awk 'NR==2 {print $4}' +} + +existing_ancestor() { + local path="$1" + while [[ ! -d "$path" && "$path" != "/" ]]; do + path="$(dirname "$path")" + done + printf '%s' "$path" +} + +detect_flavor_from_label() { + local label="$1" + case "$label" in + *Server*) printf 'server' ;; + *Desktop*) printf 'desktop' ;; + *[Uu]buntu*) printf 'desktop' ;; + *) printf 'unknown' ;; + esac +} + +version_from_text() { + printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1 || true +} + +base_os_string() { + if [[ "$UBUNTU_VERSION" == "unknown" ]]; then + printf 'Ubuntu %s' "$BASE_FLAVOR" + else + # Title-case the flavour for display only; the machine-readable flavour stays + # in base_flavor, so nothing depends on this cosmetic spelling. + printf 'Ubuntu %s %s LTS' "$(printf '%s' "${BASE_FLAVOR:0:1}" | tr '[:lower:]' '[:upper:]')${BASE_FLAVOR:1}" "$UBUNTU_VERSION" + fi +} + +# ------------------------------------------------------------------ parameters -- + +resolve_parameters() { + if [[ -z "$VERSION" ]]; then + [[ -r "$VERSION_FILE" ]] || die "Field OS version file is missing: $VERSION_FILE (pass --version)" + VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")" + fi + [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}([-.+][0-9A-Za-z.-]+)?$ ]] \ + || die "Invalid Field OS version '${VERSION}': expected e.g. 1.0.0 or 1.1.0-rc1" + + ISO_NAME="Blueforce-Field-OS-${VERSION}-${ARCH}.iso" + if [[ -z "$OUTPUT" ]]; then + OUTPUT="$DEFAULT_DIST/$ISO_NAME" + elif [[ "$OUTPUT" != /* ]]; then + OUTPUT="$PWD/$OUTPUT" + fi + [[ "$(basename "$OUTPUT")" == *.iso ]] || die "--output must name an .iso file: $OUTPUT" + OUTPUT_DIR="$(dirname "$OUTPUT")" + SHA_FILE="${OUTPUT}.sha256" + MANIFEST="$OUTPUT_DIR/${VERSION}-manifest.yaml" + + case "$(basename "$UPSTREAM")" in + *"${ARCH}"*) : ;; + *i386*|*arm64*|*ppc64el*|*s390x*) die "Only ${ARCH} media is supported; got: $(basename "$UPSTREAM")" ;; + *) warn "Upstream ISO name does not mention ${ARCH}: $(basename "$UPSTREAM")" ;; + esac +} + +require_media_inputs() { + local missing=() file + local -a required=( + "$AUTOINSTALL_DIR/autoinstall.yaml" + "$AUTOINSTALL_DIR/user-data" + "$AUTOINSTALL_DIR/meta-data" + "$FIRSTBOOT_DIR/bf-firstboot" + "$FIRSTBOOT_DIR/blueforce-firstboot.service" + "$RELEASE_DIR/manifest.yaml" + ) + for file in "${required[@]}"; do + [[ -f "$file" ]] || missing+=("${file#"$REPO_ROOT"/}") + done + [[ -d "$OFFLINE_REPO_DIR" ]] || missing+=("${OFFLINE_REPO_DIR#"$REPO_ROOT"/}/") + [[ ${#missing[@]} -eq 0 ]] || die "Required provisioning inputs are missing: ${missing[*]}" + ok "provisioning inputs present (autoinstall, firstboot, offline-repo tooling, release manifest)" +} + +resolve_offline_repo() { + local missing=() marker + if [[ -n "$OFFLINE_REPO" && "$OFFLINE_REPO" != "none" ]]; then + [[ -d "$OFFLINE_REPO" ]] || die "Offline repository directory not found: $OFFLINE_REPO" + elif [[ -z "$OFFLINE_REPO" && -d "$OFFLINE_AUTODETECT" ]]; then + OFFLINE_REPO="$OFFLINE_AUTODETECT" + else + OFFLINE_REPO="" + fi + + if [[ -z "$OFFLINE_REPO" ]]; then + if [[ "$REQUIRE_OFFLINE" -eq 1 ]]; then + die "No built offline APT repository found (looked at ${OFFLINE_AUTODETECT#"$REPO_ROOT"/} and --offline-repo). Build one with provisioning/offline-repo/build-offline-repo.sh, then retry." + fi + OFFLINE_MODE="not-included" + warn "No built offline APT repository is embedded: the medium ships the offline tooling and manifests only." + warn "Package installation on the installed system stays OFFLINE BLOCKED until a repository is built and re-embedded." + return + fi + + for marker in Packages Packages.gz SHA256SUMS packages.lock.tsv; do + [[ -f "$OFFLINE_REPO/$marker" ]] || missing+=("$marker") + done + [[ ${#missing[@]} -eq 0 ]] || die "Offline repository $OFFLINE_REPO is incomplete; missing: ${missing[*]}" + OFFLINE_MODE="embedded" + ok "offline APT repository will be embedded: $OFFLINE_REPO" +} + +scan_media_inputs() { + local -a scan_dirs=("$AUTOINSTALL_DIR" "$FIRSTBOOT_DIR" "$RELEASE_DIR" "$OFFLINE_REPO_DIR") + local hits + hits="$(grep -RIlE --exclude='*.md' -- "$IDENTITY_PATTERN" "${scan_dirs[@]}" 2>/dev/null || true)" + [[ -z "$hits" ]] || die "Device identity found in media inputs; refusing to build:"$'\n'"$hits" + hits="$(grep -RIlE --exclude='*.md' -- "$CREDENTIAL_PATTERN" "${scan_dirs[@]}" 2>/dev/null || true)" + [[ -z "$hits" ]] || die "Credential-like value found in media inputs; refusing to build:"$'\n'"$hits" + ok "media inputs carry no device identity, private key or credential" +} + +resolve_work_root() { + local need_kb=$(( UP_IS_SIZE_KB * 2 + 1048576 )) candidate available detail="" + local -a candidates=() + if [[ -n "$OPT_WORK_DIR" ]]; then + candidates=("$OPT_WORK_DIR") + elif [[ "$CHECK_ONLY" -eq 1 ]]; then + # Dry runs create no directories, so judge the filesystem the output will land on. + candidates=("$(existing_ancestor "$OUTPUT_DIR")" "${TMPDIR:-/tmp}" "/var/tmp") + else + # Real builds reuse the output directory, which keeps the scratch tree on the same + # filesystem as the artifact and inside a directory that already has to exist. + install -d "$OUTPUT_DIR" || die "Could not create the output directory: $OUTPUT_DIR" + candidates=("$OUTPUT_DIR" "${TMPDIR:-/tmp}" "/var/tmp") + fi + for candidate in "${candidates[@]}"; do + if [[ ! -d "$candidate" ]]; then + detail+=" ${candidate}: not a directory"$'\n' + continue + fi + available="$(free_kb "$candidate")" + if [[ -n "$available" && "$available" -ge "$need_kb" ]]; then + WORK_ROOT="$candidate" + return 0 + fi + detail+=" ${candidate}: $(( ${available:-0} / 1024 )) MiB free"$'\n' + done + die "Not enough scratch space. This build extracts the base ISO and writes a new one, so it needs about $(( need_kb / 1024 )) MiB free. Checked: +${detail}Pass --work-dir with a directory that has room. Nothing is downloaded to satisfy this." +} + +# ------------------------------------------------------------------- preflight -- + +preflight() { + step "Preconditions" + + [[ -f "$UPSTREAM" ]] || die "Upstream ISO not found: $UPSTREAM" + [[ -s "$UPSTREAM" ]] || die "Upstream ISO is empty: $UPSTREAM" + UP_IS_SIZE_KB=$(( $(stat -c %s -- "$UPSTREAM") / 1024 )) + ok "upstream ISO present: $(basename "$UPSTREAM") ($(( UP_IS_SIZE_KB / 1024 )) MiB)" + [[ -f "$CHECKSUM_RECORD" ]] || die "Checksum record not found: $CHECKSUM_RECORD" + + command -v xorriso >/dev/null 2>&1 \ + || die "xorriso is required to build bootable media and was not found in PATH. Install it (for example: apt-get install xorriso) and retry. Nothing was written." + ok "xorriso available: $(xorriso --version 2>/dev/null | awk 'NR==1 {print $2}')" + + local boot_report + boot_report="$(xorriso -indev "$UPSTREAM" -report_el_torito plain 2>&1)" \ + || die "xorriso cannot read El Torito boot metadata from $(basename "$UPSTREAM"); refusing to build. Nothing was written." + # xorriso exits 0 even when the input is not an ISO at all: it prints + # "No ISO 9660 image at LBA 0" and reports nothing. Combined with a checksum + # record that happens to match, that would let a non-ISO file through the + # preflight, so assert positively that boot metadata was actually produced. + grep -qE 'El Torito boot img' <<<"$boot_report" \ + || die "$(basename "$UPSTREAM") is not usable as an installation ISO: no El Torito boot image was reported. Nothing was written." + grep -qE 'El Torito boot img.*BIOS' <<<"$boot_report" \ + || warn "upstream ISO reports no BIOS El Torito entry; the produced medium may not boot on Legacy BIOS" + grep -qE 'El Torito boot img.*(UEFI|EFI)' <<<"$boot_report" \ + || warn "upstream ISO reports no UEFI El Torito entry; the produced medium may not boot on UEFI" + ok "El Torito boot metadata readable from the upstream ISO" + + UP_VOLID="$(sed -n 's/^Volume id[^:]*:[[:space:]]*//p' <<<"$boot_report" | head -n 1 | tr -d "\"'" || true)" + UP_VOLID="${UP_VOLID:-unknown}" + BASE_FLAVOR="$(detect_flavor_from_label "$UP_VOLID")" + UBUNTU_VERSION="$(version_from_text "$UP_VOLID")" + [[ -n "$UBUNTU_VERSION" ]] || UBUNTU_VERSION="unknown" + ok "base label '${UP_VOLID}' => base_flavor=${BASE_FLAVOR}, ubuntu_version=${UBUNTU_VERSION}" + if [[ "$BASE_FLAVOR" != "server" ]]; then + warn "Base medium is not the documented Server baseline (docs/24 K-01, docs/26)." + warn "The deviation is recorded in the manifest as base_flavor=${BASE_FLAVOR}; a Server ISO can be used with --upstream-iso." + fi + + [[ -n "$VOLID" ]] || VOLID="$UP_VOLID" + [[ "$VOLID" != "unknown" ]] || VOLID="BLUEFORCE_FIELD_OS" + (( ${#VOLID} <= 32 )) || die "Volume label too long for ISO 9660 (max 32 characters): $VOLID" + ok "output volume label: '${VOLID}' (${VOLID_SOURCE})" + + require_media_inputs + resolve_offline_repo + + local -a existing=() + [[ -e "$OUTPUT" ]] && existing+=("$OUTPUT") + [[ -e "$SHA_FILE" ]] && existing+=("$SHA_FILE") + [[ -e "$MANIFEST" ]] && existing+=("$MANIFEST") + [[ ${#existing[@]} -eq 0 ]] || die "Refusing to overwrite existing artifacts: ${existing[*]}" + ok "no existing artifact would be overwritten" + + scan_media_inputs + + local verify_output + if ! verify_output="$("$VERIFY_SCRIPT" --iso "$UPSTREAM" --checksum "$CHECKSUM_RECORD" --check 2>&1)"; then + printf '%s\n' "$verify_output" >&2 + die "Upstream ISO failed SHA256 verification against $CHECKSUM_RECORD" + fi + UPSTREAM_SHA256="$(sha256sum -- "$UPSTREAM" | awk '{print $1}')" + ok "upstream SHA256 recomputed from the real file: $UPSTREAM_SHA256" + + resolve_work_root + ok "scratch directory will be created under: ${WORK_ROOT} (about $(( (UP_IS_SIZE_KB * 2 + 1048576) / 1024 )) MiB required)" + + ok "output contract: $OUTPUT" + ok " ${SHA_FILE}" + ok " ${MANIFEST}" +} + +# -------------------------------------------------------------- boot injection -- + +# Insert the `autoinstall` kernel argument into a boot configuration file without +# damaging the original: the pristine copy is kept on the medium as .blueforce-orig. +# kind=grub -> `linux`/`linuxefi` lines get the argument right after the kernel path, +# ahead of the `---` separator. +# kind=isolinux -> `append` lines get the argument right after the keyword. +# Re-running is idempotent: lines that already request autoinstall are left untouched. +inject_autoinstall_arg() { + local file="$1" kind="$2" out="${1}.blueforce-inject.tmp" line changed=0 + : > "$out" + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$kind" == "grub" ]] \ + && [[ "$line" =~ ^([[:space:]]*(linux|linuxefi)[[:space:]]+)([^[:space:]]+)(.*)$ ]] \ + && [[ "$line" != *autoinstall* ]]; then + printf '%s%s autoinstall%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" >> "$out" + changed=$((changed + 1)) + continue + fi + if [[ "$kind" == "isolinux" ]] \ + && [[ "$line" =~ ^([[:space:]]*(append|APPEND)[[:space:]]+)(.*)$ ]] \ + && [[ "$line" != *autoinstall* ]]; then + printf '%sautoinstall %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" >> "$out" + changed=$((changed + 1)) + continue + fi + printf '%s\n' "$line" >> "$out" + done < "$file" + + if [[ "$changed" -gt 0 ]]; then + [[ -e "${file}.blueforce-orig" ]] || cp -p "$file" "${file}.blueforce-orig" + mv "$out" "$file" + else + rm -f "$out" + fi + printf '%d' "$changed" +} + +inject_boot_configuration() { + step "Boot configuration" + local -a grub_files=( + "$MEDIA/boot/grub/grub.cfg" + "$MEDIA/boot/grub/loopback.cfg" + "$MEDIA/EFI/boot/grub.cfg" + ) + local -a isolinux_files=( + "$MEDIA/isolinux/txt.cfg" + "$MEDIA/isolinux/isolinux.cfg" + "$MEDIA/isolinux/gtk.cfg" + ) + local file changed grub_total=0 isolinux_total=0 + for file in "${grub_files[@]}"; do + [[ -f "$file" ]] || continue + changed="$(inject_autoinstall_arg "$file" grub)" + grub_total=$((grub_total + changed)) + ok "GRUB ${file#"$MEDIA"/}: ${changed} kernel line(s) now request autoinstall" + done + for file in "${isolinux_files[@]}"; do + [[ -f "$file" ]] || continue + changed="$(inject_autoinstall_arg "$file" isolinux)" + isolinux_total=$((isolinux_total + changed)) + ok "isolinux ${file#"$MEDIA"/}: ${changed} append line(s) now request autoinstall" + done + + [[ $((grub_total + isolinux_total)) -gt 0 ]] \ + || die "No recognised boot configuration (GRUB or isolinux) was found in the upstream ISO; refusing to write a medium that cannot start the installer." + if [[ "$grub_total" -eq 0 ]]; then + warn "No GRUB configuration was patched: automatic autoinstall start is not prepared for this base ISO." + fi + if [[ "$isolinux_total" -eq 0 ]]; then + note "note: this base ISO carries no isolinux configuration (Legacy BIOS boots through the El Torito GRUB image, which reads the patched GRUB configuration)." + fi +} + +# --------------------------------------------------------------------- staging -- + +write_media_metadata() { + local release_file="$MEDIA$MEDIA_PREFIX/release/blueforce-release" + cat > "$release_file" < "$MEDIA$MEDIA_PREFIX/release/media-info.yaml" <"$WORK_DIR/extract.log" 2>&1; then + tail -n 15 "$WORK_DIR/extract.log" >&2 + die "ISO filesystem extraction failed (log: $WORK_DIR/extract.log)" + fi + chmod -R u+w "$MEDIA" + ok "upstream tree extracted to the scratch directory" + + install -d "$MEDIA$MEDIA_PREFIX/autoinstall" "$MEDIA$MEDIA_PREFIX/firstboot" \ + "$MEDIA$MEDIA_PREFIX/offline-repo" "$MEDIA$MEDIA_PREFIX/release" + + install -m 0644 "$AUTOINSTALL_DIR/autoinstall.yaml" "$MEDIA/autoinstall.yaml" + install -m 0644 "$AUTOINSTALL_DIR/autoinstall.yaml" "$MEDIA$MEDIA_PREFIX/autoinstall/autoinstall.yaml" + install -m 0644 "$AUTOINSTALL_DIR/user-data" "$MEDIA$MEDIA_PREFIX/autoinstall/user-data" + install -m 0644 "$AUTOINSTALL_DIR/meta-data" "$MEDIA$MEDIA_PREFIX/autoinstall/meta-data" + if [[ -f "$AUTOINSTALL_DIR/README.md" ]]; then + install -m 0644 "$AUTOINSTALL_DIR/README.md" "$MEDIA$MEDIA_PREFIX/autoinstall/README.md" + fi + ok "autoinstall configuration placed at /autoinstall.yaml and ${MEDIA_PREFIX}/autoinstall/" + + install -m 0755 "$FIRSTBOOT_DIR/bf-firstboot" "$MEDIA$FIRSTBOOT_PATH/bf-firstboot" + install -m 0644 "$FIRSTBOOT_DIR/blueforce-firstboot.service" "$MEDIA$FIRSTBOOT_PATH/blueforce-firstboot.service" + if [[ -f "$FIRSTBOOT_DIR/README.md" ]]; then + install -m 0644 "$FIRSTBOOT_DIR/README.md" "$MEDIA$FIRSTBOOT_PATH/README.md" + fi + ok "firstboot payload placed at ${FIRSTBOOT_PATH}/" + + cp -a "$OFFLINE_REPO_DIR/." "$MEDIA$MEDIA_PREFIX/offline-repo/" + rm -f "$MEDIA$MEDIA_PREFIX/offline-repo/packages/.gitkeep" + if [[ "$OFFLINE_MODE" == "embedded" ]]; then + install -d "$MEDIA$MEDIA_PREFIX/offline-repo/built" + cp -a "$OFFLINE_REPO/." "$MEDIA$MEDIA_PREFIX/offline-repo/built/" + ok "offline APT repository placed at ${MEDIA_PREFIX}/offline-repo/built/" + else + ok "offline APT tooling and manifests placed at ${MEDIA_PREFIX}/offline-repo/ (no built repository)" + fi + + install -m 0644 "$RELEASE_DIR/manifest.yaml" "$MEDIA$MEDIA_PREFIX/release/manifest.yaml" + write_media_metadata + chmod -R a+rX "$MEDIA$MEDIA_PREFIX" +} + +# --------------------------------------------------------------------- ISO out -- + +# Rebuild the boot structure from the base ISO's own layout. +# +# Why this is not `-boot_image any replay`: +# Ubuntu live ISOs carry boot information in two places that a replay cannot +# re-establish, because neither is a data file inside the ISO9660 tree: +# 1. the GRUB2 MBR boot code lives in the first 16 sectors of the *source +# image*, and +# 2. the EFI system partition is an APPENDED PARTITION, i.e. a byte range of +# the source image. +# Replaying them fails with: +# "Cannot enable EL Torito boot image #1 because it is not a data file in +# the ISO filesystem" +# "Cannot refer by GRUB2 MBR to data outside of ISO 9660 filesystem" +# +# `xorriso -report_el_torito as_mkisofs` emits the exact option set that +# reconstructs the layout, so it is read from the base ISO at build time rather +# than hard-coded. That keeps Server and Desktop flavours, and future ISO +# revisions, working without editing this script. +read_boot_layout_options() { + local lines="$WORK_DIR/boot-layout.lines" tokens="$1" + xorriso -indev "$UPSTREAM" -report_el_torito as_mkisofs >"$WORK_DIR/boot-layout.raw" 2>/dev/null || true + + # Step 1: keep the mkisofs option lines only, and drop the options this script + # owns. + # + # `--modification-date` must be dropped: xorriso translates it into its own + # `-volume_date uuid`, which then rejects the value as "not an ECMA-119 time + # string". This script controls the date itself. + # + # Nothing else is rewritten. The `--interval:local_fs:...` operands inside + # `--grub2-mbr` and `-append_partition` already name the base ISO with the very + # path this build passed to xorriso, so they are used verbatim. Rewriting them + # (e.g. via sed or an extra gsub) reorders the interval fields and xorriso then + # fails with "Number text too short or too long in interval reader description + # string". If a future build ever reads and writes the base ISO under different + # paths, that rewrite has to replace the path operand in place, not prefix it. + awk ' + /^-/ { + if ($0 ~ /^-V / || $0 == "-V" || $0 ~ /^-o / || $0 == "-o" || $0 ~ /^--modification-date[= ]/) next + print + } + ' "$WORK_DIR/boot-layout.raw" >"$lines" + + # Step 2: split each line into individual arguments. + # + # as_mkisofs quotes values that contain spaces or commas, and those quoted + # values must reach xorriso as ONE argument. Emitting one option per line and + # relying on the shell to strip the quotes does not work: the option and its + # value collapse into a single argument, and xorriso then reports + # "Unrecognized option '--grub2-mbr --interval:...'". + # So tokenise here: honour quotes while splitting, then drop the quote + # characters, and emit one argument per line for mapfile to collect. + awk ' + { + line = $0; n = length(line); i = 1; token = ""; inq = 0 + while (i <= n) { + c = substr(line, i, 1) + if (c == "'"'"'") { inq = !inq; i++; continue } + if (!inq && (c == " " || c == "\t")) { + if (token != "") { print token; token = "" } + i++; continue + } + token = token c; i++ + } + if (token != "") print token + } + ' "$lines" >"$tokens" + + grep -q -- '-append_partition' "$tokens" || return 1 + return 0 +} + +assemble_iso() { + step "Writing the ISO" + # The boot layout comes from the base ISO itself; see read_boot_layout_options. + if ! read_boot_layout_options "$WORK_DIR/boot-layout.opts"; then + die "could not read a usable boot layout from $(basename "$UPSTREAM"); refusing to write a medium that may not boot." + fi + + local -a boot_opts=() + while IFS= read -r line; do + [[ -n "$line" ]] && boot_opts+=("$line") + done <"$WORK_DIR/boot-layout.opts" + step "Boot layout: ${#boot_opts[@]} option line(s) replayed from the base ISO" + + local -a cmd=(xorriso -as mkisofs) + cmd+=(-V "$VOLID") + if [[ "$REPRODUCIBLE" -eq 1 ]]; then + # -as mkisofs does not know xorriso's -volume_date; the mkisofs-compatible + # spelling is --modification-date=. + # File mtimes come from the extracted base ISO plus the files this script + # installs, and are already stable for identical inputs; --reproducible only + # has to pin the volume-level dates for a byte-stable rebuild. + cmd+=("--modification-date=$REPRO_STAMP") + fi + cmd+=("${boot_opts[@]}" -o "$OUTPUT" "$MEDIA") + + # Diagnostic: record the exact argv before running it. This is what settles + # "the file looks right but xorriso still rejects an option" reports. + local -a trace_cmd=() + if [[ "${BF_ISO_TRACE:-0}" == "1" ]]; then + { printf 'argv:'; printf ' %q' "${cmd[@]}"; printf '\n'; } >"$WORK_DIR/assemble-argv.txt" + fi + + if ! "${cmd[@]}" >"$WORK_DIR/assemble.log" 2>&1; then + tail -n 20 "$WORK_DIR/assemble.log" >&2 + [[ -s "$WORK_DIR/assemble-argv.txt" ]] && cat "$WORK_DIR/assemble-argv.txt" >&2 + rm -f "$OUTPUT" + die "xorriso failed to write the output ISO; the incomplete file was removed." + fi + [[ -s "$OUTPUT" ]] || { rm -f "$OUTPUT"; die "xorriso reported success but produced no data."; } + ok "ISO written: $OUTPUT ($(( $(stat -c %s -- "$OUTPUT") / 1024 / 1024 )) MiB)" +} + +extract_from_iso() { + local iso_path="$1" dest="$2" + rm -f "$dest" + xorriso -osirrox on -indev "$OUTPUT" -extract "$iso_path" "$dest" >/dev/null 2>&1 + [[ -f "$dest" ]] +} + +verify_output_iso() { + step "Verifying the produced medium" + local check_dir="$WORK_DIR/verify" + install -d "$check_dir" + + xorriso -indev "$OUTPUT" -report_el_torito plain > "$WORK_DIR/boot-report.txt" 2>&1 || true + grep -qE 'El Torito boot img.*BIOS' "$WORK_DIR/boot-report.txt" \ + || { rm -f "$OUTPUT"; die "produced ISO lost its BIOS El Torito entry; the broken output was removed."; } + grep -qE 'El Torito boot img.*(UEFI|EFI)' "$WORK_DIR/boot-report.txt" \ + || { rm -f "$OUTPUT"; die "produced ISO lost its UEFI El Torito entry; the broken output was removed."; } + grep -qF "Volume id : '$VOLID'" "$WORK_DIR/boot-report.txt" \ + || warn "produced ISO reports a different volume label than requested (${VOLID})" + ok "El Torito boot entries preserved (BIOS + UEFI) and readable by xorriso" + + extract_from_iso /autoinstall.yaml "$check_dir/autoinstall.yaml" \ + || { rm -f "$OUTPUT"; die "the produced ISO has no /autoinstall.yaml at the medium root."; } + grep -q 'interactive-sections: \[storage\]' "$check_dir/autoinstall.yaml" \ + || { rm -f "$OUTPUT"; die "medium autoinstall lost 'interactive-sections: [storage]'; the broken output was removed."; } + grep -q 'offline-install' "$check_dir/autoinstall.yaml" \ + || { rm -f "$OUTPUT"; die "medium autoinstall is missing the offline-install apt fallback."; } + grep -q "$MEDIA_PREFIX/firstboot" "$check_dir/autoinstall.yaml" \ + || { rm -f "$OUTPUT"; die "medium autoinstall does not place the firstboot payload from ${FIRSTBOOT_PATH}/."; } + grep -q 'blueforce-firstboot.service' "$check_dir/autoinstall.yaml" \ + || { rm -f "$OUTPUT"; die "medium autoinstall does not enable blueforce-firstboot.service."; } + ok "medium root /autoinstall.yaml verified (interactive storage, offline-install fallback, firstboot late-commands)" + + extract_from_iso /boot/grub/grub.cfg "$check_dir/grub.cfg" \ + || { rm -f "$OUTPUT"; die "the produced ISO has no /boot/grub/grub.cfg; the broken output was removed."; } + grep -q 'autoinstall' "$check_dir/grub.cfg" \ + || { rm -f "$OUTPUT"; die "the medium GRUB configuration does not start autoinstall; the broken output was removed."; } + ok "medium GRUB configuration requests autoinstall on boot" + if extract_from_iso /boot/grub/grub.cfg.blueforce-orig "$check_dir/grub.cfg.orig"; then + ok "pristine GRUB configuration kept on the medium as grub.cfg.blueforce-orig" + fi + + local -a expected=( + "$MEDIA_PREFIX/firstboot/bf-firstboot" + "$MEDIA_PREFIX/firstboot/blueforce-firstboot.service" + "$MEDIA_PREFIX/release/blueforce-release" + "$MEDIA_PREFIX/release/media-info.yaml" + "$MEDIA_PREFIX/release/manifest.yaml" + "$MEDIA_PREFIX/offline-repo/manifests/base-packages.txt" + ) + local relative + for relative in "${expected[@]}"; do + extract_from_iso "$relative" "$check_dir/payload" \ + || { rm -f "$OUTPUT"; die "the produced ISO is missing ${relative}; the broken output was removed."; } + done + ok "firstboot, release and offline-repo payloads present in the produced ISO" + + if [[ "$OFFLINE_MODE" == "embedded" ]]; then + extract_from_iso "$MEDIA_PREFIX/offline-repo/built/Packages" "$check_dir/Packages" \ + || { rm -f "$OUTPUT"; die "the offline APT index is missing from the produced ISO."; } + ok "offline APT repository index present in the produced ISO" + fi +} + +# ------------------------------------------------------------------ artifacts -- + +package_version_from_offline_repo() { + local lock="" value name + [[ "$OFFLINE_MODE" == "embedded" ]] && lock="$OFFLINE_REPO/packages.lock.tsv" + if [[ -z "$lock" || ! -f "$lock" ]]; then + printf 'unknown' + return + fi + for name in "$@"; do + value="$(awk -F'\t' -v p="$name" 'NR > 1 && $1 == p {print $2; exit}' "$lock" | head -n 1 || true)" + if [[ -n "$value" ]]; then + printf '%s' "$value" + return + fi + done + printf 'unknown' +} + +package_version_by_pattern() { + local pattern="$1" lock="" value + if [[ "$OFFLINE_MODE" != "embedded" ]]; then + printf 'unknown' + return + fi + lock="$OFFLINE_REPO/packages.lock.tsv" + [[ -f "$lock" ]] || { printf 'unknown'; return; } + value="$(awk -F'\t' -v pat="$pattern" 'NR > 1 && $1 ~ pat {print $2; exit}' "$lock" | head -n 1 || true)" + printf '%s' "${value:-unknown}" +} + +write_dist_artifacts() { + step "Checksums and manifest" + local iso_sha256 build_date git_commit git_state + iso_sha256="$(sha256sum -- "$OUTPUT" | awk '{print $1}')" + printf '%s %s\n' "$iso_sha256" "$(basename "$OUTPUT")" > "$SHA_FILE" + ok "checksum written: $SHA_FILE" + + build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + git_commit="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)" + git_commit="${git_commit:-unknown}" + # Distinguish "clean", "dirty" and "cannot tell". If git is unavailable or the + # tree is not a repository, the status command fails and the previous form would + # silently report "clean" - a claim the build has no evidence for. + if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + git_state="unknown" + elif [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | head -n 1 || true)" ]]; then + git_state="dirty-worktree" + else + git_state="clean" + fi + + local rustdesk_pkg docker_pkg xrdp_pkg wireguard_pkg meg_pkg installer_pkg packages_source + rustdesk_pkg="$(package_version_by_pattern '^(rustdesk|rustdesk-server)$')" + docker_pkg="$(package_version_from_offline_repo docker-ce docker.io docker)" + xrdp_pkg="$(package_version_from_offline_repo xrdp xorgxrdp gnome-session)" + wireguard_pkg="$(package_version_from_offline_repo wireguard wireguard-tools)" + meg_pkg="$(package_version_by_pattern '^meg')" + installer_pkg="$(package_version_by_pattern '^blueforce-installer')" + if [[ "$OFFLINE_MODE" == "embedded" ]]; then + packages_source="embedded offline APT lock file (packages.lock.tsv)" + else + packages_source="no built offline repository was embedded; every package version is reported as unknown on purpose" + fi + + { + printf 'schema_version: 1\n' + printf 'field_os_version: "%s"\n' "$VERSION" + printf 'base_os: "%s"\n' "$(base_os_string)" + printf 'base_flavor: "%s"\n' "$BASE_FLAVOR" + printf 'base_iso_filename: "%s"\n' "$(basename "$UPSTREAM")" + printf 'ubuntu_version: "%s"\n' "$UBUNTU_VERSION" + printf 'ubuntu_iso_sha256: "%s"\n' "$UPSTREAM_SHA256" + printf 'git_commit: "%s"\n' "$git_commit" + printf 'git_worktree: "%s"\n' "$git_state" + printf 'build_date: "%s"\n' "$build_date" + printf 'architecture: "%s"\n' "$ARCH" + printf 'iso_volid: "%s"\n' "$VOLID" + printf 'iso_filename: "%s"\n' "$(basename "$OUTPUT")" + printf 'iso_sha256: "%s"\n' "$iso_sha256" + printf 'release_status: "lab-artifact"\n' + printf 'packages:\n' + printf ' rustdesk: "%s"\n' "$rustdesk_pkg" + printf ' docker: "%s"\n' "$docker_pkg" + printf ' xrdp: "%s"\n' "$xrdp_pkg" + printf ' wireguard: "%s"\n' "$wireguard_pkg" + printf ' meg_package_version: "%s"\n' "$meg_pkg" + printf ' blueforce_installer_version: "%s"\n' "$installer_pkg" + printf 'packages_source: "%s"\n' "$packages_source" + printf 'media:\n' + printf ' autoinstall_config: "/autoinstall.yaml"\n' + printf ' interactive_sections: [storage]\n' + printf ' apt_fallback: "offline-install"\n' + printf ' firstboot_path: "%s/"\n' "$FIRSTBOOT_PATH" + printf ' offline_repo_status: "%s"\n' "$OFFLINE_MODE" + printf ' boot: "El Torito BIOS + UEFI rebuilt from the base ISO boot layout; autoinstall requested on every kernel line"\n' + printf ' checksum_record: "%s"\n' "$(basename "$CHECKSUM_RECORD")" + printf 'safety:\n' + printf ' device_identity_embedded: false\n' + printf ' automatic_disk_wipe: false\n' + printf ' identity_collection_point: "firstboot (dealer ID is entered on the installed system)"\n' + if [[ "$BASE_FLAVOR" != "server" ]]; then + printf 'baseline_deviation:\n' + printf ' documented_baseline: "Ubuntu Server 26.04.1+ LTS (docs/24 K-01, docs/26, docs/32)"\n' + printf ' built_base: "%s"\n' "$(base_os_string)" + printf ' reason: "The only checksum-verified installation medium available on this build host is the %s ISO, and builds must not download another image."\n' "$BASE_FLAVOR" + printf ' impact: "Desktop media already ships GNOME, so the GNOME desktop layer needs no extra installation step on the target. Server media stays supported through --upstream-iso."\n' + printf ' decision_needed: "Accept the Desktop-based baseline for LAB, or rebuild against a Server ISO before production use."\n' + printf ' lab_required: true\n' + else + printf 'baseline_deviation: none\n' + fi + printf 'notes:\n' + printf ' - "The build date lives in this manifest only; the ISO itself carries no build timestamp."\n' + printf ' - "Boot acceptance (UEFI, Legacy BIOS, Secure Boot) and the autoinstall path on this flavour are LAB work; this artifact is not a production release."\n' + printf ' - "Device identity, dealer ID, private keys and passwords are never embedded; identity is collected at firstboot."\n' + } > "$MANIFEST" + ok "manifest written: $MANIFEST" +} + +print_summary() { + step "Done" + note "Field OS version : $VERSION" + note "Base medium : $(base_os_string) [base_flavor=${BASE_FLAVOR}]" + note "Base ISO SHA256 : $UPSTREAM_SHA256" + note "ISO : $OUTPUT" + note "ISO checksum : $SHA_FILE" + note "Manifest : $MANIFEST" + note "Offline repo : $OFFLINE_MODE" + note "" + note "The medium boots into an operator-assisted install: storage stays interactive and" + note "device identity is entered at firstboot. UEFI/BIOS boot acceptance is still a LAB step." +} + +main() { + resolve_parameters + preflight + if [[ "$CHECK_ONLY" -eq 1 ]]; then + step "Check passed" + note "All preconditions for $ISO_NAME are satisfied. Nothing was written." + exit 0 + fi + + WORK_DIR="$(mktemp -d "$WORK_ROOT/blueforce-iso.XXXXXX")" || die "Could not create a scratch directory under $WORK_ROOT" + cleanup() { + if [[ "$KEEP_WORK" -eq 1 ]]; then + printf 'Scratch directory kept: %s\n' "$WORK_DIR" + else + rm -rf -- "$WORK_DIR" + fi + } + trap cleanup EXIT + + prepare_media + inject_boot_configuration + assemble_iso + verify_output_iso + write_dist_artifacts + print_summary +} + +main "$@" diff --git a/provisioning/iso/field-os-version b/provisioning/iso/field-os-version new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/provisioning/iso/field-os-version @@ -0,0 +1 @@ +1.0.0 diff --git a/provisioning/iso/test-iso.sh b/provisioning/iso/test-iso.sh new file mode 100755 index 0000000..00d7097 --- /dev/null +++ b/provisioning/iso/test-iso.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Perform non-destructive checks on an explicitly supplied ISO artifact. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: test-iso.sh --iso PATH --checksum PATH [--check] + +Verifies SHA256 and, when xorriso is installed, reads El Torito boot metadata. +This script neither boots nor writes media. +EOF +} +ISO=""; CHECKSUM="" +while [[ $# -gt 0 ]]; do + case "$1" in + --iso) ISO="${2:-}"; shift 2 ;; + --checksum) CHECKSUM="${2:-}"; shift 2 ;; + --check) shift ;; + --help|-h) usage; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done +[[ -n "$ISO" && -n "$CHECKSUM" ]] || { usage >&2; exit 2; } +"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-upstream-iso.sh" --iso "$ISO" --checksum "$CHECKSUM" --check +command -v xorriso >/dev/null 2>&1 || { printf 'xorriso unavailable: SHA256 passed, boot metadata test is fail-closed.\n' >&2; exit 1; } +xorriso -indev "$ISO" -report_el_torito plain >/dev/null 2>&1 || { printf 'Boot metadata test failed.\n' >&2; exit 1; } +printf 'Non-destructive ISO checks passed: %s\n' "$ISO" diff --git a/provisioning/iso/ubuntu-26.04.1-SHA256SUMS b/provisioning/iso/ubuntu-26.04.1-SHA256SUMS new file mode 100644 index 0000000..d7f4fd7 --- /dev/null +++ b/provisioning/iso/ubuntu-26.04.1-SHA256SUMS @@ -0,0 +1,6 @@ +487f87faaf547ea30e0aba4d5b53346292571256b25333a978db1692bcee9dd2 *ubuntu-26.04-desktop-amd64.iso +dec49008a71f6098d0bcfc822021f4d042d5f2db279e4d75bdd981304f1ca5d9 *ubuntu-26.04-live-server-amd64.iso +96c7f5fb28a7fe28245331f9bfbe4375f18dd29a4850116ad3c4f60f6700c55c *ubuntu-26.04-wsl-amd64.wsl +601e30fbf5d97759367c632e2c33630665039b7e2158fd068403da3ccf1bda1f *ubuntu-26.04.1-desktop-amd64.iso +cc8a95cde20f6ced61a322420de00f10cc3c90ced545daa46cb9c1a117f1d927 *ubuntu-26.04.1-live-server-amd64.iso +48d56724b5c8e60f24893e83e73bbb58c60b3ca22fba3da977075420acd54104 *ubuntu-26.04.1-wsl-amd64.wsl diff --git a/provisioning/iso/verify-upstream-iso.sh b/provisioning/iso/verify-upstream-iso.sh new file mode 100755 index 0000000..3c36871 --- /dev/null +++ b/provisioning/iso/verify-upstream-iso.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Verify a locally supplied upstream ISO against an explicitly supplied SHA256 record. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: verify-upstream-iso.sh --iso PATH --checksum PATH [--check] + +Verifies a local ISO without downloading anything. --checksum may contain a +SHA256SUMS-style line for the ISO or a single 64-character digest. +EOF +} +ISO=""; CHECKSUM=""; CHECK_ONLY=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --iso) ISO="${2:-}"; shift 2 ;; + --checksum) CHECKSUM="${2:-}"; shift 2 ;; + --check) CHECK_ONLY=1; shift ;; + --help|-h) usage; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done +[[ -n "$ISO" && -n "$CHECKSUM" ]] || { usage >&2; exit 2; } +[[ -f "$ISO" ]] || { printf 'ISO not found: %s\n' "$ISO" >&2; exit 2; } +[[ -f "$CHECKSUM" ]] || { printf 'Checksum file not found: %s\n' "$CHECKSUM" >&2; exit 2; } +expected="$(awk -v base="$(basename "$ISO")" '$1 ~ /^[[:xdigit:]]{64}$/ && (NF == 1 || $2 == base || $2 == "*" base) {print tolower($1); exit}' "$CHECKSUM")" +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { printf 'No SHA256 entry for ISO basename in: %s\n' "$CHECKSUM" >&2; exit 2; } +actual="$(sha256sum "$ISO" | awk '{print $1}')" +if [[ "$actual" != "$expected" ]]; then + printf 'SHA256 verification FAILED for %s\nexpected: %s\nactual: %s\n' "$ISO" "$expected" "$actual" >&2 + exit 1 +fi +printf 'SHA256 verified: %s\n' "$ISO" +[[ "$CHECK_ONLY" -eq 1 ]] && printf 'Check mode: no files were changed.\n' diff --git a/provisioning/offline-repo/README.md b/provisioning/offline-repo/README.md new file mode 100644 index 0000000..8f16b52 --- /dev/null +++ b/provisioning/offline-repo/README.md @@ -0,0 +1,73 @@ +# Offline APT repository + +Reviewed `.deb` files are not stored in this repository. Supply them locally on the LAB build machine; the tooling never fetches a package, never resolves a dependency from a remote source, and never signs anything. + +## Current state (read this first) + +- **This repository contains no packages and no built repository.** `packages/` holds only `.gitkeep`, and there is no `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv` or `SHA256SUMS` anywhere in the tree. +- `manifests/*.txt` is the **package contract**. Every line is `package=version`. The package names are final and complete; the version field still reads the sentinel `UNPINNED` because no package has been acquired or reviewed yet. +- The versions must be filled on the LAB build machine from the reviewed `.deb` files. Until then everything fails closed: `--check` and a real build refuse a pin whose version is still `UNPINNED`, and the offline installer refuses a bundle whose lock does not satisfy the manifests. +- Nothing here was built on a workstation: `apt-ftparchive` and `dpkg-deb` exist only on the Ubuntu 26.04 build host, so no bundle was produced and no `.deb` was downloaded. `--dry-run-plan` is the only mode that runs anywhere. + +## Contract files + +| File | Role | +|---|---| +| `manifests/base-packages.txt` | Base system pins: tooling, SSH, WireGuard, SMART, firewall, node exporter (installer modules 02, 06, 07, 12, 13) | +| `manifests/remote-access.txt` | Remote access and desktop pins: xRDP, GNOME session, minimal desktop, RustDesk (modules 08, 09, 16) | +| `manifests/docker.txt` | Docker CE pins (module 10) | +| `manifests/requirements.tsv` | Audit table: which manifest must pin which package, and which installer module or diagnostic needs it | +| `manifests/packages.lock.schema.tsv` | Documented layout of the `packages.lock.tsv` SBOM that a build writes into the bundle | + +`scripts/install/blueforce-install.sh --offline` reads **every** `manifests/*.txt` as a pin manifest: an empty or comment-only manifest, an invalid pin, or a pin that is missing from the bundle stops the installer before any package module runs. Adding a `*.txt` file to `manifests/` is therefore a change to that contract, not a local convenience — `manifests/requirements.tsv` must be updated in the same edit. + +## Modes + +```bash +# Static contract validation: no apt-ftparchive, no dpkg-deb, no pool, no network. +./build-offline-repo.sh --dry-run-plan + +# LAB only: verify that the supplied pool satisfies every pin. +./build-offline-repo.sh --packages /secure/reviewed-debs --output /tmp/blueforce-apt --check + +# LAB only: build the bundle. +./build-offline-repo.sh --packages /secure/reviewed-debs --output /srv/blueforce-apt +``` + +`--dry-run-plan` validates the pin format, duplicate and whitespace errors, placeholder leakage, the audit table, the SBOM schema, and the presence of every required package, then prints the missing versions and missing packages as explicit lists. It exits non-zero while anything is unfilled, and it needs no build tooling, so it runs on any machine. It never writes to the manifests. + +`--check` and a real build require `apt-ftparchive` and `dpkg-deb`; without them the tooling refuses and names the missing tool. A real build also refuses to overwrite an existing output directory. + +## LAB build machine requirements + +1. Ubuntu 26.04 LTS build host, the release named in every manifest, `amd64`. +2. `apt-ftparchive` (package `apt-utils`) and `dpkg-deb` (package `dpkg`) installed. +3. A connected acquisition host to obtain and review the `.deb` files for that release, including the packages that are **not** in the Ubuntu archive: `rustdesk` (vendor build) and the Docker CE set (`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`). +4. A review step that records exactly what was accepted: package, version, architecture, SHA256 and source file name, in the layout of `manifests/packages.lock.schema.tsv`. + +### Fill procedure + +1. Put the reviewed `.deb` files in one directory, for example `/secure/reviewed-debs`. +2. For each manifest line, read the exact version with `dpkg-deb -f .deb Package Version` and replace `UNPINNED` with it. One pin per package across all manifests. +3. Run `./build-offline-repo.sh --dry-run-plan` until it prints `PLAN READY` (`missing_packages=0` and `errors=0`). +4. Run `--check` against the reviewed pool, then build into a fresh output directory. +5. Archive the bundle, the ISO manifest and the reviewed pool together, since the bundle is the only proof of what a device installed. + +## What a build produces + +`/pool/*.deb`, `Packages`, `Packages.gz`, `packages.lock.tsv` (package, version, architecture, sha256, source) and `SHA256SUMS` covering the indexes and the lock file. + +## Air-gapped target + +- The bundle is copied to the target device as `/opt/blueforce/offline-repo`, the default of `BF_OFFLINE_REPO`. +- `scripts/install/blueforce-install.sh --offline` requires that directory to exist with `Packages`, `Packages.gz`, `packages.lock.tsv` and `SHA256SUMS`, runs `sha256sum -c SHA256SUMS`, verifies the lock layout, and checks every manifest pin against a lock row and a pool file. +- Only then does it write `/offline.list` containing exactly `deb [trusted=yes] file:/opt/blueforce/offline-repo ./` and export an isolated APT configuration that points at that file. No remote source, key download, `curl`, `wget` or `apt download` is used. +- An offline installation ends in `PROVISIONED_OFFLINE`. The device is not `READY` until enrollment and central channel verification have succeeded. + +## LAB checklist + +- [ ] Every `manifests/*.txt` pin carries a real version from a reviewed `.deb`; `--dry-run-plan` prints `PLAN READY`. +- [ ] `manifests/requirements.tsv` still matches the manifests (the plan fails on drift in either direction). +- [ ] `--check` passes against the reviewed pool and the built bundle verifies with `sha256sum -c SHA256SUMS`. +- [ ] The reviewed pool and the bundle are archived together with the ISO release manifest. +- [ ] `tests/check-offline-repo-static.sh` passes in CI. diff --git a/provisioning/offline-repo/build-offline-repo.sh b/provisioning/offline-repo/build-offline-repo.sh new file mode 100755 index 0000000..2f5e931 --- /dev/null +++ b/provisioning/offline-repo/build-offline-repo.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Create a local APT repository solely from reviewed local Debian packages. +# +# Nothing here downloads anything and no dependency is resolved from a remote +# source: every .deb must already exist in the local pool directory. +# +# Modes: +# --dry-run-plan Validate the manifest contract only. Needs no build tooling +# and no package pool, and prints the exact LAB gaps. +# --check Verify that the supplied pool satisfies every pin. +# --packages/--output (default) Build the repository. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# BF_MANIFEST_DIR points the --dry-run-plan validator at a fixture directory; the +# static test suite uses it for negative cases. Production runs, --check and the +# build always read the reviewed manifests next to this script. +MANIFEST_DIR="${BF_MANIFEST_DIR:-$SCRIPT_DIR/manifests}" +MANIFESTS=(base-packages.txt remote-access.txt docker.txt) +REQUIREMENTS_FILE="requirements.tsv" +LOCK_SCHEMA_FILE="packages.lock.schema.tsv" +LOCK_HEADER="$(printf 'package\tversion\tarchitecture\tsha256\tsource')" +UNPINNED="UNPINNED" + +usage() { + cat <<'EOF' +Usage: build-offline-repo.sh --packages DIR --output DIR [--check] + build-offline-repo.sh --dry-run-plan + +Uses only local .deb files. apt-ftparchive is required; no package is fetched. +Every package is recorded as package, version, architecture, SHA256, and source. + +--dry-run-plan validates manifests/*.txt, manifests/requirements.tsv and +manifests/packages.lock.schema.tsv without apt-ftparchive, dpkg-deb or a package +pool. It exits non-zero while a pin still reads UNPINNED or a required package is +absent from the contract, and lists every gap so the LAB can fill it in. +EOF +} + +PACKAGES=""; OUTPUT=""; CHECK_ONLY=0; PLAN_ONLY=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --packages) PACKAGES="${2:-}"; shift 2 ;; + --output) OUTPUT="${2:-}"; shift 2 ;; + --check) CHECK_ONLY=1; shift ;; + --dry-run-plan) PLAN_ONLY=1; shift ;; + --help|-h) usage; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done + +# Static validation of the pin contract. Findings are collected first so the +# report is complete, sorted, and identical on every machine. +plan_report() { + local errors=0 awaiting=0 filled=0 total=0 absent=0 known=0 rows=0 + local -A owner=() pinned=() required=() + local -a awaiting_lines=() absent_lines=() error_lines=() + local manifest file line lineno package version key name extra tool_apt tool_deb mf pkg source why + + if command -v apt-ftparchive >/dev/null 2>&1; then tool_apt=present; else tool_apt=absent; fi + if command -v dpkg-deb >/dev/null 2>&1; then tool_deb=present; else tool_deb=absent; fi + + if [[ ! -d "$MANIFEST_DIR" ]]; then + printf 'PLAN ERROR: manifest directory not found: %s\n' "$MANIFEST_DIR" + printf '\nPLAN NOT READY: manifest directory is missing.\n' + return 1 + fi + + # The offline installer reads every manifests/*.txt as a pin manifest, so an + # extra file there silently becomes part of the contract. + while IFS= read -r -d '' extra; do + name="$(basename "$extra")" + known=0 + for manifest in "${MANIFESTS[@]}"; do + if [[ "$name" == "$manifest" ]]; then known=1; fi + done + if [[ "$known" -eq 0 ]]; then + error_lines+=("$name: unexpected manifests/*.txt file; scripts/install/blueforce-install.sh treats every one of them as a pin manifest") + errors=$((errors + 1)) + fi + done < <(find "$MANIFEST_DIR" -maxdepth 1 -type f -name '*.txt' -print0 | sort -z) + + for manifest in "${MANIFESTS[@]}"; do + file="$MANIFEST_DIR/$manifest" + if [[ ! -s "$file" ]]; then + error_lines+=("$manifest: missing or empty; the offline installer refuses an empty pin manifest") + errors=$((errors + 1)) + continue + fi + lineno=0 + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + if [[ -z "$line" || "$line" == \#* ]]; then continue; fi + if [[ "$line" == *[![:print:]]* ]]; then + error_lines+=("$manifest:$lineno: non-printable character in pin") + errors=$((errors + 1)) + continue + fi + if [[ "$line" == *[[:space:]]* ]]; then + error_lines+=("$manifest:$lineno: whitespace in a pin; expected exactly package=version with no inline comment: $line") + errors=$((errors + 1)) + continue + fi + if [[ ! "$line" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ ]]; then + error_lines+=("$manifest:$lineno: not an exact pin (expected package=version): $line") + errors=$((errors + 1)) + continue + fi + if [[ "${line,,}" =~ (placeholder|replace|todo|tbd|fixme|changeme|fillme|example) ]]; then + error_lines+=("$manifest:$lineno: placeholder token left in pin: $line") + errors=$((errors + 1)) + continue + fi + package="${line%%=*}" + version="${line#*=}" + total=$((total + 1)) + if [[ "${version^^}" == "$UNPINNED" || ! "$version" =~ [0-9] ]]; then + awaiting=$((awaiting + 1)) + awaiting_lines+=("$manifest: $package=$version") + else + filled=$((filled + 1)) + fi + if [[ -n "${owner[$package]:-}" ]]; then + error_lines+=("$manifest:$lineno: duplicate pin for $package; already declared in ${owner[$package]}") + errors=$((errors + 1)) + fi + owner[$package]="$manifest" + pinned["$manifest:$package"]=1 + done < "$file" + done + + if [[ ! -s "$MANIFEST_DIR/$REQUIREMENTS_FILE" ]]; then + error_lines+=("$REQUIREMENTS_FILE: missing or empty; the contract has no audit table of why each package is needed") + errors=$((errors + 1)) + else + lineno=0 + while IFS=$'\t' read -r mf pkg source why || [[ -n "${mf:-}" ]]; do + lineno=$((lineno + 1)) + if [[ -z "$mf" || "$mf" == \#* ]]; then continue; fi + if [[ -z "${pkg:-}" || -z "${source:-}" || -z "${why:-}" ]]; then + error_lines+=("$REQUIREMENTS_FILE:$lineno: expected four TAB-separated fields (manifest, package, source, why)") + errors=$((errors + 1)) + continue + fi + known=0 + for manifest in "${MANIFESTS[@]}"; do + if [[ "$mf" == "$manifest" ]]; then known=1; fi + done + if [[ "$known" -eq 0 ]]; then + error_lines+=("$REQUIREMENTS_FILE:$lineno: unknown manifest column: $mf") + errors=$((errors + 1)) + continue + fi + if [[ ! "$pkg" =~ ^[a-z0-9][a-z0-9+.-]*$ ]]; then + error_lines+=("$REQUIREMENTS_FILE:$lineno: invalid package name: $pkg") + errors=$((errors + 1)) + continue + fi + key="$mf:$pkg" + required[$key]=1 + if [[ -z "${pinned[$key]:-}" ]]; then + absent_lines+=("$mf: $pkg (required by $source)") + absent=$((absent + 1)) + fi + done < "$MANIFEST_DIR/$REQUIREMENTS_FILE" + fi + + for key in "${!pinned[@]}"; do + if [[ -z "${required[$key]:-}" ]]; then + error_lines+=("${key%%:*}: ${key#*:} is pinned but absent from $REQUIREMENTS_FILE; add the audit row or drop the pin") + errors=$((errors + 1)) + fi + done + + if [[ ! -s "$MANIFEST_DIR/$LOCK_SCHEMA_FILE" ]]; then + error_lines+=("$LOCK_SCHEMA_FILE: missing or empty; the released packages.lock.tsv layout is undocumented") + errors=$((errors + 1)) + else + lineno=0 + rows=0 + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + if [[ -z "$line" || "$line" == \#* ]]; then continue; fi + rows=$((rows + 1)) + if [[ "$line" != "$LOCK_HEADER" ]]; then + if [[ "$rows" -eq 1 ]]; then + error_lines+=("$LOCK_SCHEMA_FILE:$lineno: first uncommented line must be the exact lock header (TAB-separated package/version/architecture/sha256/source)") + else + error_lines+=("$LOCK_SCHEMA_FILE:$lineno: schema template must not contain data rows; keep examples commented out") + fi + errors=$((errors + 1)) + fi + done < "$MANIFEST_DIR/$LOCK_SCHEMA_FILE" + fi + + printf '=== Offline APT repository dry-run plan (no build, no network) ===\n' + printf 'Manifest directory: %s\n' "$MANIFEST_DIR" + printf 'Build tooling (not used by this plan): apt-ftparchive=%s dpkg-deb=%s\n' "$tool_apt" "$tool_deb" + printf 'A real build additionally needs an Ubuntu 26.04 build machine with both tools.\n' + printf 'SUMMARY: pins=%d filled=%d awaiting=%d missing_packages=%d errors=%d\n' \ + "$total" "$filled" "$awaiting" "$absent" "$errors" + + if [[ ${#awaiting_lines[@]} -gt 0 ]]; then + printf '\nMISSING VERSIONS (LAB: replace the %s sentinel with the exact version of the reviewed .deb):\n' "$UNPINNED" + while IFS= read -r line; do printf ' %s\n' "$line"; done < <(printf '%s\n' "${awaiting_lines[@]}" | sort) + fi + if [[ ${#absent_lines[@]} -gt 0 ]]; then + printf '\nMISSING PACKAGES (required by the installer or the audit table but not pinned):\n' + while IFS= read -r line; do printf ' %s\n' "$line"; done < <(printf '%s\n' "${absent_lines[@]}" | sort) + fi + if [[ ${#error_lines[@]} -gt 0 ]]; then + printf '\nPLAN ERRORS (contract violations, must be fixed before a build):\n' + while IFS= read -r line; do printf ' %s\n' "$line"; done < <(printf '%s\n' "${error_lines[@]}" | sort) + fi + + if [[ "$errors" -gt 0 || "$absent" -gt 0 || "$awaiting" -gt 0 ]]; then + printf '\nPLAN NOT READY: %d pin(s) await a LAB version, %d required package(s) unpinned, %d error(s).\n' \ + "$awaiting" "$absent" "$errors" + return 1 + fi + printf '\nPLAN READY: %d pin(s) cover the contract; run --check on the Ubuntu 26.04 build machine with the reviewed pool.\n' "$total" + return 0 +} + +if [[ "$PLAN_ONLY" -eq 1 ]]; then + [[ -z "$PACKAGES" && -z "$OUTPUT" && "$CHECK_ONLY" -eq 0 ]] || { + printf 'Refusing: --dry-run-plan cannot be combined with --packages, --output or --check.\n' >&2 + exit 2 + } + if plan_report; then exit 0; fi + exit 1 +fi + +[[ -n "$PACKAGES" && -n "$OUTPUT" ]] || { usage >&2; exit 2; } +[[ -d "$PACKAGES" ]] || { printf 'Package directory not found: %s\n' "$PACKAGES" >&2; exit 2; } +command -v apt-ftparchive >/dev/null 2>&1 || { printf 'Refusing build: apt-ftparchive is not installed.\n' >&2; exit 1; } +command -v dpkg-deb >/dev/null 2>&1 || { printf 'Refusing build: dpkg-deb is not installed.\n' >&2; exit 1; } +mapfile -d '' debs < <(find "$PACKAGES" -maxdepth 1 -type f -name '*.deb' -print0 | sort -z) +[[ ${#debs[@]} -gt 0 ]] || { printf 'Refusing build: no local .deb files supplied.\n' >&2; exit 1; } +for list in "$SCRIPT_DIR/manifests/base-packages.txt" "$SCRIPT_DIR/manifests/remote-access.txt" "$SCRIPT_DIR/manifests/docker.txt"; do + pins=0 + while IFS= read -r pin || [[ -n "$pin" ]]; do + [[ -z "$pin" || "$pin" == \#* ]] && continue + [[ "$pin" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ && "$pin" != *placeholder* && "$pin" != *REPLACE* && "$pin" != *replace* ]] || { printf 'Invalid exact package pin in %s: %s\n' "$list" "$pin" >&2; exit 2; } + version_part="${pin#*=}" + case "${version_part^^}" in + *UNPINNED*) printf 'Refusing build: pin still awaits its LAB version in %s: %s\n' "$list" "$pin" >&2; exit 2 ;; + esac + pins=$((pins + 1)) + found=0 + for deb in "${debs[@]}"; do + [[ "$(dpkg-deb -f "$deb" Package)=$(dpkg-deb -f "$deb" Version)" == "$pin" ]] && found=1 && break + done + [[ "$found" -eq 1 ]] || { printf 'Pinned package not supplied locally: %s\n' "$pin" >&2; exit 1; } + done < "$list" + [[ "$pins" -gt 0 ]] || { printf 'Refusing build: manifest has no concrete package pins: %s\n' "$list" >&2; exit 1; } +done +if [[ "$CHECK_ONLY" -eq 1 ]]; then + printf 'Check passed: %d local packages and all requested pins are available.\n' "${#debs[@]}" + exit 0 +fi +[[ ! -e "$OUTPUT" ]] || { printf 'Refusing to overwrite output: %s\n' "$OUTPUT" >&2; exit 2; } +mkdir -p "$OUTPUT/pool" +lock="$OUTPUT/packages.lock.tsv" +printf 'package\tversion\tarchitecture\tsha256\tsource\n' > "$lock" +for deb in "${debs[@]}"; do + base="$(basename "$deb")" + cp -- "$deb" "$OUTPUT/pool/$base" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(dpkg-deb -f "$deb" Package)" "$(dpkg-deb -f "$deb" Version)" "$(dpkg-deb -f "$deb" Architecture)" \ + "$(sha256sum "$deb" | awk '{print $1}')" "$base" >> "$lock" +done +( cd "$OUTPUT"; apt-ftparchive packages pool > Packages; gzip -n -9 -c Packages > Packages.gz; sha256sum Packages Packages.gz packages.lock.tsv > SHA256SUMS ) +printf 'Built offline repository: %s\n' "$OUTPUT" diff --git a/provisioning/offline-repo/manifests/base-packages.txt b/provisioning/offline-repo/manifests/base-packages.txt new file mode 100644 index 0000000..fd2aaf2 --- /dev/null +++ b/provisioning/offline-repo/manifests/base-packages.txt @@ -0,0 +1,46 @@ +# Blueforce Field OS - offline APT pin manifest: base system packages +# +# Target release : Ubuntu 26.04 LTS, architecture amd64 (LAB confirms both) +# Format : one exact pin per line -> package=version ("#" starts a comment) +# Consumed by : scripts/install/blueforce-install.sh --offline, which refuses to +# run any package module unless every pin here is present in the +# bundle lock file /packages.lock.tsv. +# +# UNPINNED is a sentinel, not a version. Fill it in LAB: +# 1. acquire the reviewed .deb for the package into one local pool directory +# 2. read the exact version: dpkg-deb -f .deb Package Version +# 3. replace UNPINNED with that version, keeping the line shape: +# openssh-server= +# 4. verify with the static plan: ../build-offline-repo.sh --dry-run-plan +# (it must print PLAN READY before --check or a real build is attempted) +# +# Rules enforced by the tooling: lower-case package names, exactly one pin per +# package across all manifests, no inline comments, no whitespace around "=", +# no placeholder/replace/todo tokens. Adding or removing a package is a contract +# change: update manifests/requirements.tsv in the same edit or the plan fails. + +# --- base tooling (installer module 02-system.sh, BASE_PKGS) --- +curl=UNPINNED +wget=UNPINNED +ca-certificates=UNPINNED +gnupg=UNPINNED +lsb-release=UNPINNED +# Transitional dummy on apt >= 2.0 (https is built into apt). Kept for air-gapped +# parity with the reference build; the LAB may drop it after review. +apt-transport-https=UNPINNED + +# --- remote shell and encrypted transport (modules 07-ssh.sh, 06-wireguard.sh) --- +openssh-server=UNPINNED +wireguard-tools=UNPINNED + +# --- field diagnostics: SMART disk health evidence --- +# Consumed by scripts/diagnostics/bf-diagnostics and bf-hardware-inventory.sh. +smartmontools=UNPINNED + +# --- firewall baseline (module 13-firewall.sh) --- +ufw=UNPINNED +iptables=UNPINNED + +# --- monitoring (module 12-monitoring.sh) --- +# The textfile collector ships inside this package; no extra dependency is needed. +prometheus-node-exporter=UNPINNED diff --git a/provisioning/offline-repo/manifests/docker.txt b/provisioning/offline-repo/manifests/docker.txt new file mode 100644 index 0000000..5a6a280 --- /dev/null +++ b/provisioning/offline-repo/manifests/docker.txt @@ -0,0 +1,30 @@ +# Blueforce Field OS - offline APT pin manifest: Docker CE +# +# Target release : Ubuntu 26.04 LTS, architecture amd64 (LAB confirms both) +# Format : one exact pin per line -> package=version ("#" starts a comment) +# Consumed by : scripts/install/blueforce-install.sh --offline, which refuses to +# run any package module unless every pin here is present in the +# bundle lock file /packages.lock.tsv. +# +# UNPINNED is a sentinel, not a version. Fill it in LAB: +# 1. acquire the reviewed .deb for the package into one local pool directory +# 2. read the exact version: dpkg-deb -f .deb Package Version +# 3. replace UNPINNED with that version, keeping the line shape: +# docker-ce= +# 4. verify with the static plan: ../build-offline-repo.sh --dry-run-plan +# (it must print PLAN READY before --check or a real build is attempted) +# +# Rules enforced by the tooling: lower-case package names, exactly one pin per +# package across all manifests, no inline comments, no whitespace around "=", +# no placeholder/replace/todo tokens. Adding or removing a package is a contract +# change: update manifests/requirements.tsv in the same edit or the plan fails. + +# These packages are NOT in the Ubuntu archive: they come from the reviewed Docker +# CE repository snapshot and must be supplied as local .deb files. containerd.io is +# the runtime bundled with Docker CE, not Ubuntu's "containerd" package. +# Installer module 10-docker.sh installs exactly this set in --offline mode. +docker-ce=UNPINNED +docker-ce-cli=UNPINNED +containerd.io=UNPINNED +docker-buildx-plugin=UNPINNED +docker-compose-plugin=UNPINNED diff --git a/provisioning/offline-repo/manifests/packages.lock.schema.tsv b/provisioning/offline-repo/manifests/packages.lock.schema.tsv new file mode 100644 index 0000000..6d26414 --- /dev/null +++ b/provisioning/offline-repo/manifests/packages.lock.schema.tsv @@ -0,0 +1,29 @@ +# Blueforce Field OS - SBOM / package lock schema for the offline APT bundle +# +# build-offline-repo.sh writes this layout into a built bundle as: +# /packages.lock.tsv +# scripts/install/blueforce-install.sh validates that file before any package +# module runs: it must exist, be non-empty, carry the header on the FIRST line +# byte for byte, and every row must hold a package, a version, an architecture, +# a 64-character lower-case sha256, and the pool file name of the .deb. +# +# Columns (TAB-separated, exactly five, header first): +# package lower-case Debian package name ^[a-z0-9][a-z0-9+.-]*$ +# version exact Debian version, epochs allowed +# architecture binary architecture e.g. amd64, all +# sha256 sha256sum of the .deb, 64 lower-case hex characters +# source file name of the .deb inside /pool/ +# +# LAB fill procedure: +# 1. review the .deb files and place them in one local pool directory +# 2. fill every UNPINNED version in manifests/*.txt from those .deb files +# 3. ./build-offline-repo.sh --packages /secure/reviewed-debs --output /srv/blueforce-apt +# 4. the released packages.lock.tsv is the build output; never hand-edit a +# released lock, and never hand-write a sha256 - the build recomputes it +# +# This template is documentation, not a bundle artifact: the header sits below the +# comments so the file can never be mistaken for a released packages.lock.tsv. +# +# ==== EXAMPLE ROWS - format illustration only, never a released artifact ==== +# <64 hex characters> __.deb +package version architecture sha256 source diff --git a/provisioning/offline-repo/manifests/remote-access.txt b/provisioning/offline-repo/manifests/remote-access.txt new file mode 100644 index 0000000..804c096 --- /dev/null +++ b/provisioning/offline-repo/manifests/remote-access.txt @@ -0,0 +1,35 @@ +# Blueforce Field OS - offline APT pin manifest: remote access and desktop +# +# Target release : Ubuntu 26.04 LTS, architecture amd64 (LAB confirms both) +# Format : one exact pin per line -> package=version ("#" starts a comment) +# Consumed by : scripts/install/blueforce-install.sh --offline, which refuses to +# run any package module unless every pin here is present in the +# bundle lock file /packages.lock.tsv. +# +# UNPINNED is a sentinel, not a version. Fill it in LAB: +# 1. acquire the reviewed .deb for the package into one local pool directory +# 2. read the exact version: dpkg-deb -f .deb Package Version +# 3. replace UNPINNED with that version, keeping the line shape: +# xrdp= +# 4. verify with the static plan: ../build-offline-repo.sh --dry-run-plan +# (it must print PLAN READY before --check or a real build is attempted) +# +# Rules enforced by the tooling: lower-case package names, exactly one pin per +# package across all manifests, no inline comments, no whitespace around "=", +# no placeholder/replace/todo tokens. Adding or removing a package is a contract +# change: update manifests/requirements.tsv in the same edit or the plan fails. + +# --- remote desktop (installer module 08-rdp.sh) --- +xrdp=UNPINNED +# X11 backend used by the GNOME session behind xRDP. xrdp recommends it; the LAB +# must confirm the dependency closure of the reviewed pool before the build. +xorgxrdp=UNPINNED +gnome-session=UNPINNED + +# --- full desktop stack (installer module 16-gui.sh) --- +ubuntu-desktop-minimal=UNPINNED + +# --- RustDesk: vendor .deb, absent from the Ubuntu archive --- +# Version is the reviewed self-hosted client build. Record where that .deb came +# from in the bundle lock row for the package. +rustdesk=UNPINNED diff --git a/provisioning/offline-repo/manifests/requirements.tsv b/provisioning/offline-repo/manifests/requirements.tsv new file mode 100644 index 0000000..c5036fe --- /dev/null +++ b/provisioning/offline-repo/manifests/requirements.tsv @@ -0,0 +1,34 @@ +# Manifest audit table - the contract behind manifests/*.txt +# +# Four TAB-separated columns, one row per required package: +# manifest package required-by why +# +# build-offline-repo.sh --dry-run-plan fails on drift in either direction: +# * a row whose package is not pinned in that manifest -> MISSING PACKAGES +# * a pin in a manifest with no row here -> PLAN ERROR (rogue pin) +# +# scripts/install/blueforce-install.sh never reads this file: it is the audit +# trail for humans and for the dry-run plan. Kernel modules and other artifacts +# that are not shipped as .deb files are out of scope. +base-packages.txt curl scripts/install/installer/modules/02-system.sh BASE_PKGS: HTTPS transfers for installer tooling +base-packages.txt wget scripts/install/installer/modules/02-system.sh BASE_PKGS: downloader expected by field runbooks +base-packages.txt ca-certificates scripts/install/installer/modules/02-system.sh BASE_PKGS: root store for TLS verification +base-packages.txt gnupg scripts/install/installer/modules/02-system.sh BASE_PKGS: signature handling for reviewed artifacts +base-packages.txt lsb-release scripts/install/installer/modules/02-system.sh BASE_PKGS: distribution identification +base-packages.txt apt-transport-https scripts/install/installer/modules/02-system.sh BASE_PKGS-adjacent: transitional dummy on apt >= 2.0, kept for parity +base-packages.txt openssh-server scripts/install/installer/modules/07-ssh.sh managed SSH access channel +base-packages.txt wireguard-tools scripts/install/installer/modules/06-wireguard.sh wg command for the WireGuard tunnel +base-packages.txt smartmontools scripts/diagnostics/bf-diagnostics SMART disk health evidence for field reports +base-packages.txt ufw scripts/install/installer/modules/13-firewall.sh host firewall baseline +base-packages.txt iptables scripts/install/installer/modules/13-firewall.sh rule backends used by the firewall module +base-packages.txt prometheus-node-exporter scripts/install/installer/modules/12-monitoring.sh node exporter including the textfile collector +remote-access.txt rustdesk scripts/install/installer/modules/09-rustdesk.sh pinned vendor .deb; not in the Ubuntu archive +remote-access.txt xrdp scripts/install/installer/modules/08-rdp.sh RDP access channel +remote-access.txt xorgxrdp scripts/install/installer/modules/08-rdp.sh X11 backend recommended by xrdp for the GNOME session +remote-access.txt gnome-session scripts/install/installer/modules/08-rdp.sh session started by xRDP logins +remote-access.txt ubuntu-desktop-minimal scripts/install/installer/modules/16-gui.sh minimal desktop stack for the field device +docker.txt docker-ce scripts/install/installer/modules/10-docker.sh container engine +docker.txt docker-ce-cli scripts/install/installer/modules/10-docker.sh docker CLI used by modules and diagnostics +docker.txt containerd.io scripts/install/installer/modules/10-docker.sh runtime bundled with Docker CE +docker.txt docker-buildx-plugin scripts/install/installer/modules/10-docker.sh buildx plugin requested by the offline install path +docker.txt docker-compose-plugin scripts/install/installer/modules/10-docker.sh compose v2 plugin for the field stack diff --git a/provisioning/offline-repo/packages/.gitkeep b/provisioning/offline-repo/packages/.gitkeep new file mode 100644 index 0000000..7501977 --- /dev/null +++ b/provisioning/offline-repo/packages/.gitkeep @@ -0,0 +1 @@ +# Package artifacts are intentionally excluded. Supply reviewed .deb files locally at build time. diff --git a/provisioning/release/README.md b/provisioning/release/README.md new file mode 100644 index 0000000..f19e489 --- /dev/null +++ b/provisioning/release/README.md @@ -0,0 +1,5 @@ +# Release metadata + +`manifest.yaml` iskelet sürüm sözleşmesidir. Gerçek ISO üretimi `iso/build-blueforce-iso.sh` tarafından ayrıca bir `release-manifest.yaml` ve `SHA256SUMS` dosyasıyla kayıt altına alınır. + +Yayın girdileri açıkça verilmelidir: doğrulanmış upstream ISO, onun SHA256 kaydı ve gözden geçirilmiş yerel içerikler. Bu repo paket veya ISO indirmez. `status: skeleton` olan bu dosya dağıtım onayı değildir. \ No newline at end of file diff --git a/provisioning/release/manifest.yaml b/provisioning/release/manifest.yaml new file mode 100644 index 0000000..8572a42 --- /dev/null +++ b/provisioning/release/manifest.yaml @@ -0,0 +1,9 @@ +schema_version: 1 +release: + name: blueforce-provisioning-skeleton + version: 0.1.0 + status: skeleton +artifacts: [] +notes: + - No final ISO is produced by this repository without explicit local input and LAB approval. + - Device identity and secrets must not be included in release artifacts. diff --git a/repomix-output.xml b/repomix-output.xml new file mode 100644 index 0000000..0eeb8ad --- /dev/null +++ b/repomix-output.xml @@ -0,0 +1,9473 @@ +This file is a merged representation of the entire codebase, combined into a single document by Repomix. + + +This section contains a summary of this file. + + +This file contains a packed representation of the entire repository's contents. +It is designed to be easily consumable by AI systems for analysis, code review, +or other automated processes. + + + +The content is organized as follows: +1. This summary section +2. Repository information +3. Directory structure +4. Repository files (if enabled) +5. Multiple file entries, each consisting of: + - File path as an attribute + - Full contents of the file + + + +- This file should be treated as read-only. Any changes should be made to the + original repository files, not this packed version. +- When processing this file, use the file path to distinguish + between different files in the repository. +- Be aware that this file may contain sensitive information. Handle it with + the same level of security as you would the original repository. + + + +- Some files may have been excluded based on .gitignore rules and Repomix's configuration +- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files +- Files matching patterns in .gitignore are excluded +- Files matching default ignore patterns are excluded +- Files are sorted by Git change count (files with more changes are at the bottom) + + + + + +ansible/ + inventory/ + group_vars/ + all.yml + .gitkeep + hosts.example.yml + playbooks/ + .gitkeep + bf-collect-logs.yml + bf-deploy-update.yml + bf-disk-check.yml + bf-docker-status.yml + bf-gui-off.yml + bf-gui-on.yml + bf-package-check.yml + bf-ping.yml + bf-rdp-restart.yml + bf-reboot.yml + bf-run-script.yml + bf-rustdesk-restart.yml + bf-security-updates-check.yml + bf-service-restart.yml + bf-uptime.yml + bf-wireguard-restart.yml + roles/ + common/ + defaults/ + main.yml + tasks/ + main.yml + README.md + docker/ + defaults/ + main.yml + tasks/ + main.yml + README.md + monitoring/ + defaults/ + main.yml + tasks/ + main.yml + README.md + wireguard/ + defaults/ + main.yml + handlers/ + main.yml + tasks/ + main.yml + README.md + .gitkeep +config/ + docker/ + .gitkeep + daemon.json + firewall/ + .gitkeep + blueforce-docker-user-restore.sh + ufw-blueforce.sh + ssh/ + sshd_config.d/ + blueforce.conf + .gitkeep + systemd/ + docker.service.d/ + blueforce-docker-firewall.conf + wg-quick@wg0.service.d/ + override.conf + .gitkeep + bf-gui-off.service + bf-gui-on.service + blueforce-docker-firewall.service + journald-blueforce.conf + logrotate-blueforce + wireguard/ + .gitkeep + README.md + wg0.conf.example +docs/ + _TEMPLATE.md + 00-MASTER-PLAN.md + 01-ARCHITECTURE.md + 02-DEVICE-NAMING-AND-INVENTORY.md + 03-UBUNTU-BASELINE.md + 04-GOLDEN-IMAGE-AND-PROVISIONING.md + 05-ONE-CLICK-INSTALLER.md + 06-USERS-SSH-AND-PERMISSIONS.md + 07-REMOTE-ACCESS.md + 08-WIREGUARD-AND-NETWORK.md + 09-FLEET-MANAGEMENT.md + 10-UPDATE-AND-ROLLBACK-POLICY.md + 11-SECURITY-HARDENING.md + 12-DOCKER-OPERATIONS.md + 13-MEG-LINUX-ACCEPTANCE.md + 14-MONITORING-AND-HEALTH.md + 15-LOGGING.md + 16-POWER-LOSS-AND-AUTO-RECOVERY.md + 17-BACKUP-RECOVERY-AND-REINSTALL.md + 18-PILOT-AND-700-DEVICE-ROLLOUT.md + 19-TROUBLESHOOTING.md + 20-FIELD-TECHNICIAN-RUNBOOK.md + 21-CENTRAL-ADMIN-RUNBOOK.md + 22-DOCUMENTATION-PLATFORM.md + 23-SECURITY-AND-LICENSE-AUDIT.md + 24-DECISION-LOG.md + 25-IMPLEMENTATION-ROADMAP.md +docs-site/ + docs/ + .gitkeep + src/ + css/ + custom.css + pages/ + index.tsx + .gitignore + .gitkeep + babel.config.js + docusaurus.config.ts + package.json + README.md + sidebars.js + sync-docs.sh + tsconfig.json +monitoring/ + grafana/ + dashboards/ + fleet-overview.json + provisioning/ + dashboards/ + dashboards.yml + datasources/ + prometheus.yml + prometheus/ + targets/ + fleet.json + README.md + alerts.yml + prometheus.yml + .gitkeep + docker-compose.yml + README.md +research/ + .gitkeep + A-os-gui-wireguard-docker.md + B-remote-access.md + C-fleet-monitoring-docs.md +rustdesk/ + .gitkeep + docker-compose.yml + README.md +scripts/ + diagnostics/ + .gitkeep + bf-diagnostics + bf-hardware-inventory.sh + bf-status + bf-support-bundle + install/ + installer/ + modules/ + 01-precheck.sh + 02-system.sh + 03-user.sh + 04-hostname.sh + 05-network.sh + 06-wireguard.sh + 07-ssh.sh + 08-rdp.sh + 09-rustdesk.sh + 10-docker.sh + 11-meg.sh + 12-monitoring.sh + 13-firewall.sh + 14-update-policy.sh + 15-logrotate.sh + 16-gui.sh + 17-healthcheck.sh + 18-final-check.sh + .gitkeep + blueforce-install.sh + README.md + maintenance/ + .gitkeep + bf-gui-off + bf-gui-on + recovery/ + .gitkeep +tests/ + .gitkeep + check-configs.sh + check-specs.sh + semaphore-community-limits.md +.gitignore +README.md +SUMMARY.md + + + +This section contains the contents of the repository's files. + + +# Shared variables for all Blueforce fleet hosts. +# Fill placeholders before first production run. +--- +# WireGuard subnet (placeholder - must match docs/08 hub topology). +wg_subnet: "10.42.0.0/16" +wg_interface: wg0 +wg_hub_endpoint: "vpn.example.com:51820" # PLACEHOLDER: real hub endpoint + +# Monitoring endpoint (placeholder - VictoriaMetrics/Prometheus push or pull target). +monitoring_endpoint: "http://monitoring.example.com:8428" # PLACEHOLDER +monitoring_job: blueforce + +# Update repository / mirror (placeholder - local mirror recommended for 700 devices). +update_repo_url: "http://mirror.example.com/ubuntu" # PLACEHOLDER +update_repo_suite: jammy +meg_image_tag: "1.0.0" # pinned tag, never 'latest' (docs/10, docs/12) + +# Operation policy defaults. Every fleet-changing play must bind to one approved wave. +operation_wave: "" +operation_confirmed: false +reboot_grace_seconds: 300 +support_bundle_dir: /var/lib/blueforce/support-bundles + + + +# Blueforce fleet inventory (EXAMPLE - copy to hosts.yml and fill real IDs). +# Naming: bf-<8-digit-dealer-no> per docs/02-DEVICE-NAMING-AND-INVENTORY.md. +# Targeting levels per docs/09-FLEET-MANAGEMENT.md: single host -> group -> pilot wave -> full fleet. +# Waves per docs/10-UPDATE-AND-ROLLBACK-POLICY.md: lab(2)/pilot_1(5)/pilot_2(20)/wave_1(50)/wave_2(100)/production. +all: + children: + blueforce: + children: + lab: + hosts: + bf-12010101: + ansible_host: 10.42.0.11 + bf-12010102: + ansible_host: 10.42.0.12 + pilot_1: + hosts: + bf-12010201: + ansible_host: 10.42.1.11 + bf-12010202: + ansible_host: 10.42.1.12 + bf-12010203: + ansible_host: 10.42.1.13 + bf-12010204: + ansible_host: 10.42.1.14 + bf-12010205: + ansible_host: 10.42.1.15 + pilot_2: + hosts: + bf-12010301: + ansible_host: 10.42.2.11 + bf-12010302: + ansible_host: 10.42.2.12 + wave_1: + hosts: + bf-12010401: + ansible_host: 10.42.3.11 + bf-12010402: + ansible_host: 10.42.3.12 + wave_2: + hosts: + bf-12010501: + ansible_host: 10.42.4.11 + bf-12010502: + ansible_host: 10.42.4.12 + production: + hosts: + bf-12010601: + ansible_host: 10.42.5.11 + bf-12010602: + ansible_host: 10.42.5.12 + vars: + ansible_user: blueforce + ansible_ssh_private_key_file: ~/.ssh/blueforce_id_ed25519 # PLACEHOLDER: set per-admin key path (see docs/06) + ansible_python_interpreter: /usr/bin/python3 + + + +--- +# Collect timestamped journald logs and a support snapshot from one approved wave. +- name: Collect support logs + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + collect_logs_dir: "{{ support_bundle_dir | default('/var/lib/blueforce/support-bundles') }}" + collect_log_lines: "{{ log_lines | default(500) }}" + fetch_logs: false + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require a safe support bundle directory + ansible.builtin.assert: + that: + - collect_logs_dir is match('^/var/lib/blueforce/support-bundles(?:/[A-Za-z0-9][A-Za-z0-9._-]*)*\Z') + fail_msg: "support_bundle_dir must be /var/lib/blueforce/support-bundles or an approved alphanumeric subpath; shell metacharacters are refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Generate unique bundle timestamp + ansible.builtin.command: date -u +%Y%m%dT%H%M%SZ + register: bundle_timestamp + changed_when: false + + - name: Ensure bundle directory exists + ansible.builtin.file: + path: "{{ collect_logs_dir }}" + state: directory + owner: root + group: root + mode: "0750" + + - name: Export recent journal to timestamped bundle + ansible.builtin.shell: >- + journalctl -n {{ collect_log_lines | int }} --no-pager > + {{ (collect_logs_dir ~ '/journal-' ~ inventory_hostname ~ '-' ~ bundle_timestamp.stdout ~ '.log') | quote }} + changed_when: true + + - name: Record timestamped kernel and disk snapshot + ansible.builtin.shell: >- + { uname -a; printf '%s\\n' '---'; df -h; printf '%s\\n' '---'; uptime; } > + {{ (collect_logs_dir ~ '/snapshot-' ~ inventory_hostname ~ '-' ~ bundle_timestamp.stdout ~ '.txt') | quote }} + changed_when: true + + - name: Fetch timestamped journal bundle to controller + ansible.builtin.fetch: + src: "{{ collect_logs_dir }}/journal-{{ inventory_hostname }}-{{ bundle_timestamp.stdout }}.log" + dest: "fetched-logs/{{ inventory_hostname }}/" + flat: true + when: fetch_logs | bool + + + +--- +# Approved package update with mandatory wave scope and per-batch health gates. +# Example: ansible-playbook -i inventory/hosts.yml playbooks/bf-deploy-update.yml -e release_wave=lab -e wave_gate_confirmed=true -e update_packages='["package=version"]' +- name: Deploy approved package update + hosts: "{{ release_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + serial: 5 + max_fail_percentage: 0 + any_errors_fatal: true + vars: + release_wave: "" + wave_gate_confirmed: false + production_override: false + halt_on_fail: true + update_packages: [] + update_cache_time: 3600 + approved_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + pre_tasks: + - name: Require a valid rollout wave + ansible.builtin.assert: + that: + - release_wave in approved_waves + - release_wave in groups + - groups[release_wave] | length > 0 + fail_msg: "release_wave must name a non-empty approved inventory group; refusing an ungated rollout." + run_once: true + + - name: Require exact or subset host selection for release wave + ansible.builtin.assert: + that: + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[release_wave]) | length == 0 + fail_msg: "Selected hosts must equal or be a subset of groups[release_wave]; broader fleet scope is refused." + run_once: true + + - name: Require central wave-gate confirmation + ansible.builtin.assert: + that: + - wave_gate_confirmed | bool + fail_msg: "Set wave_gate_confirmed=true only after the prior wave health gate and central approval pass." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - release_wave != 'production' or production_override | bool + fail_msg: "Production rollout requires production_override=true in addition to wave_gate_confirmed=true." + run_once: true + + - name: Require halt-on-failure protection + ansible.builtin.assert: + that: + - halt_on_fail | bool + fail_msg: "halt_on_fail must remain true for every rollout." + run_once: true + + - name: Require pinned package versions + ansible.builtin.assert: + that: + - update_packages | length > 0 + - update_packages | select('match', '^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$') | list | length == update_packages | length + fail_msg: "update_packages must be a non-empty list of exact package=version pins." + run_once: true + tasks: + - name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: "{{ update_cache_time }}" + + - name: Install approved pinned package versions + ansible.builtin.apt: + name: "{{ update_packages }}" + state: present + allow_downgrade: true + post_tasks: + - name: Run mandatory post-batch health gate + ansible.builtin.command: /usr/local/bin/bf-status + register: update_health + changed_when: false + failed_when: update_health.rc != 0 + + - name: Report passed post-batch health gate + ansible.builtin.debug: + msg: "Update batch health gate passed for {{ ansible_play_batch }}" + run_once: true + + + +--- +# Check disk usage (read-only) and warn above threshold. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-disk-check.yml --limit lab +# Threshold overridable: -e disk_warn_pct=80. +# No Semaphore Pro feature is used. +- name: Check disk usage + hosts: blueforce + gather_facts: false + vars: + disk_warning_percent: "{{ disk_warn_pct | default(80) }}" + tasks: + - name: Get disk usage + ansible.builtin.command: df -h --output=source,pcent,target + register: df_out + changed_when: false + + - name: Show disk usage + ansible.builtin.debug: + msg: "{{ inventory_hostname }}:\n{{ df_out.stdout }}" + + - name: Get root filesystem usage percentage + ansible.builtin.shell: "df --output=pcent / | tail -n 1 | tr -dc '0-9'" + register: root_pct + changed_when: false + + - name: Flag hosts above threshold + ansible.builtin.debug: + msg: "WARNING {{ inventory_hostname }} root disk {{ root_pct.stdout }}% exceeds {{ disk_warning_percent }}%" + when: root_pct.stdout | int >= (disk_warning_percent | int) + + + +--- +# Report Docker daemon and container status (read-only). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-docker-status.yml --limit lab +# No changes are made by this playbook. No Semaphore Pro feature is used. +- name: Check Docker status + hosts: blueforce + gather_facts: false + become: true + tasks: + - name: Check Docker service state + ansible.builtin.systemd: + name: docker + register: docker_svc + changed_when: false + + - name: List running containers + ansible.builtin.command: docker ps --format '{{{{.Names}}}} {{{{.Image}}}} {{{{.Status}}}}' + register: docker_ps + changed_when: false + failed_when: false + + - name: Show Docker status + ansible.builtin.debug: + msg: "{{ inventory_hostname }} docker={{ docker_svc.status.ActiveState | default('unknown') }} containers={{ docker_ps.stdout_lines | default([]) }}" + + + +--- +# Disable the GUI for a confirmed maintenance wave (kiosk/headless mode). +# Dry run: ansible-playbook -i inventory/hosts.yml playbooks/bf-gui-off.yml -e operation_wave=lab -e operation_confirmed=true --check +- name: Disable graphical target + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Read current default target + ansible.builtin.command: systemctl get-default + register: current_default_target + changed_when: false + + - name: Set default target to multi-user + ansible.builtin.command: systemctl set-default multi-user.target + when: current_default_target.stdout != 'multi-user.target' + + - name: Stop graphical target now + ansible.builtin.systemd: + name: graphical.target + state: stopped + + + +--- +# Enable the GUI for a confirmed maintenance wave without changing the boot default. +- name: Enable graphical maintenance session + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Start GUI and wait for RDP + ansible.builtin.command: /usr/local/bin/bf-gui-on + register: gui_on + changed_when: true + + - name: Verify terminal boot default remains configured + ansible.builtin.command: systemctl get-default + register: current_default_target + changed_when: false + failed_when: current_default_target.stdout != 'multi-user.target' + + + +--- +# Report installed versions of pinned packages (read-only). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-package-check.yml --limit lab +# Package list overridable: -e "check_packages=['docker-ce','wireguard']". +# No Semaphore Pro feature is used. +- name: Check installed package versions + hosts: blueforce + gather_facts: false + become: true + vars: + check_packages: + - docker-ce + - wireguard + - rustdesk + tasks: + - name: Query package versions + ansible.builtin.shell: "dpkg-query -W -f='${Package} ${Version}\n' {{ check_packages | join(' ') }}" + register: pkg_out + changed_when: false + failed_when: false + + - name: Show package versions + ansible.builtin.debug: + msg: "{{ inventory_hostname }}:\n{{ pkg_out.stdout | default('(query failed)') }}" + + + +--- +# Connectivity check for the Blueforce fleet. +# Targeting: single host -> group -> pilot wave -> full fleet (--limit). +# Dry run first: ansible-playbook -i inventory/hosts.yml playbooks/bf-ping.yml --limit lab --check +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-ping.yml --limit lab +# No changes are made by this playbook. No Semaphore Pro feature is used. +- name: Ping fleet hosts + hosts: blueforce + gather_facts: false + tasks: + - name: Check SSH connectivity + ansible.builtin.ping: + + + +--- +# Restart the RDP service (xrdp). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rdp-restart.yml --limit lab +# Service name is overridable via rdp_service_name. +# No Semaphore Pro feature is used. +- name: Restart RDP service + hosts: blueforce + gather_facts: false + become: true + vars: + rdp_restart_service: "{{ rdp_service_name | default('xrdp') }}" + tasks: + - name: Restart RDP + ansible.builtin.systemd: + name: "{{ rdp_restart_service }}" + state: restarted + + - name: Verify RDP is active + ansible.builtin.command: "systemctl is-active {{ rdp_restart_service }}" + register: rdp_state + changed_when: false + + - name: Show RDP state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} rdp={{ rdp_state.stdout }}" + + + +--- +# Controlled reboots require an approved wave and explicit confirmation. +- name: Controlled fleet reboot + hosts: blueforce + gather_facts: false + become: true + serial: 2 + max_fail_percentage: 0 + any_errors_fatal: true + vars: + release_wave: "" + reboot_confirmed: false + allow_production_reboot: false + reboot_delay_seconds: "{{ reboot_grace_seconds | default(300) }}" + pre_tasks: + - name: Require an approved non-production release wave + ansible.builtin.assert: + that: + - release_wave in ['lab', 'pilot_1', 'pilot_2', 'wave_1', 'wave_2', 'production'] + - reboot_confirmed | bool + - release_wave != 'production' or allow_production_reboot | bool + fail_msg: "Set approved release_wave and reboot_confirmed=true. Production additionally requires allow_production_reboot=true." + + - name: Refuse full-fleet scope without explicit production override + ansible.builtin.assert: + that: + - release_wave == 'production' or ansible_limit | default('') | regex_search('(^|[:,])' ~ release_wave ~ '($|[:,])') + fail_msg: "Limit this reboot to the approved release_wave group; full fleet is refused." + tasks: + - name: Reboot host with grace period + ansible.builtin.reboot: + reboot_timeout: 600 + pre_reboot_delay: "{{ reboot_delay_seconds }}" + msg: Blueforce scheduled reboot via Ansible + + - name: Verify host health after reboot batch + ansible.builtin.command: /usr/local/bin/bf-status --check + register: reboot_health + changed_when: false + failed_when: reboot_health.rc != 0 + + + +--- +# Run an approved script already present on a confirmed maintenance wave. +- name: Run operator script + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + script_path: "" + script_args: "" + script_allowed_dirs: + - /opt/blueforce/bin + - /usr/local/sbin + script_allowlist: [] + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Canonicalize requested script path + ansible.builtin.command: + argv: [readlink, -f, --, "{{ script_path }}"] + register: canonical_script + changed_when: false + failed_when: canonical_script.rc != 0 + + - name: Canonicalize approved directory prefixes + ansible.builtin.command: + argv: [readlink, -f, --, "{{ item }}"] + loop: "{{ script_allowed_dirs }}" + register: canonical_dirs + changed_when: false + + - name: Validate exact approved path boundary or explicit allowlist + ansible.builtin.assert: + that: + - canonical_script.stdout is match('^/.+') + - canonical_script.stdout in script_allowlist or (canonical_dirs.results | map(attribute='stdout') | select('match', '^/.+') | map('regex_replace', '$', '/') | select('in', canonical_script.stdout + '/') | list | length > 0) + fail_msg: "Refused root execution of '{{ script_path }}': canonical path '{{ canonical_script.stdout }}' is not in an approved directory or explicit allowlist." + + - name: Check canonical script is a regular executable file + ansible.builtin.stat: + path: "{{ canonical_script.stdout }}" + register: script_stat + + - name: Validate canonical script metadata + ansible.builtin.assert: + that: + - script_stat.stat.exists + - script_stat.stat.isreg + - script_stat.stat.executable + + - name: Execute approved canonical script + ansible.builtin.command: + argv: "{{ [canonical_script.stdout] + (script_args | default('') | split()) }}" + register: script_out + changed_when: true + + - name: Show script output + ansible.builtin.debug: + msg: "{{ inventory_hostname }} exit={{ script_out.rc }}\n{{ script_out.stdout | default('') }}{{ script_out.stderr | default('') }}" + + + +--- +# Restart the RustDesk remote-access service. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rustdesk-restart.yml --limit lab +# Service name is overridable via rustdesk_service_name. +# No Semaphore Pro feature is used. +- name: Restart RustDesk service + hosts: blueforce + gather_facts: false + become: true + vars: + rustdesk_restart_service: "{{ rustdesk_service_name | default('rustdesk') }}" + tasks: + - name: Restart RustDesk + ansible.builtin.systemd: + name: "{{ rustdesk_restart_service }}" + state: restarted + + - name: Verify RustDesk is active + ansible.builtin.command: "systemctl is-active {{ rustdesk_restart_service }}" + register: rustdesk_state + changed_when: false + + - name: Show RustDesk state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} rustdesk={{ rustdesk_state.stdout }}" + + + +--- +# Report pending security updates only. NEVER installs anything. +# Policy docs/10: unattended upgrades are disabled; every change needs an approval wave. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-security-updates-check.yml --limit lab +# No Semaphore Pro feature is used. +- name: Report pending security updates (no install) + hosts: blueforce + gather_facts: false + become: true + tasks: + - name: Refresh apt metadata (cache only, no upgrade) + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: List upgradable packages + ansible.builtin.shell: "apt list --upgradable 2>/dev/null" + register: upgradable + changed_when: false + + - name: List security updates only + ansible.builtin.shell: "apt-get -s dist-upgrade -V | grep -i security || true" + register: sec_updates + changed_when: false + + - name: Show report + ansible.builtin.debug: + msg: "{{ inventory_hostname }} upgradable:\n{{ upgradable.stdout }}\nsecurity:\n{{ sec_updates.stdout | default('(none)') }}" + + - name: Remind operator that install is out of scope + ansible.builtin.debug: + msg: "REPORT ONLY - use bf-deploy-update.yml with approval waves to install anything." + + + +--- +# Restart a single service by name (parametric, allowlisted). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-service-restart.yml --limit lab -e service_name=blueforce-agent +# Only names in service_allowlist are accepted; everything else fails closed. +# No Semaphore Pro feature is used. +- name: Restart allowlisted service + hosts: blueforce + gather_facts: false + become: true + vars: + service_name: "" + service_allowlist: + - blueforce-agent + - docker + - sshd + - ssh + - systemd-journald + - cron + tasks: + - name: Validate service_name is provided + ansible.builtin.assert: + that: + - service_name | length > 0 + fail_msg: "service_name is required, e.g. -e service_name=blueforce-agent" + + - name: Validate service_name is allowlisted + ansible.builtin.assert: + that: + - service_name in service_allowlist + fail_msg: "service_name '{{ service_name }}' is not in the allowlist. Refusing to restart arbitrary services." + + - name: Restart service + ansible.builtin.systemd: + name: "{{ service_name }}" + state: restarted + + + +--- +# Collect uptime from fleet hosts (read-only). +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-uptime.yml --limit pilot_1 +# No changes are made by this playbook. No Semaphore Pro feature is used. +- name: Report host uptime + hosts: blueforce + gather_facts: false + tasks: + - name: Get uptime + ansible.builtin.command: uptime + register: uptime_out + changed_when: false + + - name: Show uptime + ansible.builtin.debug: + msg: "{{ inventory_hostname }}: {{ uptime_out.stdout }}" + + + +--- +# Restart the WireGuard tunnel interface. +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-wireguard-restart.yml --limit lab +# Uses wg_interface from group_vars and defaults to wg0. +# No Semaphore Pro feature is used. +- name: Restart WireGuard tunnel + hosts: blueforce + gather_facts: false + become: true + vars: + wireguard_restart_interface: "{{ wg_interface | default('wg0') }}" + tasks: + - name: Restart WireGuard service + ansible.builtin.systemd: + name: "wg-quick@{{ wireguard_restart_interface }}" + state: restarted + + - name: Verify tunnel state + ansible.builtin.command: "wg show {{ wireguard_restart_interface }}" + register: wg_out + changed_when: false + + - name: Show WireGuard state + ansible.builtin.debug: + msg: "{{ inventory_hostname }}: {{ wg_out.stdout_lines | default([]) }}" + + + +--- +# Defaults for the common role. Override in group_vars/host_vars, never hardcode secrets here. +common_timezone: Europe/Istanbul +common_admin_user: blueforce + + + +--- +# Common baseline: users, time, unattended-upgrades OFF (docs/10), base packages. +# No Semaphore Pro feature is used. +- name: Ensure base packages are present + ansible.builtin.apt: + name: + - openssh-server + - python3 + - curl + - htop + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Disable automatic upgrades (policy docs/10) + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + content: | + APT::Periodic::Update-Package-Lists "0"; + APT::Periodic::Unattended-Upgrade "0"; + +- name: Mask apt-daily timers (policy docs/10) + ansible.builtin.systemd: + name: "{{ item }}" + masked: true + state: stopped + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + failed_when: false + +- name: Set timezone + ansible.builtin.timezone: + name: "{{ common_timezone }}" + + + +# common role + +Baseline for every Blueforce host: base packages, timezone, and the docs/10 +requirement that `unattended-upgrades` stays OFF with `apt-daily*.timer` masked. + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `common_timezone` | `Europe/Istanbul` | System timezone | +| `common_admin_user` | `blueforce` | Admin login (SSH key managed per docs/06) | + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - common +``` + + + +--- +# Docker is installed only when an operator supplies a verified repository and exact version. +# Supported Ubuntu releases include 24.04 noble and 26.04 resolute; no release is hard-coded. +docker_supported_codenames: + - noble + - resolute +docker_apt_codename: "{{ ansible_distribution_release | default('') }}" +docker_apt_repository: "" # Required: approved, already key-verified Docker APT repository. +docker_engine_version: "" # Required exact version, e.g. 5:29.0.0-1~ubuntu.26.04~resolute. +docker_packages: [] +meg_container_name: meg +meg_image_tag: 1.0.0 + + + +--- +# Docker engine setup with explicit, approved repository and version only. +- name: Refuse unsupported Ubuntu codename or unapproved Docker inputs + ansible.builtin.assert: + that: + - docker_apt_codename in docker_supported_codenames + - docker_apt_repository | length > 0 + - docker_engine_version | length > 0 + - docker_engine_version is not search('latest') + - docker_packages | length > 0 + - docker_packages | select('search', '(^|=).*latest($|[^[:alnum:]])') | list | length == 0 + fail_msg: >- + Docker installation is refused until this host has a supported codename and an explicitly approved + repository, exact engine version, and exact package list. No automatic Docker update is permitted. + +- name: Verify approved Docker repository is configured + ansible.builtin.command: "apt-cache policy docker-ce" + register: docker_policy + changed_when: false + failed_when: docker_apt_repository not in docker_policy.stdout + +- name: Verify requested Docker version is available from approved repository + ansible.builtin.command: "apt-cache madison docker-ce" + register: docker_versions + changed_when: false + failed_when: docker_engine_version not in docker_versions.stdout + +- name: Install only the approved exact Docker packages + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: false + +- name: Enable and start Docker daemon + ansible.builtin.systemd: + name: docker + enabled: true + state: started + +- name: Ensure MEG container is running at pinned tag + ansible.builtin.command: "docker ps --filter name={{ meg_container_name }} --format '{{{{.Names}}}}'" + register: meg_ps + changed_when: false + failed_when: false + when: meg_container_name | length > 0 + +- name: Report MEG container state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} meg={{ meg_ps.stdout | default('(skipped)') }}" + when: meg_container_name | length > 0 + + + +# docker role + +Installs the pinned Docker engine, enables the daemon, and reports the MEG +container state. Image and package tags are always pinned; `latest` and +Watchtower-style auto-updaters are forbidden (docs/10, docs/12). + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `docker_packages` | pinned `docker-ce=...` | Exact engine packages to install | +| `meg_container_name` | `meg` | Container name to report on | +| `meg_image_tag` | `1.0.0` | Pinned MEG image tag | + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - docker +``` + + + +--- +# Defaults for the monitoring role. +monitoring_packages: + - prometheus-node-exporter +monitoring_service: prometheus-node-exporter +monitoring_endpoint: http://monitoring.example.com:8428 +monitoring_job: blueforce + + + +--- +# Monitoring baseline: node exporter install + health signal files. +# No Semaphore Pro feature is used. +- name: Ensure monitoring packages are present + ansible.builtin.apt: + name: "{{ monitoring_packages }}" + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Enable and start node exporter + ansible.builtin.systemd: + name: "{{ monitoring_service }}" + enabled: true + state: started + +- name: Verify unattended-upgrades stay disabled (policy docs/10) + ansible.builtin.shell: 'grep -E ''Unattended-Upgrade "0"'' /etc/apt/apt.conf.d/20auto-upgrades' + register: unattended_check + changed_when: false + failed_when: false + +- name: Report monitoring state + ansible.builtin.debug: + msg: "{{ inventory_hostname }} exporter={{ monitoring_service }} unattended_off={{ unattended_check.rc == 0 }}" + + + +# monitoring role + +Installs and starts the node exporter and verifies the docs/10 requirement +that automatic upgrades stay disabled. + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `monitoring_packages` | `prometheus-node-exporter` | Packages to install | +| `monitoring_service` | `prometheus-node-exporter` | Service to enable | +| `monitoring_endpoint` | `http://monitoring.example.com:8428` | Metrics endpoint (placeholder) | +| `monitoring_job` | `blueforce` | Job label | + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - monitoring +``` + + + +--- +# Defaults for the wireguard role. Secrets MUST be overridden via Key Store / vault. +wg_interface: wg0 +wg_address: "" +wg_dns: "" +wg_private_key: "" +wg_hub_public_key: "" +wg_hub_endpoint: "" +wg_allowed_ips: "10.42.0.0/16" +wg_keepalive: 25 + + + +--- +# Handlers for the wireguard role. +- name: Restart WireGuard + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + state: restarted + + + +--- +# WireGuard client setup: package, config from template variables, tunnel up. +# Private keys are supplied through the Semaphore Community Key Store, never committed. +# No Semaphore Pro feature is used. +- name: Validate required WireGuard configuration + ansible.builtin.assert: + that: + - wg_address | length > 0 + - wg_private_key | length > 0 + - wg_hub_public_key | length > 0 + - wg_hub_endpoint | length > 0 + - wg_allowed_ips | length > 0 + fail_msg: WireGuard address, keys, hub endpoint, and allowed IPs are required. + no_log: true + +- name: Ensure WireGuard package is present + ansible.builtin.apt: + name: wireguard + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Write WireGuard client config + ansible.builtin.copy: + dest: "/etc/wireguard/{{ wg_interface }}.conf" + owner: root + group: root + mode: "0600" + content: | + [Interface] + PrivateKey = {{ wg_private_key }} + Address = {{ wg_address }} + {% if wg_dns | default('') | length > 0 %} + DNS = {{ wg_dns }} + {% endif %} + [Peer] + PublicKey = {{ wg_hub_public_key }} + Endpoint = {{ wg_hub_endpoint }} + AllowedIPs = {{ wg_allowed_ips }} + PersistentKeepalive = {{ wg_keepalive }} + no_log: true + notify: Restart WireGuard + +- name: Enable and start WireGuard tunnel + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + enabled: true + state: started + + + +# wireguard role + +Installs WireGuard, writes `/etc/wireguard/.conf` from variables, and +starts `wg-quick@`. Private keys are secrets: supply them through the +Semaphore Community encrypted Key Store or another controller-side secret +source; never commit them to Git. + +## Variables + +| Name | Default | Purpose | +|---|---|---| +| `wg_interface` | `wg0` | Interface name | +| `wg_address` | _(empty)_ | Required client tunnel address, e.g. `10.42.3.11/16` | +| `wg_dns` | _(empty)_ | Optional DNS | +| `wg_private_key` | _(empty)_ | Required client private key (secret) | +| `wg_hub_public_key` | _(empty)_ | Required hub public key | +| `wg_hub_endpoint` | `vpn.example.com:51820` | Required hub endpoint | +| `wg_allowed_ips` | `10.42.0.0/16` | Required allowed IPs | +| `wg_keepalive` | `25` | Persistent keepalive seconds | + +The role fails closed when any required tunnel value is empty. + +## Usage + +```yaml +- hosts: blueforce + become: true + roles: + - role: wireguard + vars: + wg_address: 10.42.3.11/16 +``` + + + +{ + "_note": [ + "Purpose: Cap Docker log disk use and keep containers alive across daemon restarts.", + "Docker manages firewall rules; the installer applies the mandatory DOCKER-USER deny policy.", + "Published application ports require explicit reviewed rules in /etc/blueforce/docker-published-port-allowlist.conf." + ], + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "live-restore": true, + "iptables": true +} + + + +#!/usr/bin/env bash +# Apply Blueforce's reviewed Docker published-port policy to DOCKER-USER. +set -euo pipefail + +ALLOWLIST="${DOCKER_ALLOWLIST:-/etc/blueforce/docker-published-port-allowlist.conf}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 + +fail() { printf 'blueforce-docker-firewall: %s\n' "$*" >&2; exit 1; } +parse_allowlist() { + local callback="$1" raw line protocol port extra + [[ -r "$ALLOWLIST" ]] || fail "allowlist is missing or unreadable: $ALLOWLIST" + while IFS= read -r raw || [[ -n "$raw" ]]; do + line="${raw%%#*}" + line="$(printf '%s' "$line" | xargs)" + [[ -z "$line" ]] && continue + read -r protocol port extra <<< "$line" + [[ -z "${extra:-}" && "$protocol" =~ ^(tcp|udp)$ && "$port" =~ ^[0-9]{1,5}$ ]] || fail "invalid allowlist entry '$raw'; use 'tcp PORT' or 'udp PORT'" + ((10#$port >= 1 && 10#$port <= 65535)) || fail "port out of range in '$raw'" + "$callback" "$protocol" "$port" + done < "$ALLOWLIST" +} + +validate_allowlist() { parse_allowlist :; } +install_allow_rule() { + local protocol="$1" port="$2" + # Docker DNAT precedes DOCKER-USER; match the original reviewed published port. + iptables -I DOCKER-USER -p "$protocol" -m conntrack --ctorigdstport "$port" -j ACCEPT +} +verify_allow_rule() { + local protocol="$1" port="$2" + iptables -C DOCKER-USER -p "$protocol" -m conntrack --ctorigdstport "$port" -j ACCEPT || fail "allowlist rule missing: $protocol $port" +} + +validate_allowlist +command -v iptables >/dev/null 2>&1 || fail "iptables is required" +iptables -S DOCKER-USER >/dev/null 2>&1 || fail "DOCKER-USER chain is unavailable; start Docker first" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + iptables -C DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT || fail "established/related rule missing" + iptables -C DOCKER-USER -i docker0 -j RETURN || fail "docker0 outbound return rule missing" + iptables -C DOCKER-USER -i br+ -j RETURN || fail "user-defined bridge outbound return rule missing" + parse_allowlist verify_allow_rule + iptables -C DOCKER-USER -j DROP || fail "default published-port deny rule missing" + exit 0 +fi + +# Blueforce owns DOCKER-USER so an old permissive rule cannot bypass the deny. +iptables -F DOCKER-USER +iptables -A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +# Container-to-Internet packets enter FORWARD from Docker bridges; inbound published-port +# packets enter from an external interface, so these returns do not bypass the deny below. +iptables -A DOCKER-USER -i docker0 -j RETURN +iptables -A DOCKER-USER -i br+ -j RETURN +# Insert every parsed allowlist exception before the final default drop. +parse_allowlist install_allow_rule +iptables -A DOCKER-USER -j DROP + + + +#!/usr/bin/env bash +# Apply field-device UFW baseline. SSH/RDP are accepted only on wg0. +set -euo pipefail +WG_IF="wg0" +WG_PORT="51820" +DOCKER_ALLOWLIST="/etc/blueforce/docker-published-port-allowlist.conf" +DOCKER_POLICY_BIN="/usr/local/sbin/blueforce-docker-firewall" +DOCKER_POLICY_UNIT="blueforce-docker-firewall.service" +rule_exists() { ufw status | grep -Fq "$1"; } +ensure_rule() { local match="$1"; shift; rule_exists "$match" && echo "keep: $match" || ufw "$@"; } + +install_docker_policy() { + local root + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + install -d -m 755 /etc/blueforce /etc/iptables + if [[ ! -f "$DOCKER_ALLOWLIST" ]]; then + printf '%s\n' '# Explicit reviewed Docker published-port exceptions: tcp PORT or udp PORT.' '# Default: deny every externally published Docker port.' > "$DOCKER_ALLOWLIST" + fi + chown root:root "$DOCKER_ALLOWLIST" + chmod 640 "$DOCKER_ALLOWLIST" + install -o root -g root -m 700 "$root/config/firewall/blueforce-docker-user-restore.sh" "$DOCKER_POLICY_BIN" + install -o root -g root -m 644 "$root/config/systemd/blueforce-docker-firewall.service" "/etc/systemd/system/$DOCKER_POLICY_UNIT" + systemctl daemon-reload + systemctl enable "$DOCKER_POLICY_UNIT" + systemctl start docker + "$DOCKER_POLICY_BIN" +} +cmd_apply() { + ufw default deny incoming + ufw default allow outgoing + ensure_rule "${WG_PORT}/udp" allow "${WG_PORT}/udp" comment 'WireGuard hub' + ensure_rule "22/tcp on ${WG_IF}" allow in on "${WG_IF}" to any port 22 proto tcp comment 'SSH over WireGuard' + ensure_rule "3389/tcp on ${WG_IF}" allow in on "${WG_IF}" to any port 3389 proto tcp comment 'RDP over WireGuard' + install_docker_policy + ufw --force enable + ufw status verbose +} +cmd_reset() { ufw --force reset; ufw default deny incoming; ufw default allow outgoing; ufw --force enable; ufw status verbose; } +cmd_status() { ufw status verbose; "$DOCKER_POLICY_BIN" --check; iptables -S DOCKER-USER; } +case "${1:-apply}" in apply) cmd_apply ;; reset) cmd_reset ;; status) cmd_status ;; *) echo "Usage: $0 {apply|reset|status}" >&2; exit 1 ;; esac + + + +# Purpose: Harden field-device SSH to key-only maintenance access. +# Scope: Applies to the blueforce user; root and password logins stay disabled. +# Safety: Drop-in for sshd_config.d; validate with sshd -t before reload. +# Ref: docs/06-USERS-SSH-AND-PERMISSIONS.md, K-13. + +# No direct root login; privilege is gained only via audited sudo. +PermitRootLogin no + +# Key-only auth: passwords and interactive challenges are disabled. +PasswordAuthentication no +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes + +# Only the maintenance user may open SSH sessions. +AllowUsers blueforce + +# Reduce idle-session and brute-force exposure. +X11Forwarding no +MaxAuthTries 3 +LoginGraceTime 60 +ClientAliveInterval 300 +ClientAliveCountMax 2 + + + +# Start the firewall after every Docker start; the firewall unit orders itself after Docker. +[Unit] +Wants=blueforce-docker-firewall.service + + + +# Purpose: Keep the WireGuard tunnel up across failures without manual action. +# Scope: Drop-in for wg-quick@wg0; restarts the tunnel with a short backoff. +# Safety: Reboot-safe once the unit is enabled; confirm on the LAB 26.04.1 image. +# Ref: docs/08-WIREGUARD-AND-NETWORK.md and docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md, K-05. +[Service] +Restart=always +RestartSec=5 + + + +# Purpose: Manually return a field device to terminal mode after maintenance. +# This unit is deliberately not enabled by any boot target. +[Unit] +Description=Blueforce GUI off (manual terminal maintenance switch) +After=graphical.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bf-gui-off +RemainAfterExit=no + + + +# Purpose: Manually switch a field device to graphical mode for maintenance. +# This unit is deliberately not enabled by any boot target. +[Unit] +Description=Blueforce GUI on (manual graphical maintenance switch) +After=multi-user.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bf-gui-on +RemainAfterExit=no + + + +[Unit] +Description=Blueforce persistent Docker published-port firewall +BindsTo=docker.service +PartOf=docker.service +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/blueforce-docker-firewall +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target + + + +# Purpose: Bound journald disk use on field devices while keeping history. +# Scope: Persistent storage with caps; install to /etc/systemd/journald.conf.d/. +# Safety: Reboot-safe; apply with systemctl restart systemd-journald. +# Ref: docs/15-LOGGING.md, K-14. +[Journal] +Storage=persistent +SystemMaxUse=500M +RuntimeMaxUse=100M +MaxFileSec=14day + + + +# Purpose: Rotate MEG and Blueforce application logs to cap disk use. +# Scope: /var/log/blueforce and /var/log/meg file logs; install to /etc/logrotate.d/. +# Safety: Idempotent under logrotate; validate with logrotate -d before deploying. +# Ref: docs/15-LOGGING.md, K-14. +/var/log/blueforce/*.log /var/log/meg/*.log { + weekly + rotate 7 + size 10M + maxage 30 + compress + delaycompress + missingok + notifempty + create 0640 root adm + sharedscripts + postrotate + /usr/bin/systemctl kill --signal=SIGHUP rsyslog.service >/dev/null 2>&1 || true + endscript +} +# NOTE: install.log and update-history.log stay append-only and are excluded here. + + + + + + +# WireGuard spoke reference + +Ref: `docs/08-WIREGUARD-AND-NETWORK.md` (K-05), naming `K-12`. + +## Layout + +- Hub-spoke: the central server is the hub (`10.8.0.1`), each field device is a spoke. +- Peer name equals the device identity: `bf-<8digits>` (hostname, inventory, monitoring label). +- Each spoke gets one fixed tunnel IP (`10.8.0.x/32`) from inventory. + +## Filling the example + +1. Copy `wg0.conf.example` to `/etc/wireguard/wg0.conf` (mode `600`). +2. Replace `Address` with the device tunnel IP from inventory. +3. Replace `` with the device private key. +4. Replace `` and `` with hub values. +5. Keep `PersistentKeepalive = 25` (official reasonable default for NAT peers). +6. Keep `PostUp`/`PostDown`: they scope SSH (22) and RDP (3389) rules to `wg0`. + +## Bring-up + +```bash +sudo install -m 600 wg0.conf /etc/wireguard/wg0.conf +sudo systemctl enable --now wg-quick@wg0 +sudo wg show wg0 latest-handshakes +``` + +## Notes + +- Spokes behind CGNAT must always initiate outward; the hub only listens on `51820/udp`. +- Placeholders marked `` must never hold real keys in this repo. + + + +# Purpose: Reference spoke config for field devices joining the hub. +# Scope: Peer name bf- with fixed tunnel IP; hub values come from inventory. +# Safety: Example only; replace every placeholder before installing to /etc/wireguard/. +# Ref: docs/08-WIREGUARD-AND-NETWORK.md, K-05; peer naming K-12. + +[Interface] +# Tunnel address from inventory (one fixed /32 per device, hub is 10.8.0.1). +Address = 10.8.0.15/32 +# Device private key. with the real key; never commit it. +PrivateKey = +# Open management ports on the tunnel interface when it comes up. +PostUp = ufw allow in on wg0 to any port 22 proto tcp; ufw allow in on wg0 to any port 3389 proto tcp +# Remove tunnel-scoped management rules when the tunnel goes down. +PostDown = ufw delete allow in on wg0 to any port 22 proto tcp; ufw delete allow in on wg0 to any port 3389 proto tcp + +[Peer] +# Hub public key. with the real hub key. +PublicKey = +# Hub endpoint. with the real hub address. +Endpoint = :51820 +# Return traffic is limited to the tunnel subnet. +AllowedIPs = 10.8.0.0/16 +# Keep the NAT/CGNAT mapping alive behind mobile networks. +PersistentKeepalive = 25 + + + +Generated by ../sync-docs.sh; do not edit by hand. + + + +/* Blueforce docs theme overrides. Dark mode is the default (see docusaurus.config.ts). */ +:root { + --ifm-color-primary: #4aa8ff; +} + + + +import React from 'react'; +import Link from '@docusaurus/Link'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Layout from '@theme/Layout'; + +export default function Home() { + const {siteConfig} = useDocusaurusContext(); + return ( + +
+

{siteConfig.title}

+

{siteConfig.tagline}

+ + Open docs + +
+
+ ); +} +
+ + +node_modules/ +build/ +.docusaurus/ +# Generated content (recreated by sync-docs.sh on prestart/prebuild). +docs/*.md +!docs/.gitkeep + + + +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; + + + +import {themes as prismThemes} from 'prism-react-renderer'; +import type {Config} from '@docusaurus/types'; +import type * as Preset from '@docusaurus/preset-classic'; + +// Blueforce fleet documentation site. Content source: ../docs/*.md. +const config: Config = { + title: 'Blueforce Docs', + tagline: 'Fleet runbooks and architecture', + url: 'http://docs.blueforce.intranet', + baseUrl: '/', + onBrokenLinks: 'throw', + i18n: { defaultLocale: 'tr', locales: ['tr'] }, + markdown: { + mermaid: true, + hooks: { onBrokenMarkdownLinks: 'warn' }, + }, + themes: ['@docusaurus/theme-mermaid', '@easyops-cn/docusaurus-search-local'], + presets: [ + ['classic', { + docs: { path: 'docs', routeBasePath: '/', sidebarPath: './sidebars.js' }, + blog: false, + theme: { customCss: './src/css/custom.css' }, + } satisfies Preset.Options], + ], + themeConfig: { + colorMode: { defaultMode: 'dark', disableSwitch: false, respectPrefersColorScheme: false }, + navbar: { title: 'Blueforce Docs', items: [{ type: 'docSidebar', sidebarId: 'docs', position: 'left', label: 'Docs' }] }, + } satisfies Preset.ThemeConfig, +}; +export default config; + + + +{ + "name": "blueforce-docs", + "version": "0.1.0", + "private": true, + "description": "Blueforce fleet documentation site. Source of truth is ../docs/*.md; this package only builds the site.", + "scripts": { + "sync": "./sync-docs.sh", + "prestart": "./sync-docs.sh", + "prebuild": "./sync-docs.sh", + "start": "docusaurus start --host 127.0.0.1 --port 3000", + "start:intranet": "docusaurus start --host 0.0.0.0 --port 3000", + "build": "docusaurus build", + "serve": "docusaurus serve build --host 127.0.0.1 --port 3000", + "serve:intranet": "docusaurus serve build --host 0.0.0.0 --port 3000", + "clear": "docusaurus clear" + }, + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-mermaid": "3.10.2", + "@easyops-cn/docusaurus-search-local": "0.49.2", + "@mermaid-js/layout-elk": "0.1.9", + "@mdx-js/react": "3.0.1", + "clsx": "2.1.1", + "prism-react-renderer": "2.4.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { "typescript": "5.5.4" }, + "engines": { "node": ">=18.0" } +} + + + +# Blueforce docs site (Docusaurus) + +Static documentation is synced one-way from `../docs/*.md` into `./docs/`; never edit generated `./docs/` directly. + +## Local development + +```bash +cd docs-site +npm install +npm start # http://127.0.0.1:3000 (loopback only) +npm run build +npm run serve # local static preview on 127.0.0.1:3000 +``` + +For an explicitly approved intranet preview, use `npm run start:intranet` or `npm run serve:intranet`; those commands bind `0.0.0.0:3000`. + +## Deploy + +Publish `build/` through the intranet web server. The site has no favicon reference until a maintained favicon asset is added. + + + +// Auto-generated sidebar: every synced Markdown file appears without manual entries. +// @ts-check + +/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +const sidebars = { + docs: [{type: 'autogenerated', dirName: '.'}], +}; + +export default sidebars; + + + +#!/usr/bin/env bash +# One-way sync: repo docs (../docs/*.md) -> site content (./docs/). +# The repo Markdown files are the single source of truth; never edit ./docs by hand. +set -euo pipefail + +SITE_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC_DIR="$SITE_DIR/../docs" +DST_DIR="$SITE_DIR/docs" + +mkdir -p "$DST_DIR" + +# Copy current sources. +cp -f "$SRC_DIR"/*.md "$DST_DIR"/ + +# Remove stale files that no longer exist upstream (keep .gitkeep). +for f in "$DST_DIR"/*.md; do + base="$(basename "$f")" + if [ ! -f "$SRC_DIR/$base" ]; then + rm -f "$f" + fi +done + +# MDX compatibility: a bare "<" followed by a letter/digit in prose is parsed +# as JSX and breaks the build (e.g. "<15 dk"). Escape it as "<" ONLY +# outside fenced code blocks and inline code spans, where "<" is safe +# (mermaid "
", placeholders like `BF-`). Rendered text is unchanged. +python3 - "$DST_DIR" <<'EOF' +import re, sys, glob, os +dst = sys.argv[1] +pat = re.compile(r'<(?=[A-Za-z0-9])') +for f in glob.glob(os.path.join(dst, '*.md')): + out, in_fence = [], False + for line in open(f, encoding='utf-8'): + if line.startswith('```'): + in_fence = not in_fence + out.append(line) + continue + if in_fence: + out.append(line) + continue + # Split inline code spans; escape only the prose parts. + parts = re.split(r'(`[^`]*`)', line) + for i in range(0, len(parts), 2): + parts[i] = pat.sub('<', parts[i]) + out.append(''.join(parts)) + open(f, 'w', encoding='utf-8').write(''.join(out)) +EOF + +echo "Synced $(ls "$SRC_DIR"/*.md | wc -l) files into docs-site/docs." +
+ + +{ + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": "." + } +} + + + +{"annotations": {"list": []}, "editable": false, "graphTooltip": 0, "panels": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Devices answering scrapes (up==1) vs silent (up==0).", "fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"text": "OFFLINE"}, "1": {"text": "ONLINE"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": 0}, {"color": "green", "value": 1}]}}, "overrides": []}, "gridPos": {"h": 4, "w": 8, "x": 0, "y": 0}, "id": 1, "options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "up{job=\"node\",device_id=\"$device\"}", "refId": "A"}], "title": "Online / Offline", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "100 - available% across reporting devices.", "fieldConfig": {"defaults": {"max": 100, "min": 0, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 70}, {"color": "red", "value": 90}]}, "unit": "percent"}, "overrides": []}, "gridPos": {"h": 4, "w": 8, "x": 8, "y": 0}, "id": 2, "options": {"colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "100 * (1 - node_memory_MemAvailable_bytes{job=\"node\",device_id=\"$device\"} / node_memory_MemTotal_bytes{job=\"node\",device_id=\"$device\"})", "refId": "A"}], "title": "RAM used %", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Average CPU over 5m from node_cpu_seconds_total.", "fieldConfig": {"defaults": {"max": 100, "min": 0, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 70}, {"color": "red", "value": 90}]}, "unit": "percent"}, "overrides": []}, "gridPos": {"h": 4, "w": 8, "x": 16, "y": 0}, "id": 3, "options": {"colorMode": "value", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "100 * (1 - avg(rate(node_cpu_seconds_total{job=\"node\",device_id=\"$device\",mode=\"idle\"}[5m])) / count(rate(node_cpu_seconds_total{job=\"node\",device_id=\"$device\",mode=\"idle\"}[5m])))", "refId": "A"}], "title": "CPU used %", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Root filesystem usage %.", "fieldConfig": {"defaults": {"max": 100, "min": 0, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 75}, {"color": "red", "value": 90}]}, "unit": "percent"}, "overrides": []}, "gridPos": {"h": 6, "w": 12, "x": 0, "y": 4}, "id": 4, "options": {"colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "100 * (1 - node_filesystem_avail_bytes{job=\"node\",device_id=\"$device\",mountpoint=\"/\"} / node_filesystem_size_bytes{job=\"node\",device_id=\"$device\",mountpoint=\"/\"})", "refId": "A"}], "title": "Disk / used %", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Seconds since the last WireGuard handshake; rising values mean a stale tunnel. Source: textfile metric wireguard_last_handshake_seconds.", "fieldConfig": {"defaults": {"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": 0}, {"color": "yellow", "value": 180}, {"color": "red", "value": 600}]}, "unit": "s"}, "overrides": []}, "gridPos": {"h": 6, "w": 12, "x": 12, "y": 4}, "id": 5, "options": {"colorMode": "value", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"}, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "time() - wireguard_last_handshake_seconds{job=\"node\",device_id=\"$device\"}", "refId": "A"}], "title": "WireGuard handshake age", "type": "stat"}, {"datasource": {"type": "prometheus", "uid": "prometheus"}, "description": "Running containers (1) vs stopped (0), including MEG. Source: textfile metric docker_container_running.", "fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"text": "STOPPED"}, "1": {"text": "RUNNING"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": 0}, {"color": "green", "value": 1}]}}, "overrides": []}, "gridPos": {"h": 6, "w": 24, "x": 0, "y": 10}, "id": 6, "targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "docker_container_running{job=\"node\",device_id=\"$device\"}", "legendFormat": "{{name}}", "refId": "A"}], "title": "Docker containers", "type": "timeseries"}], "refresh": "30s", "schemaVersion": 39, "tags": ["blueforce", "fleet"], "templating": {"list": [{"current": {}, "datasource": {"type": "prometheus", "uid": "prometheus"}, "definition": "label_values(up{job=\"node\"}, device_id)", "includeAll": false, "multi": false, "name": "device", "options": [], "query": {"query": "label_values(up{job=\"node\"}, device_id)", "refId": "PrometheusVariableQueryEditor-VariableQuery"}, "refresh": 1, "regex": "", "type": "query"}]}, "time": {"from": "now-6h", "to": "now"}, "timepicker": {}, "timezone": "", "title": "Blueforce Fleet Overview", "uid": "bf-fleet-overview", "version": 1} + + + +# Auto-provision the fleet dashboard from disk (no manual import). +apiVersion: 1 + +providers: + - name: blueforce-fleet + orgId: 1 + folder: Blueforce + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /etc/grafana/dashboards + + + +# Auto-provisioned Prometheus datasource for Grafana OSS. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + + + +[ + { + "targets": ["10.7.0.11"], + "labels": { + "device_id": "BF-12010193", + "site": "pilot", + "job": "node" + } + } +] + + + +# File-based service discovery for the 700-device fleet. +# +# Prometheus watches this directory (refresh 30s); adding or removing a +# `*.json` file takes effect without a reload or restart. +# +# Per-device entry template (WireGuard address as target, identity label +# contract `device_id="BF-"` on every target): +# +# [ +# { +# "targets": ["10.7.0.11"], +# "labels": {"device_id": "BF-12010193", "site": "pilot", "job": "node"} +# } +# ] +# +# Scale-out: generate one file per site/region (e.g. `site-ankara.json`) +# from the Ansible inventory; keep each file under a few thousand targets. + + + +# Fleet alert rules. Severity: critical pages the on-call admin (14-MONITORING). +# Notification endpoints (SMS/e-mail/webhook) are wired in the central +# Grafana Alerting / Alertmanager setup, not in this file. +# All expressions assume 60s scrape interval. + +groups: + - name: fleet + interval: 60s + rules: + # Device does not answer scrapes for 5 minutes -> presumed offline. + - alert: FleetOffline + expr: up{job="node"} == 0 + for: 5m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} is offline" + description: "No successful scrape for 5m (instance {{ $labels.instance }}). Check Uptime Kuma last-seen and field power/network." + + # Root filesystem less than 10% available for 10 minutes. + - alert: FleetDiskFull + expr: | + ( + node_filesystem_avail_bytes{job="node", mountpoint="/", fstype!~"tmpfs|overlay"} + / + node_filesystem_size_bytes{job="node", mountpoint="/", fstype!~"tmpfs|overlay"} + ) < 0.10 + for: 10m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} disk almost full" + description: "Root filesystem <10% available for 10m on {{ $labels.instance }}." + + # Boot time changed recently -> unexpected reboot (power loss or crash). + - alert: FleetUnexpectedReboot + expr: time() - node_boot_time_seconds{job="node"} < 15 * 60 + for: 5m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} rebooted unexpectedly" + description: "{{ $labels.instance }} booted less than 15m ago. Correlate with power-loss recovery (16-POWER-LOSS)." + + # unattended-upgrades must stay DISABLED (K-11); 1 means the lock was broken. + # Source: textfile metric unattended_upgrade_active. + - alert: FleetSecurityUpdatesViolation + expr: unattended_upgrade_active{job="node"} == 1 + for: 5m + labels: + severity: critical + annotations: + summary: "Device {{ $labels.device_id }} has unattended-upgrades enabled" + description: "Update policy lock (K-11) violated on {{ $labels.instance }}. Disable unattended-upgrades per 10-UPDATE." + + + +# Prometheus configuration for the Blueforce fleet. +# Scrape interval 60s keeps cardinality/storage tractable at 700 nodes. +# Fleet targets scale via file-based service discovery: add/remove entries +# in targets/fleet.json without reloading Prometheus (refresh_interval 30s). + +global: + scrape_interval: 60s + scrape_timeout: 10s + evaluation_interval: 60s + external_labels: + fleet: blueforce + +rule_files: + - alerts.yml + +scrape_configs: + # Prometheus self-monitoring. + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + # Field devices: Node Exporter (:9100) + textfile collector metrics. + # Identity label contract: device_id="BF-" on every target. + - job_name: node + file_sd_configs: + - files: ["/etc/prometheus/targets/*.json"] + refresh_interval: 30s + relabel_configs: + - source_labels: [__address__] + target_label: __address__ + regex: (.+) + replacement: ${1}:9100 + - source_labels: [device_id] + target_label: device_id + regex: (.+) + replacement: ${1} + metric_relabel_configs: + # Enforce the device_id label contract; drop series without it. + - source_labels: [device_id] + regex: .+ + action: keep + + + +# Central monitoring stack for the Blueforce fleet. +# Grafana requires GF_SECURITY_ADMIN_PASSWORD__FILE on first startup; no default credential exists. +services: + prometheus: + image: prom/prometheus:v3.1.0 + container_name: bf-prometheus + restart: unless-stopped + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + - ./prometheus/targets:/etc/prometheus/targets:ro + - prometheus-data:/prometheus + command: [--config.file=/etc/prometheus/prometheus.yml, --storage.tsdb.path=/prometheus, --storage.tsdb.retention.time=90d, --web.enable-lifecycle] + ports: ["127.0.0.1:9090:9090"] + networks: [monitoring] + grafana: + image: grafana/grafana-oss:11 + container_name: bf-grafana + restart: unless-stopped + depends_on: [prometheus] + environment: + GF_SECURITY_ADMIN_USER: "${GF_SECURITY_ADMIN_USER:-admin}" + GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password + secrets: [grafana_admin_password] + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + ports: ["127.0.0.1:3001:3000"] + networks: [monitoring] + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: bf-uptime-kuma + restart: unless-stopped + volumes: [uptime-kuma-data:/app/data] + ports: ["127.0.0.1:3002:3001"] + networks: [monitoring] +secrets: + grafana_admin_password: + file: "${GRAFANA_ADMIN_PASSWORD_FILE:?Set GRAFANA_ADMIN_PASSWORD_FILE to a root-owned password file}" +volumes: + prometheus-data: + grafana-data: + uptime-kuma-data: +networks: + monitoring: + driver: bridge + + + +# Central monitoring stack (Prometheus + Grafana OSS + Uptime Kuma) + +## Start + +Create a root-owned password file whose contents are the Grafana first-admin password (no newline is preferred), then start with its absolute path: + +```bash +install -m 600 -o root -g root /dev/null /etc/blueforce/grafana-admin-password +# Write the generated password directly to /etc/blueforce/grafana-admin-password. +cd monitoring +GRAFANA_ADMIN_PASSWORD_FILE=/etc/blueforce/grafana-admin-password docker compose up -d +``` + +`GRAFANA_ADMIN_PASSWORD_FILE` is mandatory. Compose mounts that host file as a Docker secret; it is not stored in the repository or printed in the compose environment. The process owner must keep the file root-owned and mode `0600`. Grafana is available at `http://127.0.0.1:3001`; Prometheus at `:9090`; Uptime Kuma at `:3002`. + +## Fleet targets + +Field devices are scraped via file-based service discovery in `prometheus/targets/*.json` (see `prometheus/targets/README.md`). + +## Alerts + +Validate changes with `promtool check rules prometheus/alerts.yml` and `promtool check config prometheus/prometheus.yml`. + + + +# RustDesk self-hosted server (hbbs + hbbr) on the central VDS. +# OSS edition (AGPL-3.0), unlimited clients, no license cost. +# Docs: https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ +# +# Port contract (VDS firewall must allow these from field-device internet): +# 21115/TCP hbbs NAT-type test +# 21116/TCP hbbs ID registration / heartbeat +# 21116/UDP hbbs TCP hole punching +# 21117/TCP hbbr relay traffic +# Optional, keep CLOSED unless the web client is used: +# 21118/TCP hbbs web client support +# 21119/TCP hbbr web client support +# Pro-only (not used here): +# 21114/TCP Pro web console +# +# network_mode: host is REQUIRED so hbbs/hbbr see the real client source +# IPs (rendezvous + relay decisions break behind Docker NAT). + +services: + hbbs: + image: rustdesk/rustdesk-server:1.1.16 + container_name: bf-hbbs + network_mode: host + restart: unless-stopped + volumes: + - ./data:/root + command: hbbs + + hbbr: + image: rustdesk/rustdesk-server:1.1.16 + container_name: bf-hbbr + network_mode: host + restart: unless-stopped + volumes: + - ./data:/root + command: hbbr + + + +# RustDesk Self-Hosted Server (hbbs + hbbr) + +OSS rendezvous + relay server for the Blueforce fleet. Field clients reach +the VDS public address directly over the internet, independent of WireGuard: +if the tunnel drops, this channel stays up (requires field internet + VDS up). + +## Prerequisites + +- A central VDS with a public DNS name (example: `rustdesk.example.com`). + Use your real DNS name; the value below is a placeholder. +- TCP/UDP ports 21115-21117 reachable from the internet (see port table in + `docker-compose.yml`). Keep 21118/21119 closed unless the web client is used. +- Docker Engine + Docker Compose plugin on the VDS. + +## Setup + +```bash +cd rustdesk +docker compose up -d +# First start generates the keypair in ./data (id_ed25519 + id_ed25519.pub). +cat data/id_ed25519.pub # <-- public key; embed in field clients +docker logs bf-hbbs # verify hbbs listening +docker logs bf-hbbr # verify hbbr listening +``` + +## Key distribution (field clients) + +1. Read the public key: `cat data/id_ed25519.pub` (single line, no newline issues). +2. Bake into the golden image / deployment script together with the server address: + `rustdesk.example.com` (replace with the real VDS DNS name). +3. Client config keys: `host = rustdesk.example.com`, `key = `. +4. Tag each client with its device identity `BF-` so the RustDesk address + book matches the Ansible inventory and MeshCentral names. +5. Key rotation: deploy the new key alongside the old one, confirm clients + connect, then retire the old key. + +## Health monitoring + +Point Uptime Kuma (see `../monitoring`) at: + +- TCP 21116 (hbbs heartbeat) +- TCP 21117 (hbbr relay) + +## Notes + +- `network_mode: host` is intentional; do not add port mappings. +- Data directory `./data` holds the keypair; back it up (see 17-BACKUP-RECOVERY). +- Never commit the private key (`data/id_ed25519`) to Git. + + + +#!/usr/bin/env bash +# +# bf-diagnostics - Deep health check for a Blueforce field terminal. +# +# Detailed version of bf-status: SMART, journal errors, docker ps, +# WireGuard state, unattended-upgrades lock, sockets, inodes, textfiles. +# Never prints secrets, keys, or passwords (handshake keys are redacted). +# +# Install: install -m 0755 bf-diagnostics /usr/local/bin/bf-diagnostics +# Usage: bf-diagnostics [--help] +# +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bf-diagnostics [--help] + +Run deep checks: SMART, journal errors, docker ps, wg show, +unattended-upgrades lock state, sockets, inodes, textfile collectors. + +Options: + --help Show this help and exit +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +elif [[ $# -gt 0 ]]; then + echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 +fi + +set +e # report, don't abort + +sec() { printf '\n### %s\n' "$*"; } + +sec "HOST" +hostname; uptime; systemctl get-default 2>/dev/null || echo "target: unknown" + +sec "SMART" +if command -v smartctl >/dev/null 2>&1; then + for dev in /dev/sd? /dev/nvme?n1; do + [[ -b "${dev}" ]] || continue + echo "--- ${dev} ---" + smartctl -H -A "${dev}" 2>/dev/null | grep -Ei 'health|reallocated|pending|uncorrect|wear|temperature|power_on' || echo "no SMART attributes parsed" + done +else + echo "smartctl not installed; skipping SMART." +fi + +sec "JOURNAL ERRORS (last 24h, top 20)" +if command -v journalctl >/dev/null 2>&1; then + journalctl --since "24 hours ago" -p err --no-pager -o short 2>/dev/null | tail -20 || echo "no journal access" +else + echo "journalctl not available." +fi + +sec "FAILED SYSTEMD UNITS" +if command -v systemctl >/dev/null 2>&1; then + systemctl --failed --no-pager 2>/dev/null || echo "no failed units" +else + echo "systemctl not available." +fi + +sec "DOCKER PS" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null || echo "docker ps failed" + echo "--- restart counts ---" + docker ps --format '{{.Names}} restarts={{.RestartCount}}' 2>/dev/null || true +else + echo "docker daemon not reachable." +fi + +sec "WIREGUARD (keys redacted)" +if command -v wg >/dev/null 2>&1; then + wg show 2>/dev/null | sed -E 's/(private-key|public-key|preshared-key): .*/\1: /' || echo "no WireGuard interfaces" + echo "--- latest handshakes ---" + wg show all latest-handshakes 2>/dev/null || echo "no handshake data" +else + echo "wg tool not installed." +fi + +sec "UNATTENDED-UPGRADES LOCK (must be DISABLED)" +grep -H . /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null || echo "20auto-upgrades file missing" +for t in apt-daily.timer apt-daily-upgrade.timer; do + printf '%s: %s\n' "$t" "$(systemctl is-enabled "$t" 2>/dev/null || echo unknown)" +done + +sec "SOCKETS (listening)" +ss -ltnp 2>/dev/null || ss -ltn 2>/dev/null || echo "ss not available" + +sec "DISK + INODES" +df -h / /var 2>/dev/null +df -i / /var 2>/dev/null | head -5 + +sec "TEXTFILE COLLECTORS" +ls -l /var/lib/node_exporter/textfile/ 2>/dev/null || echo "textfile dir missing" +for f in /var/lib/node_exporter/textfile/*.prom; do + [[ -f "$f" ]] || continue + echo "--- $f ---"; head -10 "$f" +done + +sec "SECURITY UPDATES PENDING" +if command -v apt >/dev/null 2>&1; then + apt list --upgradable 2>/dev/null | grep -ci security || echo "0 security updates (or apt list unavailable)" +else + echo "apt not available." +fi + +sec "REBOOT REQUIRED" +if [[ -f /var/run/reboot-required ]]; then echo "YES"; cat /var/run/reboot-required.pkgs 2>/dev/null || true +else echo "NO"; fi + +echo +echo "Diagnostics complete. For remote triage run: bf-support-bundle" +exit 0 + + + +#!/usr/bin/env bash +# +# bf-hardware-inventory.sh - Scan field-terminal hardware and emit JSON + Markdown. +# +# Covers every field in docs/02-DEVICE-NAMING-AND-INVENTORY.md §9: +# Manufacturer/Model/Serial/BIOS/CPU/RAM/Disk/SMART/NIC/MAC/GPU/USB/ +# SerialPorts/Kernel/Arch/TPM/Virt/Temps. Missing probes report +# "unknown" instead of aborting. Never prints secrets, keys, or passwords. +# +# Install: install -m 0755 bf-hardware-inventory.sh /usr/local/bin/bf-hardware-inventory.sh +# Usage: bf-hardware-inventory.sh [--dealer-id ID] [--out-dir DIR] [--json] [--markdown] [--help] +# +set -euo pipefail + +DEALER_ID="" +OUT_DIR="" +MODE="both" # json | markdown | both + +usage() { + cat <<'EOF' +Usage: bf-hardware-inventory.sh [--dealer-id ID] [--out-dir DIR] [--json] [--markdown] [--help] + +Scan hardware and emit inventory (JSON for machines, Markdown for humans). + +Options: + --dealer-id ID 8-digit dealer number (validates ^[0-9]{8}$ when given) + --out-dir DIR Write BF-.json and BF-.md into DIR + (requires --dealer-id; default filenames use hostname) + --json Print only JSON to stdout + --markdown Print only Markdown to stdout + --help Show this help and exit + +Fields (doc 02 §9): Manufacturer, Model, Serial, BIOS, CPU, RAM, +Disk, SMART, NIC, MAC, GPU, USB, SerialPorts, Kernel, Arch, TPM, +Virt, Temps. Unavailable probes report "unknown". +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dealer-id) DEALER_ID="${2:?missing value for --dealer-id}"; shift 2 ;; + --out-dir) OUT_DIR="${2:?missing value for --out-dir}"; shift 2 ;; + --json) MODE="json"; shift ;; + --markdown) MODE="markdown"; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ -n "${DEALER_ID}" ]] && ! [[ "${DEALER_ID}" =~ ^[0-9]{8}$ ]]; then + echo "ERROR: --dealer-id must match ^[0-9]{8}$ (got: ${DEALER_ID})" >&2 + exit 2 +fi +if [[ -n "${OUT_DIR}" && -z "${DEALER_ID}" ]]; then + echo "ERROR: --out-dir requires --dealer-id" >&2 + exit 2 +fi + +set +e # probes must not abort the report + +HOST="$(hostname 2>/dev/null || echo unknown)" +if [[ -n "${DEALER_ID}" ]]; then + DEVICE_ID="BF-${DEALER_ID}" + LOWER_HOST="bf-${DEALER_ID}" +else + DEVICE_ID="$(echo "${HOST}" | tr '[:lower:]' '[:upper:]')" + LOWER_HOST="$(echo "${HOST}" | tr '[:upper:]' '[:lower:]')" +fi +NOW="$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)" + +json_escape() { # stdin -> JSON string body + python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || sed 's/"/\\"/g' | tr -d '\n' +} +val() { # $1 raw -> "unknown" fallback, no newlines + local v="$1" + v="$(echo "${v}" | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -z "${v}" ]] && echo "unknown" || echo "${v}" +} + +# --- Probes (all fields from doc 02) --- +MANUFACTURER="$(val "$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || dmidecode -s system-manufacturer 2>/dev/null)")" +MODEL="$(val "$(cat /sys/class/dmi/id/product_name 2>/dev/null || dmidecode -s system-product-name 2>/dev/null)")" +SERIAL="$(val "$(cat /sys/class/dmi/id/product_serial 2>/dev/null || dmidecode -s system-serial-number 2>/dev/null)")" +BIOS_VENDOR="$(val "$(cat /sys/class/dmi/id/bios_vendor 2>/dev/null || dmidecode -s bios-vendor 2>/dev/null)")" +BIOS_VERSION="$(val "$(cat /sys/class/dmi/id/bios_version 2>/dev/null || dmidecode -s bios-version 2>/dev/null)")" +BIOS_DATE="$(val "$(cat /sys/class/dmi/id/bios_date 2>/dev/null || dmidecode -s bios-release-date 2>/dev/null)")" +CPU_MODEL="$(val "$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo 2>/dev/null || lscpu 2>/dev/null | awk -F': *' '/Model name/ {print $2; exit}')")" +CPU_CORES="$(val "$(nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null)")" +ARCH="$(val "$(uname -m 2>/dev/null)")" +KERNEL="$(val "$(uname -sr 2>/dev/null)")" +MEM_KB="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null)" +MEMORY_MB="$([ -n "${MEM_KB}" ] && echo $((MEM_KB / 1024)) || echo "unknown")" +MEMORY_STR="$([ "${MEMORY_MB}" != "unknown" ] && echo "${MEMORY_MB} MB" || echo "unknown")" + +DISKS="$(lsblk -d -n -o NAME,SIZE,TYPE,ROTA 2>/dev/null || echo unknown)" +GPU="$(val "$(lspci 2>/dev/null | grep -iE 'vga|3d|display' | head -3 | sed 's/^[^:]*: //')")" +NIC_LIST="$(val "$(ls /sys/class/net 2>/dev/null | grep -v '^lo$' | tr '\n' ',' | sed 's/,$//')")" +MAC_LIST="$(val "$(for n in /sys/class/net/*; do [[ "$(basename "$n")" == lo ]] && continue; cat "$n/address" 2>/dev/null; done | tr '\n' ',' | sed 's/,$//')")" +USB_LIST="$(val "$(lsusb 2>/dev/null | head -20 || echo '')")" +SERIAL_PORTS="$(val "$(ls /dev/ttyS* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null | tr '\n' ',' | sed 's/,$//')")" +TPM="$(val "$( ([ -c /dev/tpm0 ] && echo -n 'present (/dev/tpm0)') ; dmesg 2>/dev/null | grep -im1 'tpm' | head -1 | sed 's/^/ /')")" +VIRT="$(val "$(systemd-detect-virt 2>/dev/null || echo none)")" +TEMPS="$(val "$( (cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | head -5 | tr '\n' ' '); sensors 2>/dev/null | grep -iE 'core|temp|package' | head -5 | tr '\n' ';')")" + +SMART_SUMMARY="unknown" +if command -v smartctl >/dev/null 2>&1; then + SMART_SUMMARY="$(for dev in /dev/sd? /dev/nvme?n1; do + [[ -b "${dev}" ]] || continue + echo -n "${dev}: " + smartctl -H "${dev}" 2>/dev/null | grep -im1 'health\|result' | sed 's/^[[:space:]]*//' | head -1 + echo -n '; ' + done)" + SMART_SUMMARY="$(val "${SMART_SUMMARY}")" +else + SMART_SUMMARY="smartctl not installed" +fi + +OS_PRETTY="$(val "$(grep -E '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"')")" + +E() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || printf '%s' "$1"; } + +JSON="$(cat < "${OUT_DIR}/BF-${DEALER_ID}.json" + printf '%s\n' "${MD}" > "${OUT_DIR}/BF-${DEALER_ID}.md" + echo "wrote ${OUT_DIR}/BF-${DEALER_ID}.json ${OUT_DIR}/BF-${DEALER_ID}.md" + else + printf '%s\n\n%s\n' "${JSON}" "${MD}" + fi + ;; +esac + + + +#!/usr/bin/env bash +# +# bf-status - Quick 16-line health glance for a Blueforce field terminal. +# +# Output contract: docs/14-MONITORING-AND-HEALTH.md ("bf-status ornek ciktisi"). +# Never prints secrets, keys, or passwords. +# +# Install: install -m 0755 bf-status /usr/local/bin/bf-status +# Usage: bf-status [--help] +# +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bf-status [--help] + +Print the 16-line BLUEFORCE FIELD TERMINAL health summary. + +Options: + --help Show this help and exit + +Exit status is always 0; individual failing probes report DOWN/UNKNOWN +inline instead of aborting the report. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +elif [[ $# -gt 0 ]]; then + echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 +fi + +# Never abort the report on a failing probe. +set +e + +HOST="$(hostname 2>/dev/null || echo unknown)" +DEVICE_ID="$(echo "${HOST}" | tr '[:lower:]' '[:upper:]')" +NOW="$(date '+%Y-%m-%d %H:%M %z' 2>/dev/null || echo unknown)" +OVERALL="OK" + +uptime_str() { + uptime -p 2>/dev/null | sed 's/^up //' || echo unknown +} +load_str() { + awk '{print $1, $2, $3}' /proc/loadavg 2>/dev/null || echo "unknown" +} +cpu_pct() { + # 1-second sample from /proc/stat. + read -r _ a b c d e f g h i j < /proc/stat 2>/dev/null || { echo "?"; return; } + idle1=$((d + e)); total1=$((a+b+c+d+e+f+g+h+i+j)) + sleep 1 + read -r _ a b c d e f g h i j < /proc/stat 2>/dev/null || { echo "?"; return; } + idle2=$((d + e)); total2=$((a+b+c+d+e+f+g+h+i+j)) + dt=$((total2 - total1)); di=$((idle2 - idle1)) + if [[ "${dt}" -le 0 ]]; then echo "?"; else echo $(( (100 * (dt - di)) / dt )); fi +} +mem_str() { + local avail total + avail="$(awk '/MemAvailable/ {print $2}' /proc/meminfo 2>/dev/null)" + total="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null)" + if [[ -n "${avail}" && -n "${total}" && "${total}" -gt 0 ]]; then + local used=$((total - avail)) + local pct=$((100 * used / total)) + printf '%s/%sG (%s%%)' \ + "$(awk -v v="${used}" 'BEGIN{printf "%.1f", v/1048576}')" \ + "$(awk -v v="${total}" 'BEGIN{printf "%.1f", v/1048576}')" \ + "${pct}" + else + echo "unknown" + fi +} +disk_str() { + df -BG / 2>/dev/null | awk 'NR==2 {gsub(/G/,"",$2); gsub(/G/,"",$3); gsub(/%/,"",$5); printf "%s/%sG (%s%%)", $3, $2, $5}' || echo "unknown" +} +svc_state() { # $1 = unit + if ! command -v systemctl >/dev/null 2>&1; then echo "UNKNOWN"; return; fi + if systemctl is-active --quiet "$1" 2>/dev/null; then echo "OK"; return; fi + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$1"; then echo "DOWN"; else echo "OFF"; fi +} +wg_info() { + if ! command -v wg >/dev/null 2>&1; then echo "wg0 MISSING (no wg tool)"; return; fi + if ! ip link show wg0 >/dev/null 2>&1; then echo "wg0 DOWN (no interface)"; return; fi + local hs + hs="$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)" + if [[ -z "${hs}" || "${hs}" == "0" ]]; then echo "wg0 UP, no handshake yet"; return; fi + local now ago + now="$(date +%s)"; ago=$((now - hs)) + echo "wg0 UP, handshake ${ago}s ago" +} +net_counters() { + local rx=0 tx=0 f + for f in /sys/class/net/*/statistics/rx_bytes; do rx=$((rx + $(cat "$f" 2>/dev/null || echo 0))); done + for f in /sys/class/net/*/statistics/tx_bytes; do tx=$((tx + $(cat "$f" 2>/dev/null || echo 0))); done + echo "rx $(numfmt --to=iec "$rx" 2>/dev/null || echo "$rx") tx $(numfmt --to=iec "$tx" 2>/dev/null || echo "$tx")" +} +docker_info() { + if ! command -v docker >/dev/null 2>&1; then echo "daemon MISSING"; return; fi + if docker info >/dev/null 2>&1; then echo "daemon UP"; else echo "daemon DOWN"; fi +} +meg_info() { + # MEG container name convention: meg-* ; report first match. + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then echo "meg:unknown (docker down)"; return; fi + local line + line="$(docker ps --format '{{.Names}} {{.Status}} {{.RestartCount}}' 2>/dev/null | grep -i '^meg' | head -1)" + if [[ -z "${line}" ]]; then echo "meg NOT RUNNING"; return; fi + echo "${line}" +} +meg_last_success() { + local f="/var/lib/blueforce/meg_last_success" + if [[ -f "${f}" ]]; then + local ts now + ts="$(cat "${f}" 2>/dev/null | tr -d '[:space:]')" + now="$(date +%s)" + if [[ "${ts}" =~ ^[0-9]+$ ]]; then echo "UP, last success $((now - ts))s ago"; else echo "UP, last success unknown"; fi + else + echo "unknown (no timestamp file)" + fi +} +unattended_state() { + local masked="no" cfg="unknown" + if systemctl is-enabled apt-daily.timer 2>/dev/null | grep -q masked; then masked="yes"; fi + if grep -Eq '"0"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; then cfg="0"; fi + if [[ "${masked}" == "yes" && "${cfg}" == "0" ]]; then echo "DISABLED (timer masked)"; else echo "CHECK (timer/cfg drift)"; fi +} +ufw_str() { + if ! command -v ufw >/dev/null 2>&1; then echo "ufw not installed"; return; fi + if ufw status 2>/dev/null | head -1 | grep -qi active; then echo "active, default DENY in | 22/3389 wg0-only"; else echo "INACTIVE"; fi +} +gui_str() { + local target sessions="0" + target="$(systemctl get-default 2>/dev/null || echo unknown)" + if ss -ltn 2>/dev/null | grep -q ':3389 '; then sessions="$(ss -tn state established '( dport = :3389 or sport = :3389 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"; fi + if [[ "${target}" == "graphical.target" ]]; then echo "ON (graphical) | rdp sessions: ${sessions}"; else echo "OFF (multi-user) | rdp sessions: ${sessions}"; fi +} +kuma_str() { + local f="/var/lib/blueforce/kuma_last_push" + if [[ -f "${f}" ]]; then + local ts now + ts="$(cat "${f}" 2>/dev/null | tr -d '[:space:]')" + now="$(date +%s)" + if [[ "${ts}" =~ ^[0-9]+$ ]]; then echo "OK $((now - ts))s ago"; else echo "unknown"; fi + else + echo "unknown (no push record)" + fi +} +scrape_str() { + local f="/var/lib/node_exporter/textfile/last_scrape" + if [[ -f "${f}" ]]; then echo "OK ($(cat "${f}" 2>/dev/null | tr -d '[:space:]'))"; else echo "unknown"; fi +} +rustdesk_str() { + if pgrep -x rustdesk >/dev/null 2>&1 || systemctl is-active --quiet rustdesk 2>/dev/null; then echo "online"; else echo "offline"; fi +} +mesh_str() { + if pgrep -i meshcentral >/dev/null 2>&1 || systemctl is-active --quiet meshagent 2>/dev/null; then echo "online"; else echo "offline"; fi +} +boot_str() { + local b + b="$(who -b 2>/dev/null | awk '{print $3, $4}' || echo unknown)" + echo "${b} (power-loss recovery OK)" +} +alerts_str() { + # Warning heuristic: disk use > 80% counts one warning. + local pct + pct="$(df / 2>/dev/null | awk 'NR==2 {gsub(/%/,"",$5); print $5}')" + if [[ -n "${pct}" && "${pct}" -ge 90 ]] 2>/dev/null; then echo "1 critical, 0 warning (disk ${pct}%)"; + elif [[ -n "${pct}" && "${pct}" -ge 80 ]] 2>/dev/null; then echo "0 critical, 1 warning (disk trend)"; + else echo "0 critical, 0 warning"; fi +} + +DOCKER_SVC="$(svc_state docker)" +WG_SVC="$(svc_state "wg-quick@wg0")" +XRDP_SVC="$(svc_state xrdp)" +NODE_SVC="$(svc_state node_exporter)" + +printf '%s %s %s\n' "${DEVICE_ID}" "${NOW}" "${OVERALL}" +printf 'uptime: %s | load: %s\n' "$(uptime_str)" "$(load_str)" +printf 'cpu: %s%% | mem: %s | disk /: %s\n' "$(cpu_pct)" "$(mem_str)" "$(disk_str)" +printf 'net: %s | %s\n' "$(wg_info)" "$(net_counters)" +printf 'docker: %s | %s\n' "$(docker_info)" "$(meg_info)" +printf 'meg: %s\n' "$(meg_last_success)" +printf 'systemd: docker %s | wg-quick@wg0 %s | xrdp %s | node_exporter %s\n' "${DOCKER_SVC}" "${WG_SVC}" "${XRDP_SVC}" "${NODE_SVC}" +printf 'unattended-upgrades: %s\n' "$(unattended_state)" +printf 'ufw: %s\n' "$(ufw_str)" +printf 'gui: %s\n' "$(gui_str)" +printf 'kuma push: %s | prometheus scrape: %s\n' "$(kuma_str)" "$(scrape_str)" +printf 'rustdesk: %s | meshcentral: %s\n' "$(rustdesk_str)" "$(mesh_str)" +printf 'boot: %s\n' "$(boot_str)" +printf 'alerts: %s\n' "$(alerts_str)" +printf 'next: support bundle: bf-support-bundle\n' + + + +#!/usr/bin/env bash +# +# bf-support-bundle - Collect a secret-free support tarball for a Blueforce field terminal. +# +# Gathers bf-status + bf-diagnostics output, journal tail, textfile collectors +# and basic system facts into a tar.gz. Files matching secret patterns +# (*.key, *secret*, password material, private keys) are NEVER included, +# and key-like values in captured command output are redacted. +# A manifest.txt inside the bundle lists every file + collection time. +# +# Output contract: docs/14-MONITORING-AND-HEALTH.md ("bf-support-bundle ... +# secret ... ASLA pakete girmez"). +# +# Install: install -m 0755 bf-support-bundle /usr/local/bin/bf-support-bundle +# Usage: bf-support-bundle [--out-dir DIR] [--help] +# +set -euo pipefail + +OUT_DIR="/var/tmp" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: bf-support-bundle [--out-dir DIR] [--help] + +Collect a secret-free support bundle (tar.gz with manifest.txt). + +Options: + --out-dir DIR Directory for the tarball (default: /var/tmp) + --help Show this help and exit + +Excluded (never packaged): + *.key, *secret*, *password*, *passwd*, *private*, + *id_rsa*, *id_ed25519*, *.pem, WireGuard private keys, + RustDesk passwords, SSH private keys. + +Manifest: + manifest.txt inside the tarball lists each file, byte size, + collection timestamp, and the exclusion patterns applied. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --out-dir) OUT_DIR="${2:?missing value for --out-dir}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +HOST="$(hostname 2>/dev/null || echo unknown)" +STAMP="$(date '+%Y%m%d-%H%M%S' 2>/dev/null || echo unknown)" +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT + +BUNDLE_NAME="blueforce-support-${HOST}-${STAMP}" +STAGE="${WORK}/${BUNDLE_NAME}" +mkdir -p "${STAGE}" + +log() { echo "[bf-support-bundle] $*" >&2; } + +# Exclude patterns: filename-level, applied to every copy step and tar. +EXCLUDE_PATTERNS=( + '*.key' '*secret*' '*password*' '*passwd*' '*private*' + '*id_rsa*' '*id_ed25519*' '*.pem' '*.p12' '*.pfx' +) +is_excluded() { # $1 = basename + local base="$1" pat + for pat in "${EXCLUDE_PATTERNS[@]}"; do + # shellcheck disable=SC2053 + [[ "${base}" == ${pat} ]] && return 0 + done + return 1 +} + +redact() { # stdin -> stdout: mask key-like values + sed -E \ + -e 's/(private-key|public-key|preshared-key)([[:space:]]*[:=][[:space:]]*).*/\1\2/I' \ + -e 's/(password|passwd|secret|token)([[:space:]]*[:=][[:space:]]*)[^[:space:]]+/\1\2/I' +} + +collect_cmd() { # $1 = outfile, $@ = command + local out="$1"; shift + { "$@" 2>&1 || echo "(command failed: $*)"; } | redact > "${out}" || true +} + +log "collecting into ${STAGE}" + +collect_cmd "${STAGE}/bf-status.txt" "${SCRIPT_DIR}/bf-status" || true +if [[ -x "${SCRIPT_DIR}/bf-diagnostics" ]]; then + collect_cmd "${STAGE}/bf-diagnostics.txt" "${SCRIPT_DIR}/bf-diagnostics" || true +fi +collect_cmd "${STAGE}/system.txt" bash -c 'hostname; uptime; uname -a; systemctl get-default 2>/dev/null || echo "target: unknown"' +collect_cmd "${STAGE}/disk-df.txt" bash -c 'df -h / /var 2>/dev/null; echo ---; df -i / /var 2>/dev/null' +collect_cmd "${STAGE}/sockets.txt" bash -c 'ss -ltnp 2>/dev/null || ss -ltn 2>/dev/null || echo "ss not available"' +collect_cmd "${STAGE}/failed-units.txt" bash -c 'systemctl --failed --no-pager 2>/dev/null || echo "no failed units"' + +if command -v journalctl >/dev/null 2>&1; then + journalctl --since "24 hours ago" -p err --no-pager -o short 2>/dev/null | redact | tail -200 > "${STAGE}/journal-err-24h.txt" || echo "no journal access" > "${STAGE}/journal-err-24h.txt" +else + echo "journalctl not available." > "${STAGE}/journal-err-24h.txt" +fi + +if command -v wg >/dev/null 2>&1; then + { wg show 2>/dev/null | redact || echo "no WireGuard interfaces"; echo "--- latest handshakes ---"; wg show all latest-handshakes 2>/dev/null || echo "no handshake data"; } > "${STAGE}/wireguard.txt" || true +else + echo "wg tool not installed." > "${STAGE}/wireguard.txt" +fi + +# Textfile collectors: copy *.prom only, never anything matching excludes. +mkdir -p "${STAGE}/textfile" +if [[ -d /var/lib/node_exporter/textfile ]]; then + while IFS= read -r -d '' f; do + base="$(basename "${f}")" + if is_excluded "${base}"; then + log "skipping excluded file: ${base}" + continue + fi + cp "${f}" "${STAGE}/textfile/${base}" 2>/dev/null || true + done < <(find /var/lib/node_exporter/textfile -maxdepth 1 -name '*.prom' -type f -print0 2>/dev/null || true) + if [[ -z "$(ls -A "${STAGE}/textfile" 2>/dev/null)" ]]; then + echo "textfile dir empty." > "${STAGE}/textfile/README.txt" + fi +else + echo "textfile dir missing." > "${STAGE}/textfile/README.txt" +fi + +# Manifest: file list + sizes + timestamp + exclusion rules. +{ + echo "blueforce support bundle manifest" + echo "host: ${HOST}" + echo "collected_at: $(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo unknown)" + echo "generator: bf-support-bundle" + echo "excluded_patterns: ${EXCLUDE_PATTERNS[*]}" + echo "note: secrets, private keys and passwords are never included; key-like values redacted." + echo "" + echo "files:" + ( cd "${STAGE}" && find . -type f -printf '%p %s bytes\n' 2>/dev/null | sort ) || ( cd "${STAGE}" && find . -type f -exec wc -c {} + | sort ) +} > "${STAGE}/manifest.txt" + +mkdir -p "${OUT_DIR}" +TARBALL="${OUT_DIR}/${BUNDLE_NAME}.tar.gz" +tar --sort=name --mtime='UTC 2020-01-01' \ + --exclude='*.key' --exclude='*secret*' --exclude='*password*' \ + --exclude='*passwd*' --exclude='*private*' \ + --exclude='*id_rsa*' --exclude='*id_ed25519*' --exclude='*.pem' \ + -czf "${TARBALL}" -C "${WORK}" "${BUNDLE_NAME}" + +log "wrote ${TARBALL}" +echo "${TARBALL}" + + + +#!/usr/bin/env bash +# Pre-flight checks: root, Ubuntu 26.04, RAM/disk, internet. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="01-precheck" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [01-precheck] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +MIN_RAM_MB=2048 +MIN_DISK_MB=20480 + +fail() { log "FAIL: $1"; echo "FAIL: $1" >&2; exit 1; } + +run_checks() { + [[ "$(id -u)" -eq 0 ]] || fail "must run as root" + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + [[ "${VERSION_ID:-}" == 26.04* ]] || fail "unsupported OS: ${NAME:-unknown} ${VERSION_ID:-unknown} (need Ubuntu 26.04)" + else + fail "/etc/os-release not found" + fi + local ram_mb disk_mb + ram_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo) + [[ "$ram_mb" -ge "$MIN_RAM_MB" ]] || fail "RAM too low: ${ram_mb}MB (need >= ${MIN_RAM_MB}MB)" + disk_mb=$(df -m / --output=avail | tail -1 | tr -d ' ') + [[ "$disk_mb" -ge "$MIN_DISK_MB" ]] || fail "disk too low: ${disk_mb}MB free on / (need >= ${MIN_DISK_MB}MB)" + if ! getent hosts archive.ubuntu.com >/dev/null 2>&1 && ! ping -c1 -W5 1.1.1.1 >/dev/null 2>&1; then + fail "no internet connectivity (DNS and ping both failed)" + fi + log "OK: precheck passed (Ubuntu ${VERSION_ID}, RAM ${ram_mb}MB, disk ${disk_mb}MB free)" +} + +run_checks + + + +#!/usr/bin/env bash +# Base APT sources, disable unattended-upgrades, set timezone. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="02-system" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [02-system] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +TIMEZONE="${TIMEZONE:-Europe/Istanbul}" +BASE_PKGS="curl wget ca-certificates gnupg lsb-release" + +check_state() { + local ok=1 + for p in $BASE_PKGS; do dpkg -s "$p" >/dev/null 2>&1 || { echo "missing package: $p"; ok=0; }; done + grep -q '^APT::Periodic::Update-Package-Lists "0"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null || { echo "20auto-upgrades not locked"; ok=0; } + for t in apt-daily.service apt-daily.timer apt-daily-upgrade.timer; do + systemctl is-enabled "$t" 2>/dev/null | grep -q "masked" || { echo "timer not masked: $t"; ok=0; } + done + [[ "$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null)" == "$TIMEZONE" ]] || { echo "timezone not $TIMEZONE"; ok=0; } + return $((1 - ok)) +} + +if [[ "$CHECK_MODE" -eq 1 ]]; then + if check_state; then log "OK(check): system baseline matches"; else log "FAIL(check): system baseline drift"; exit 1; fi + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get update +# shellcheck disable=SC2086 +apt-get install -y $BASE_PKGS + +# Lock automatic updates OFF (fleet policy: no unapproved updates). +printf 'APT::Periodic::Update-Package-Lists "0";\nAPT::Periodic::Unattended-Upgrade "0";\n' > /tmp/20auto-upgrades.bf +mv /tmp/20auto-upgrades.bf /etc/apt/apt.conf.d/20auto-upgrades +for t in apt-daily.service apt-daily.timer apt-daily-upgrade.timer apt-daily-upgrade.service; do + systemctl mask "$t" >/dev/null 2>&1 || true +done + +if command -v timedatectl >/dev/null 2>&1; then + timedatectl set-timezone "$TIMEZONE" || echo "$TIMEZONE" > /etc/timezone +else + echo "$TIMEZONE" > /etc/timezone +fi +log "OK: base packages installed, unattended-upgrades disabled, timezone $TIMEZONE" + + + +#!/usr/bin/env bash +# Blueforce maintenance user, restricted sudoers, mandatory administrator key. +set -euo pipefail +MODULE="03-user" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SUDOERS_FILE="/etc/sudoers.d/blueforce" +AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" +BF_ADMIN_PUBLIC_KEY="${BF_ADMIN_PUBLIC_KEY:-}" +BF_ADMIN_PUBLIC_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; } +valid_key_file() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } +load_admin_key() { + if [[ -n "$BF_ADMIN_PUBLIC_KEY" && -n "$BF_ADMIN_PUBLIC_KEY_FILE" ]]; then + log "FAIL: set only one of BF_ADMIN_PUBLIC_KEY or BF_ADMIN_PUBLIC_KEY_FILE"; return 1 + elif [[ -n "$BF_ADMIN_PUBLIC_KEY" ]]; then + printf '%s\n' "$BF_ADMIN_PUBLIC_KEY" + elif [[ -n "$BF_ADMIN_PUBLIC_KEY_FILE" && -r "$BF_ADMIN_PUBLIC_KEY_FILE" ]]; then + cat "$BF_ADMIN_PUBLIC_KEY_FILE" + else + log "FAIL: BF_ADMIN_PUBLIC_KEY or BF_ADMIN_PUBLIC_KEY_FILE is required before SSH hardening"; return 1 + fi +} +if [[ "$CHECK_MODE" -eq 1 ]]; then + id blueforce >/dev/null 2>&1 || { log "FAIL(check): user blueforce missing"; exit 1; } + [[ -f "$SUDOERS_FILE" ]] && visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1 || { log "FAIL(check): sudoers invalid"; exit 1; } + valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; } + log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0 +fi +key_data="$(load_admin_key)" || exit 1 +[[ -n "${key_data//[[:space:]]/}" ]] || { log "FAIL: administrator key is empty"; exit 1; } +tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT +printf '%s\n' "$key_data" > "$tmp_key" +ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; } +if ! id blueforce >/dev/null 2>&1; then useradd -m -s /bin/bash blueforce; fi +passwd -l blueforce >/dev/null 2>&1 || true +install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh +install -m 600 -o blueforce -g blueforce "$tmp_key" "$AUTHORIZED_KEYS" +valid_key_file || { log "FAIL: atomic authorized_keys install did not validate"; exit 1; } +cat > /tmp/blueforce.sudoers <<'EOF' +# Managed by blueforce-installer (03-user). Local edits will be overwritten. +blueforce ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs * +EOF +visudo -cf /tmp/blueforce.sudoers +install -m 440 /tmp/blueforce.sudoers "$SUDOERS_FILE" +log "OK: user blueforce has a validated administrator key and restricted sudoers" + + + +#!/usr/bin/env bash +# Set hostname bf- from validated dealer number. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="04-hostname" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [04-hostname] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +[[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; } +[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; } +TARGET="bf-${DEALER_ID}" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; } + log "OK(check): hostname is $TARGET" + exit 0 +fi + +if [[ "$(hostname)" == "$TARGET" ]]; then + log "hostname already $TARGET, keeping" +else + hostnamectl set-hostname "$TARGET" + if grep -q "127.0.1.1" /etc/hosts; then + sed -i "s/^127.0.1.1.*/127.0.1.1\t$TARGET/" /etc/hosts + else + echo -e "127.0.1.1\t$TARGET" >> /etc/hosts + fi + log "hostname set to $TARGET" +fi +mkdir -p "$STATE_DIR" +echo "$DEALER_ID" > "$STATE_DIR/dealer-id" +echo "bf-${DEALER_ID}" > "$STATE_DIR/hostname" +echo "BF-${DEALER_ID}" > "$STATE_DIR/device-id" +log "OK: identity files written (BF-${DEALER_ID} / bf-${DEALER_ID})" + + + +#!/usr/bin/env bash +# Wait for NetworkManager, verify DNS and connectivity. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="05-network" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [05-network] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +WAIT_SECS="${NM_WAIT_SECS:-90}" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + getent hosts archive.ubuntu.com >/dev/null 2>&1 || { log "FAIL(check): DNS resolution broken"; exit 1; } + log "OK(check): DNS resolves" + exit 0 +fi + +elapsed=0 +while [[ "$elapsed" -lt "$WAIT_SECS" ]]; do + if systemctl is-active --quiet NetworkManager 2>/dev/null || systemctl is-active --quiet systemd-networkd 2>/dev/null; then + break + fi + sleep 5; elapsed=$((elapsed + 5)) +done +systemctl is-active --quiet NetworkManager 2>/dev/null || systemctl is-active --quiet systemd-networkd 2>/dev/null || { log "FAIL: no network manager active after ${WAIT_SECS}s"; exit 1; } + +getent hosts archive.ubuntu.com >/dev/null 2>&1 || { log "FAIL: DNS resolution failed"; exit 1; } +[[ -f /etc/resolv.conf ]] || { log "FAIL: /etc/resolv.conf missing"; exit 1; } +log "OK: network manager active, DNS resolves" + + + +#!/usr/bin/env bash +# WireGuard per-device configuration; refuse placeholders or shared defaults. +# Idempotent: safe to re-run. Supports --check for read-only verification. +set -euo pipefail + +MODULE="06-wireguard" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +WG_DIR="/etc/wireguard" +WG_CONF="$WG_DIR/wg0.conf" +WG_ADDRESS="${WG_ADDRESS:-}" +WG_ENDPOINT="${WG_ENDPOINT:-}" +WG_PEER_PUBKEY="${WG_PEER_PUBKEY:-}" +REQUIRE_WIREGUARD="${REQUIRE_WIREGUARD:-true}" +BF_WG_HANDSHAKE_MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" + +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [06-wireguard] $1" | tee -a "$LOG_FILE"; } +valid_address() { + local address="$1" ip prefix octet + [[ "$address" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]] && [[ "$address" != "10.88.0.0/24" ]] || return 1 + ip="${address%/*}"; prefix="${address#*/}" + IFS=. read -r -a octets <<< "$ip" + for octet in "${octets[@]}"; do ((10#$octet <= 255)) || return 1; done + ((10#$prefix <= 32)) +} +valid_endpoint() { + local host port + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*:[1-9][0-9]{0,4}$ ]] && [[ "$1" != "vpn.example.com:51820" ]] || return 1 + host="${1%:*}"; port="${1##*:}" + [[ -n "$host" ]] && ((10#$port <= 65535)) +} +valid_pubkey() { [[ "$1" =~ ^[A-Za-z0-9+/]{43}=$ ]] && [[ "$1" != "REPLACE_WITH_SERVER_PUBLIC_KEY" ]]; } +valid_handshake_age() { [[ "$BF_WG_HANDSHAKE_MAX_AGE" =~ ^[0-9]+$ ]] && ((10#$BF_WG_HANDSHAKE_MAX_AGE > 0)); } +config_is_complete() { + [[ -f "$WG_CONF" ]] && + ! grep -qE '10\.88\.0\.0/24|vpn\.example\.com:51820|REPLACE_WITH_SERVER_PUBLIC_KEY' "$WG_CONF" && + grep -qE '^Address = ([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$' "$WG_CONF" && + grep -qE '^Endpoint = [A-Za-z0-9][A-Za-z0-9.-]*:[1-9][0-9]{0,4}$' "$WG_CONF" && + grep -qE '^PublicKey = [A-Za-z0-9+/]{43}=$' "$WG_CONF" +} +handshake_is_fresh() { + local now + valid_handshake_age || return 1 + now="$(date +%s)" + wg show wg0 latest-handshakes 2>/dev/null | awk -v now="$now" -v max_age="$BF_WG_HANDSHAKE_MAX_AGE" '$2 > 0 && now >= $2 && now - $2 <= max_age { found=1 } END { exit(found ? 0 : 1) }' +} + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ "$REQUIRE_WIREGUARD" == "true" || "$REQUIRE_WIREGUARD" == "false" ]] || { log "FAIL(check): REQUIRE_WIREGUARD must be true or false"; exit 1; } + [[ -f "$WG_DIR/privatekey" && -f "$WG_DIR/publickey" ]] || { log "FAIL(check): wireguard keys missing"; exit 1; } + config_is_complete || { log "FAIL(check): wg0.conf is missing or contains incomplete/placeholder peer data"; exit 1; } + systemctl is-enabled --quiet wg-quick@wg0 || { log "FAIL(check): wg-quick@wg0 not enabled"; exit 1; } + systemctl is-active --quiet wg-quick@wg0 || { log "FAIL(check): wg-quick@wg0 not active"; exit 1; } + wg show interfaces | tr ' ' '\n' | grep -qx wg0 || { log "FAIL(check): wg0 interface missing"; exit 1; } + if [[ "$REQUIRE_WIREGUARD" == "true" ]]; then + handshake_is_fresh || { log "FAIL(check): no WireGuard peer handshake within ${BF_WG_HANDSHAKE_MAX_AGE}s"; exit 1; } + fi + log "OK(check): wireguard config is complete, wg0 is active, and required handshake is fresh" + exit 0 +fi + +valid_address "$WG_ADDRESS" || { log "FAIL: WG_ADDRESS must be a unique device CIDR, not a default or placeholder"; exit 1; } +valid_endpoint "$WG_ENDPOINT" || { log "FAIL: WG_ENDPOINT must be a non-placeholder host:port"; exit 1; } +valid_pubkey "$WG_PEER_PUBKEY" || { log "FAIL: WG_PEER_PUBKEY must be a valid non-placeholder WireGuard public key"; exit 1; } +[[ "$REQUIRE_WIREGUARD" == "true" || "$REQUIRE_WIREGUARD" == "false" ]] || { log "FAIL: REQUIRE_WIREGUARD must be true or false"; exit 1; } +valid_handshake_age || { log "FAIL: BF_WG_HANDSHAKE_MAX_AGE must be a positive number of seconds"; exit 1; } +command -v wg >/dev/null 2>&1 || { export DEBIAN_FRONTEND=noninteractive; apt-get update && apt-get install -y wireguard; } +install -d -m 700 "$WG_DIR" +if [[ ! -f "$WG_DIR/privatekey" ]]; then + umask 077 + wg genkey | tee "$WG_DIR/privatekey" | wg pubkey > "$WG_DIR/publickey" + chmod 600 "$WG_DIR/privatekey" "$WG_DIR/publickey" + log "wireguard keypair generated" +fi +PRIVKEY="$(<"$WG_DIR/privatekey")" +PEER_NAME="${BF_HOSTNAME:-bf-${DEALER_ID:-unknown}}" +tmp_conf="$(mktemp "$WG_DIR/wg0.conf.XXXXXX")" +cat > "$tmp_conf" < + + +#!/usr/bin/env bash +# Harden sshd only after a validated Blueforce administrator key is installed. +set -euo pipefail +MODULE="07-ssh" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +DROPIN="/etc/ssh/sshd_config.d/99-blueforce.conf" +AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [07-ssh] $1" | tee -a "$LOG_FILE"; } +key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } +read -r -d '' WANT <<'EOF' || true +# Managed by blueforce-installer (07-ssh). Local edits will be overwritten. +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +ChallengeResponseAuthentication no +UsePAM yes +X11Forwarding no +ClientAliveInterval 300 +ClientAliveCountMax 2 +MaxAuthTries 3 +AllowUsers blueforce +EOF +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; } + key_ready || { log "FAIL(check): no valid non-empty blueforce authorized_keys"; exit 1; } + grep -qx 'AllowUsers blueforce' "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; } + sshd -t 2>/dev/null || { log "FAIL(check): sshd config test failed"; exit 1; } + log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server +key_ready || { log "FAIL: valid non-empty /home/blueforce/.ssh/authorized_keys is required before SSH hardening"; exit 1; } +printf '%s\n' "$WANT" > /tmp/99-blueforce.conf.bf +install -m 644 /tmp/99-blueforce.conf.bf "$DROPIN" +sshd -t || { log "FAIL: sshd -t rejected the new config"; exit 1; } +if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi +systemctl enable "$SVC" >/dev/null 2>&1 || true +systemctl reload-or-restart "$SVC" +log "OK: hardened SSH installed after valid key verification; AllowUsers=blueforce" + + + +#!/usr/bin/env bash +# xRDP + GNOME session, terminal boot default, multi-user. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="08-rdp" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [08-rdp] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s xrdp >/dev/null 2>&1 || { log "FAIL(check): xrdp not installed"; exit 1; } + systemctl is-enabled --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not enabled"; exit 1; } + [[ "$(systemctl get-default)" == "multi-user.target" ]] || { log "FAIL(check): default target is not multi-user"; exit 1; } + log "OK(check): xrdp installed+enabled, terminal boot default" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +dpkg -s xrdp >/dev/null 2>&1 || apt-get install -y xrdp +dpkg -s gnome-session >/dev/null 2>&1 || apt-get install -y gnome-session + +# Default GNOME session for RDP logins (per-user override still wins). +if [[ ! -f /etc/skel/.xsession ]]; then + echo "gnome-session" > /etc/skel/.xsession +fi +if id blueforce >/dev/null 2>&1 && [[ ! -f /home/blueforce/.xsession ]]; then + echo "gnome-session" > /home/blueforce/.xsession + chown blueforce:blueforce /home/blueforce/.xsession +fi + +systemctl enable xrdp xrdp-sesman >/dev/null 2>&1 || systemctl enable xrdp +systemctl set-default multi-user.target +log "OK: xrdp ready, gnome-session default, boot target multi-user" + + + +#!/usr/bin/env bash +# RustDesk is a required remote channel: only a pinned supplied package is accepted. +set -euo pipefail +MODULE="09-rustdesk" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +RUSTDESK_SERVER="${RUSTDESK_SERVER:-}" +RUSTDESK_DEB="${RUSTDESK_DEB:-}" +SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml" +USER_CONFIG="/home/blueforce/.config/RustDesk/RustDesk2.toml" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; } +valid_server() { + local endpoint="$1" host port + [[ -n "$endpoint" ]] || return 1 + [[ "$endpoint" != *example* && "$endpoint" != *invalid* && "$endpoint" != *REPLACE* && "$endpoint" != *replace* && "$endpoint" != *'<'* && "$endpoint" != *'>'* && "$endpoint" != localhost* ]] || return 1 + [[ "$endpoint" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] || return 1 + host="${endpoint%%:*}" + [[ -n "$host" ]] || return 1 + if [[ "$endpoint" == *:* ]]; then + port="${endpoint##*:}" + ((10#$port <= 65535)) || return 1 + fi +} +config_endpoint() { + sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$SYSTEM_CONFIG" 2>/dev/null | head -n 1 +} +configured() { + local endpoint + endpoint="$(config_endpoint)" + valid_server "$endpoint" || return 1 + grep -Fqx "rendezvous-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && + grep -Fqx "relay-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && + grep -Fqx "rendezvous-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 && + grep -Fqx "relay-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 +} +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s rustdesk >/dev/null 2>&1 || { log "FAIL(check): rustdesk remote channel is not installed"; exit 1; } + configured || { log "FAIL(check): RustDesk client config is missing, inconsistent, or uses a placeholder/non-self-hosted endpoint"; exit 1; } + rustdesk --get-id 2>/dev/null | grep -qE '^[0-9]+$' || { log "FAIL(check): RustDesk client cannot provide a device ID"; exit 1; } + log "OK(check): RustDesk remote channel is installed with a self-hosted client configuration" + exit 0 +fi +[[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; } +valid_server "$RUSTDESK_SERVER" || { log "FAIL: RUSTDESK_SERVER must be a non-placeholder self-hosted host or host:port"; exit 1; } +export DEBIAN_FRONTEND=noninteractive +dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; } +for cfgdir in /etc/rustdesk /home/blueforce/.config/RustDesk; do + install -d -m 700 "$cfgdir" + cat > "$cfgdir/RustDesk2.toml" </dev/null || true +configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; } +install -d -m 700 "$STATE_DIR" +rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true +[[ -s "$STATE_DIR/rustdesk-id" ]] || { log "FAIL: RustDesk client did not provide a device ID"; exit 1; } +log "OK: RustDesk pinned package installed and self-hosted channel configured" + + + +#!/usr/bin/env bash +# Docker CE + daemon.json with log rotation, service enabled. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="10-docker" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [10-docker] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +DAEMON_JSON="/etc/docker/daemon.json" +WANT_JSON='{ + "log-driver": "json-file", + "log-opts": {"max-size": "10m", "max-file": "3"}, + "live-restore": true +}' + +if [[ "$CHECK_MODE" -eq 1 ]]; then + command -v docker >/dev/null 2>&1 || { log "FAIL(check): docker missing"; exit 1; } + systemctl is-enabled --quiet docker 2>/dev/null || { log "FAIL(check): docker not enabled"; exit 1; } + [[ -f "$DAEMON_JSON" ]] || { log "FAIL(check): daemon.json missing"; exit 1; } + log "OK(check): docker installed, enabled, daemon.json present" + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get install -y ca-certificates curl gnupg + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + # shellcheck disable=SC1091 + . /etc/os-release + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list + apt-get update + fi + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || apt-get install -y docker.io +fi + +printf '%s\n' "$WANT_JSON" > /tmp/daemon.json.bf +mkdir -p /etc/docker +mv /tmp/daemon.json.bf "$DAEMON_JSON" +systemctl enable --now docker +systemctl is-active --quiet docker || { log "FAIL: docker installed but service not running"; exit 1; } +log "OK: docker ready with log rotation (10m x3) and live-restore" + + + +#!/usr/bin/env bash +# MEG placeholder: install vendor .deb if present, else SKIP + acceptance checklist. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="11-meg" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [11-meg] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +MEG_DEB="${MEG_DEB:-$(ls /opt/blueforce/debs/meg*.deb /opt/blueforce/meg*.deb 2>/dev/null | head -1)}" +MEG_CONTAINER="${MEG_CONTAINER:-meg}" + +acceptance_checklist() { + local fails=0 + # Gate 2: pinned version, never latest. + local img + img=$(docker inspect --format '{{.Config.Image}}' "$MEG_CONTAINER" 2>/dev/null || echo "") + if [[ -z "$img" ]]; then echo "CHECK container-running: SKIP (no container $MEG_CONTAINER yet)"; else echo "CHECK container-running: PASS ($img)"; fi + if [[ "$img" == *":latest" ]]; then echo "CHECK pinned-tag: FAIL (latest forbidden)"; fails=$((fails+1)); else echo "CHECK pinned-tag: PASS"; fi + # Gate 4: restart policy unless-stopped. + if [[ -n "$img" ]]; then + local pol + pol=$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$MEG_CONTAINER" 2>/dev/null) + [[ "$pol" == "unless-stopped" ]] && echo "CHECK restart-policy: PASS" || { echo "CHECK restart-policy: FAIL ($pol)"; fails=$((fails+1)); } + fi + # Gate 5: no 0.0.0.0 port bind to the outside. + if ss -tlnp 2>/dev/null | grep -q "0.0.0.0"; then echo "CHECK port-bind: WARN (external bind present — review)"; else echo "CHECK port-bind: PASS"; fi + return "$fails" +} + +if [[ "$CHECK_MODE" -eq 1 ]]; then + if [[ -z "${MEG_DEB:-}" || ! -f "$MEG_DEB" ]] && ! dpkg -s meg >/dev/null 2>&1 && ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$MEG_CONTAINER"; then + log "OK(check): MEG not supplied yet (SKIP state)" + exit 0 + fi + acceptance_checklist || { log "FAIL(check): MEG acceptance checklist failed"; exit 1; } + log "OK(check): MEG acceptance checklist passed" + exit 0 +fi + +if [[ "${MEG_SKIP:-0}" == "1" ]]; then log "SKIP: MEG_SKIP=1"; exit 3; fi + +if ! dpkg -s meg >/dev/null 2>&1 && ! docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$MEG_CONTAINER"; then + if [[ -n "${MEG_DEB:-}" && -f "$MEG_DEB" ]]; then + export DEBIAN_FRONTEND=noninteractive + dpkg -i "$MEG_DEB" || apt-get install -f -y + log "MEG package installed from $MEG_DEB" + else + log "SKIP: no MEG .deb supplied — vendor package pending, continuing" + exit 3 + fi +else + log "MEG already present, keeping" +fi + +if ! acceptance_checklist; then + log "FAIL: MEG acceptance checklist failed — see output above" + exit 1 +fi +log "OK: MEG present and acceptance checklist passed" + + + +#!/usr/bin/env bash +# Node exporter + textfile collector directory. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="12-monitoring" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [12-monitoring] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +TEXTFILE_DIR="/var/lib/node_exporter/textfile" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + systemctl is-enabled --quiet prometheus-node-exporter 2>/dev/null || systemctl is-enabled --quiet node_exporter 2>/dev/null || { log "FAIL(check): node exporter not enabled"; exit 1; } + [[ -d "$TEXTFILE_DIR" ]] || { log "FAIL(check): textfile dir missing"; exit 1; } + log "OK(check): node exporter enabled, textfile dir present" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +dpkg -s prometheus-node-exporter >/dev/null 2>&1 || apt-get install -y prometheus-node-exporter + +install -d -m 755 "$TEXTFILE_DIR" +# Ensure the textfile collector flag is set (package default may omit it). +OVERRIDE="/etc/systemd/system/prometheus-node-exporter.service.d/blueforce.conf" +mkdir -p "$(dirname "$OVERRIDE")" +printf '[Service]\nExecStart=\nExecStart=/usr/bin/prometheus-node-exporter --collector.textfile.directory=%s\n' "$TEXTFILE_DIR" > /tmp/override.bf +mv /tmp/override.bf "$OVERRIDE" +systemctl daemon-reload +systemctl enable --now prometheus-node-exporter 2>/dev/null || systemctl enable --now node_exporter +log "OK: node exporter enabled with textfile dir $TEXTFILE_DIR" + + + +#!/usr/bin/env bash +# Apply UFW deny-in baseline and persist Docker published-port policy. +set -euo pipefail +MODULE="13-firewall" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SSH_PORT="${SSH_PORT:-22}" +RDP_PORT="${RDP_PORT:-3389}" +DOCKER_ALLOWLIST="/etc/blueforce/docker-published-port-allowlist.conf" +DOCKER_POLICY_BIN="/usr/local/sbin/blueforce-docker-firewall" +DOCKER_POLICY_UNIT="blueforce-docker-firewall.service" +DOCKER_POLICY_DROPIN="/etc/systemd/system/docker.service.d/blueforce-docker-firewall.conf" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [13-firewall] $1" | tee -a "$LOG_FILE"; } + +if [[ "$CHECK_MODE" -eq 1 ]]; then + ufw status 2>/dev/null | grep -q 'Status: active' || { log "FAIL(check): ufw not active"; exit 1; } + ufw status 2>/dev/null | grep -Eq "${SSH_PORT}/tcp.*on wg0|${SSH_PORT}/tcp.*wg0" || { log "FAIL(check): SSH is not restricted to wg0"; exit 1; } + ufw status 2>/dev/null | grep -Eq "${RDP_PORT}/tcp.*on wg0|${RDP_PORT}/tcp.*wg0" || { log "FAIL(check): RDP is not restricted to wg0"; exit 1; } + [[ -f "$DOCKER_ALLOWLIST" && -x "$DOCKER_POLICY_BIN" && -f "$DOCKER_POLICY_DROPIN" ]] || { log "FAIL(check): persistent Docker firewall assets missing"; exit 1; } + systemctl is-enabled --quiet "$DOCKER_POLICY_UNIT" || { log "FAIL(check): persistent Docker firewall unit is not enabled"; exit 1; } + "$DOCKER_POLICY_BIN" --check || { log "FAIL(check): Docker published-port policy is not enforced"; exit 1; } + log "OK(check): SSH/RDP restricted to wg0 and Docker policy persists through systemd" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +command -v ufw >/dev/null 2>&1 || apt-get install -y ufw +command -v iptables >/dev/null 2>&1 || apt-get install -y iptables +ufw default deny incoming +ufw default allow outgoing +ufw delete allow "$SSH_PORT/tcp" 2>/dev/null || true +ufw delete allow "$RDP_PORT/tcp" 2>/dev/null || true +ufw allow in on wg0 to any port "$SSH_PORT" proto tcp comment 'SSH over WireGuard only' +ufw allow in on wg0 to any port "$RDP_PORT" proto tcp comment 'RDP over WireGuard only' +install -d -m 755 /etc/blueforce /etc/iptables +if [[ ! -f "$DOCKER_ALLOWLIST" ]]; then + cat > "$DOCKER_ALLOWLIST" <<'EOF' +# Explicit Docker published-port exceptions. One reviewed entry per line: +# tcp 8443 +# udp 51820 +# This allowlist intentionally permits no externally published Docker ports by default. +EOF +fi +chown root:root "$DOCKER_ALLOWLIST" +chmod 640 "$DOCKER_ALLOWLIST" +install -o root -g root -m 700 "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" "$DOCKER_POLICY_BIN" +install -o root -g root -m 644 "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" "/etc/systemd/system/$DOCKER_POLICY_UNIT" +install -d -m 755 "$(dirname "$DOCKER_POLICY_DROPIN")" +install -o root -g root -m 644 "$REPO_ROOT/config/systemd/docker.service.d/blueforce-docker-firewall.conf" "$DOCKER_POLICY_DROPIN" +systemctl daemon-reload +systemctl enable "$DOCKER_POLICY_UNIT" +# Docker creates DOCKER-USER during startup; applying now proves the parsed policy. +systemctl start docker +"$DOCKER_POLICY_BIN" +ufw --force enable +log "OK: UFW allows SSH/RDP only on wg0; persisted Docker policy denies all unapproved published ports" + + + +#!/usr/bin/env bash +# APT holds + disable automatic update timers. +set -euo pipefail +MODULE="14-update-policy" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +APT_HOLDS="${APT_HOLDS:-}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [14-update-policy] $1" | tee -a "$LOG_FILE"; } +if [[ "$CHECK_MODE" -eq 1 ]]; then + for t in apt-daily.timer apt-daily-upgrade.timer; do systemctl is-enabled "$t" 2>/dev/null | grep -q masked || { log "FAIL(check): $t not masked"; exit 1; }; done + log "OK(check): update timers masked"; exit 0 +fi +for t in apt-daily.service apt-daily.timer apt-daily-upgrade.service apt-daily-upgrade.timer; do systemctl mask "$t" >/dev/null 2>&1 || true; systemctl stop "$t" >/dev/null 2>&1 || true; done +printf 'APT::Periodic::Update-Package-Lists "0";\nAPT::Periodic::Unattended-Upgrade "0";\nAPT::Periodic::Enable "0";\n' > /tmp/20auto-upgrades.bf +mv /tmp/20auto-upgrades.bf /etc/apt/apt.conf.d/20auto-upgrades +if [[ -n "$APT_HOLDS" ]]; then + read -r -a apt_holds <<< "$APT_HOLDS" + ((${#apt_holds[@]})) && apt-mark hold -- "${apt_holds[@]}" + log "holds applied: ${apt_holds[*]}" +fi +log "OK: automatic updates locked off (timers masked, periodic=0)" + + + +#!/usr/bin/env bash +# journald caps + app logrotate policy. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="15-logrotate" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [15-logrotate] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +JOURNAL_DROPIN="/etc/systemd/journald.conf.d/99-blueforce.conf" +APP_ROTATE="/etc/logrotate.d/blueforce" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ -f "$JOURNAL_DROPIN" && -f "$APP_ROTATE" ]] || { log "FAIL(check): logrotate configs missing"; exit 1; } + log "OK(check): journald caps + app logrotate present" + exit 0 +fi + +mkdir -p /etc/systemd/journald.conf.d +cat > /tmp/journald.bf <<'EOF' +# Managed by blueforce-installer (15-logrotate). +[Journal] +SystemMaxUse=500M +RuntimeMaxUse=100M +MaxRetentionSec=30day +ForwardToSyslog=no +EOF +mv /tmp/journald.bf "$JOURNAL_DROPIN" +systemctl restart systemd-journald 2>/dev/null || true + +cat > /tmp/blueforce-rotate.bf <<'EOF' +# Managed by blueforce-installer (15-logrotate). +/var/log/blueforce*.log /var/log/bf-*.log { + size 10M + rotate 5 + compress + delaycompress + missingok + notifempty + copytruncate +} +EOF +mv /tmp/blueforce-rotate.bf "$APP_ROTATE" +chmod 644 "$APP_ROTATE" +logrotate --debug "$APP_ROTATE" 2>&1 | head -3 || true +log "OK: journald capped (500M/30d), app logs rotate at 10M x5" + + + +#!/usr/bin/env bash +# GNOME minimal and maintained GUI maintenance tools; terminal boot remains default. +set -euo pipefail +MODULE="16-gui" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [16-gui] $1" | tee -a "$LOG_FILE"; } +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s ubuntu-desktop-minimal >/dev/null 2>&1 || { log "FAIL(check): ubuntu-desktop-minimal missing"; exit 1; } + [[ -x /usr/local/bin/bf-gui-on && -x /usr/local/bin/bf-gui-off ]] || { log "FAIL(check): maintained bf-gui switches missing"; exit 1; } + [[ -f /etc/systemd/system/bf-gui-on.service && -f /etc/systemd/system/bf-gui-off.service ]] || { log "FAIL(check): manual GUI service units missing"; exit 1; } + [[ "$(systemctl get-default)" == multi-user.target ]] || { log "FAIL(check): terminal boot default was changed"; exit 1; } + log "OK(check): GNOME minimal, maintained switches, and terminal boot default present"; exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +dpkg -s ubuntu-desktop-minimal >/dev/null 2>&1 || apt-get install -y ubuntu-desktop-minimal +for tool in bf-gui-on bf-gui-off; do install -m 755 "$SCRIPT_ROOT/maintenance/$tool" "/usr/local/bin/$tool"; done +for unit in bf-gui-on.service bf-gui-off.service; do install -m 644 "$SCRIPT_ROOT/../config/systemd/$unit" "/etc/systemd/system/$unit"; done +systemctl daemon-reload +systemctl set-default multi-user.target +systemctl disable gdm3 2>/dev/null || systemctl disable gdm 2>/dev/null || true +log "OK: maintained GUI tools and manual units installed; boot default is multi-user" + + + +#!/usr/bin/env bash +# Install bf-status and run it for the first time. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="17-healthcheck" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [17-healthcheck] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +STATUS_BIN="/usr/local/bin/bf-status" + +write_bf_status() { + cat > /tmp/bf-status.bf <<'EOF' +#!/usr/bin/env bash +# bf-status: quick device health summary. Exit 0 = healthy, 1 = problem. +set -uo pipefail +fails=0 +say() { local st="$1"; shift; echo "[$st] $*"; [[ "$st" == "PASS" ]] || fails=$((fails+1)); } + +ID_FILE="/var/lib/blueforce/dealer-id" +if [[ -f "$ID_FILE" && "$(cat $ID_FILE)" =~ ^[0-9]{8}$ ]]; then + DID=$(cat "$ID_FILE") + [[ "$(hostname)" == "bf-$DID" ]] && say PASS "identity BF-$DID / $(hostname)" || say FAIL "hostname $(hostname) != bf-$DID" +else + say FAIL "dealer-id file missing or invalid" +fi +systemctl is-enabled --quiet wg-quick@wg0 2>/dev/null && say PASS "wireguard enabled" || say FAIL "wireguard not enabled" +systemctl is-active --quiet docker 2>/dev/null && say PASS "docker running" || say FAIL "docker not running" +ufw status 2>/dev/null | grep -q "Status: active" && say PASS "firewall active" || say FAIL "firewall inactive" +systemctl is-enabled apt-daily.timer 2>/dev/null | grep -q masked && say PASS "auto-update off" || say FAIL "auto-update timer live" +systemctl is-enabled --quiet prometheus-node-exporter 2>/dev/null || systemctl is-enabled --quiet node_exporter 2>/dev/null && say PASS "monitoring enabled" || say FAIL "monitoring not enabled" +echo "bf-status: $fails failing check(s)" +exit "$fails" +EOF + mv /tmp/bf-status.bf "$STATUS_BIN" + chmod 755 "$STATUS_BIN" +} + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ -x "$STATUS_BIN" ]] || { log "FAIL(check): bf-status missing"; exit 1; } + log "OK(check): bf-status installed" + exit 0 +fi + +mkdir -p "$STATE_DIR" +if [[ -n "$DEALER_ID" ]]; then echo "$DEALER_ID" > "$STATE_DIR/dealer-id"; fi +write_bf_status +"$STATUS_BIN" | tee "$STATE_DIR/bf-status-first-run.log" >/dev/null || log "WARN: first bf-status run reports failures (see $STATE_DIR/bf-status-first-run.log)" +log "OK: bf-status installed, first run saved" + + + +#!/usr/bin/env bash +# Gate all installer modules; READY is written only when every required channel passes. +set -euo pipefail +MODULE="18-final-check" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GATES=(01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 09-rustdesk 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck) +READY_FILE="$STATE_DIR/READY" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [18-final-check] $1" | tee -a "$LOG_FILE"; } +self_gate() { [[ -n "$DEALER_ID" && "$DEALER_ID" =~ ^[0-9]{8}$ ]] && [[ "$(hostname)" == "bf-${DEALER_ID}" ]]; } +run_gates() { + local fails=0 skipped=0 out rc g + for g in "${GATES[@]}"; do + set +e; out=$(DEALER_ID="$DEALER_ID" BF_HOSTNAME="$BF_HOSTNAME" BF_DEVICE="$BF_DEVICE" STATE_DIR="$STATE_DIR" REQUIRE_WIREGUARD="${REQUIRE_WIREGUARD:-true}" BF_WG_HANDSHAKE_MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" LOG_FILE=/dev/null bash "$SCRIPT_DIR/$g.sh" --check 2>&1); rc=$?; set -e + case "$rc" in 0) echo "GATE $g: PASS";; 3) echo "GATE $g: SKIP (not READY)"; skipped=$((skipped+1)); fails=$((fails+1));; *) echo "GATE $g: FAIL"; echo "$out" | tail -3; fails=$((fails+1));; esac + done + if self_gate; then echo 'GATE 18-final-check: PASS'; else echo 'GATE 18-final-check: FAIL'; fails=$((fails+1)); fi + echo "---"; echo "gates: 18 total, $fails failed, $skipped skipped"; return "$fails" +} +if [[ "$CHECK_MODE" -eq 1 ]]; then run_gates; exit $?; fi +if run_gates; then + install -d -m 700 "$STATE_DIR" + printf 'READY dealer=BF-%s host=bf-%s date=%s\n' "$DEALER_ID" "$DEALER_ID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$READY_FILE" + log "OK: ALL REQUIRED GATES PASSED — device READY (BF-${DEALER_ID})" +else + log "FAIL: final-check gates failed — device NOT READY"; exit 1 +fi + + + +#!/usr/bin/env bash +# Blueforce one-click installer — single entry point for field setup. +set -euo pipefail +INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULES_DIR="$INSTALLER_DIR/installer/modules" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +STATE_FILE="$STATE_DIR/install-state" +MODULES=(01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 09-rustdesk 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck 18-final-check) +DEALER_ID="" FROM="" ONLY="" CHECK_MODE=0 RESUME=0 ASSUME_YES=0 +usage() { cat <<'EOF' +Usage: sudo ./blueforce-install.sh --dealer-id <8 digits> [--from NN] [--only NAME] [--check] [--resume] [--yes] +--check is read-only: it creates no logs, state, or directories. +EOF +} +while [[ $# -gt 0 ]]; do case "$1" in --dealer-id) DEALER_ID="${2:-}"; shift 2;; --from) FROM="${2:-}"; shift 2;; --only) ONLY="${2:-}"; shift 2;; --check) CHECK_MODE=1; shift;; --resume) RESUME=1; shift;; --yes|-y) ASSUME_YES=1; shift;; --help|-h) usage; exit 0;; *) echo "Unknown argument: $1" >&2; usage >&2; exit 2;; esac; done +[[ -n "$DEALER_ID" ]] || { echo "Missing --dealer-id (8 digits)" >&2; exit 2; } +[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { echo "Invalid dealer id '$DEALER_ID': must match ^[0-9]{8}$" >&2; exit 2; } +log() { if [[ "$CHECK_MODE" -eq 1 ]]; then printf '[installer check] %s\n' "$1" >&2; else mkdir -p "$(dirname "$LOG_FILE")" "$STATE_DIR"; echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [installer] $1" | tee -a "$LOG_FILE"; fi; } +record_state() { mkdir -p "$STATE_DIR"; local tmp="$STATE_FILE.tmp"; grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$tmp" || true; echo "$1=$2" >> "$tmp"; mv "$tmp" "$STATE_FILE"; } +state_of() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true; } +export DEALER_ID BF_HOSTNAME="bf-${DEALER_ID}" BF_DEVICE="BF-${DEALER_ID}" STATE_DIR +if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then read -r -p "Proceed with installation as $BF_DEVICE? [y/N] " answer; [[ "$answer" =~ ^[Yy]$ ]] || { echo Aborted.; exit 2; }; fi +RUN=(); for m in "${MODULES[@]}"; do + if [[ -n "$ONLY" ]]; then [[ "$m" == "$ONLY" || "$m" == "$ONLY"* ]] && RUN+=("$m"); continue; fi + [[ -n "$FROM" && "${m%%-*}" < "$FROM" ]] && continue + if [[ "$CHECK_MODE" -eq 0 && "$RESUME" -eq 1 && "$(state_of "$m")" == OK ]]; then log "SKIP $m (already OK)"; continue; fi + RUN+=("$m") +done +[[ ${#RUN[@]} -gt 0 ]] || { echo "No modules selected." >&2; exit 2; } +log "=== install start dealer=$BF_DEVICE mode=$([[ "$CHECK_MODE" -eq 1 ]] && echo check || echo apply) modules=${RUN[*]} ===" +failures=0 +for m in "${RUN[@]}"; do + script="$MODULES_DIR/$m.sh"; if [[ ! -x "$script" ]]; then log "FAIL $m (script missing)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); break; fi + args=(); [[ "$CHECK_MODE" -eq 1 ]] && args+=(--check) + set +e; if [[ "$CHECK_MODE" -eq 1 ]]; then LOG_FILE=/dev/null bash "$script" "${args[@]}"; else LOG_FILE="$LOG_FILE" bash "$script"; fi; rc=$?; set -e + case "$rc" in 0) log "OK $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" OK;; 3) log "SKIP $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" SKIP;; *) log "FAIL $m (exit $rc)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); [[ "$CHECK_MODE" -eq 0 ]] && exit 1;; esac +done +[[ "$CHECK_MODE" -eq 1 && "$failures" -gt 0 ]] && exit 1 +log "=== install done dealer=$BF_DEVICE failures=$failures ===" + + + +# Blueforce installer (`scripts/install/`) + +Single entry point: `sudo ./blueforce-install.sh --dealer-id <8 digits>`. + +- Orchestrator: `blueforce-install.sh` (dealer-id gate `^[0-9]{8}$`, + hostname `bf-` + device `BF-`, ordered module runs, `--resume`, + results to `/var/log/blueforce-install.log` + `/var/lib/blueforce/install-state`). +- Modules: `installer/modules/01-*.sh` … `18-*.sh`. Every module is idempotent + and supports `--check` (read-only audit). Exit `0` = OK, `3` = SKIP. +- Required remote-channel preconditions: set per-device `WG_ADDRESS`, `WG_ENDPOINT`, + and `WG_PEER_PUBKEY`; WireGuard readiness requires a peer handshake no older than + `BF_WG_HANDSHAKE_MAX_AGE` seconds (default `180`) unless `REQUIRE_WIREGUARD=false`. + Set `RUSTDESK_SERVER` to a real self-hosted endpoint and provide the pinned local + `RUSTDESK_DEB`; placeholders are rejected. +- Docker published ports default to deny. Add only reviewed `tcp PORT` or `udp PORT` + entries to `/etc/blueforce/docker-published-port-allowlist.conf`; the enabled + `blueforce-docker-firewall.service` reapplies that policy after Docker starts. +- Exit codes: `0` READY · `1` module failure · `2` argument/format error. + +Module map: 01-precheck · 02-system · 03-user · 04-hostname · 05-network · +06-wireguard · 07-ssh · 08-rdp · 09-rustdesk · 10-docker · 11-meg · +12-monitoring · 13-firewall · 14-update-policy · 15-logrotate · 16-gui · +17-healthcheck · 18-final-check. + +NOTE on AnyDesk: there is deliberately no AnyDesk module. Remote access is +covered by SSH-over-WireGuard, xRDP and RustDesk. AnyDesk stays a manual, +per-device exception outside this installer. + + + +#!/usr/bin/env bash +# +# bf-gui-off - Switch the field terminal back to console (multi-user) mode. +# +# Stops graphical services (xRDP + display manager) and isolates +# multi-user.target. Idempotent: already-console systems are left untouched. +# +# Install: install -m 0755 bf-gui-off /usr/local/bin/bf-gui-off +# Usage: sudo bf-gui-off +# +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bf-gui-off [--help] + +Stop the graphical layer and return the device to console mode. + +Options: + --help Show this help and exit + +Behavior: + - Stops xrdp, xrdp-sesman and the display manager (gdm3/gdm/...) + - Disables them so they do not restart on boot + - Runs 'systemctl isolate multi-user.target' +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: bf-gui-off must run as root (use sudo)." >&2 + exit 1 +fi + +log() { echo "[bf-gui-off] $*"; } + +UNITS=(xrdp xrdp-sesman gdm3 gdm sddm lightdm) + +for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${unit}"; then + if systemctl is-active --quiet "${unit}"; then + log "Stopping ${unit} ..." + systemctl stop "${unit}" || log "WARNING: failed to stop ${unit}." + else + log "${unit} already stopped." + fi + systemctl disable "${unit}" >/dev/null 2>&1 || true + fi +done + +log "Isolating multi-user.target ..." +systemctl isolate multi-user.target + +log "GUI is OFF (console mode)." + + + +#!/usr/bin/env bash +# +# bf-gui-on - Switch the field terminal to graphical mode and prepare RDP. +# +# Enables and starts the display manager plus xRDP services, isolates the +# graphical target, then waits until RDP is ready to accept sessions. +# Idempotent: already-graphical systems are left untouched. +# +# Install: install -m 0755 bf-gui-on /usr/local/bin/bf-gui-on +# Usage: sudo bf-gui-on [--timeout SECS] +# +set -euo pipefail + +TIMEOUT=60 +DISPLAY_MANAGER="" + +usage() { + cat <<'EOF' +Usage: bf-gui-on [--timeout SECS] [--help] + +Switch the device to graphical mode and wait until RDP is ready. + +Options: + --timeout SECS Seconds to wait for RDP port 3389 (default: 60) + --help Show this help and exit + +Behavior: + - Enables and starts the display manager (gdm3/gdm) + xrdp + xrdp-sesman + - Runs 'systemctl isolate graphical.target' + - Waits until TCP/3389 accepts connections (RDP ready standby) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --timeout) TIMEOUT="${2:?missing value for --timeout}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: bf-gui-on must run as root (use sudo)." >&2 + exit 1 +fi + +log() { echo "[bf-gui-on] $*"; } + +# Detect installed display manager unit (LAB validates exact names on 26.04.1). +detect_dm() { + local candidate + for candidate in gdm3.service gdm.service sddm.service lightdm.service; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${candidate}"; then + echo "${candidate%.service}" + return 0 + fi + done + return 1 +} + +if DISPLAY_MANAGER="$(detect_dm)"; then + log "Display manager detected: ${DISPLAY_MANAGER}" +else + log "WARNING: no display manager unit found; proceeding with xrdp only." +fi + +UNITS=() +[[ -n "${DISPLAY_MANAGER}" ]] && UNITS+=("${DISPLAY_MANAGER}") +UNITS+=(xrdp xrdp-sesman) + +# Enable units that exist so GUI survives reboot while switched on. +for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${unit}"; then + systemctl enable "${unit}" >/dev/null 2>&1 || true + else + log "WARNING: unit ${unit} not installed; skipping." + fi +done + +CURRENT="$(systemctl get-default 2>/dev/null || echo unknown)" +log "Current default target: ${CURRENT}" + +log "Isolating graphical.target ..." +systemctl isolate graphical.target + +for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${unit}"; then + if ! systemctl is-active --quiet "${unit}"; then + log "Starting ${unit} ..." + systemctl start "${unit}" || log "WARNING: failed to start ${unit}." + else + log "${unit} already active." + fi + fi +done + +# Wait for RDP standby on TCP/3389. +log "Waiting for RDP (TCP/3389) up to ${TIMEOUT}s ..." +elapsed=0 +while [[ "${elapsed}" -lt "${TIMEOUT}" ]]; do + if (exec 3<>/dev/tcp/127.0.0.1/3389) >/dev/null 2>&1; then + exec 3>&- 2>/dev/null || true + log "RDP is ready (graphical ON)." + exit 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +echo "ERROR: RDP port 3389 did not open within ${TIMEOUT}s." >&2 +echo "HINT: check 'systemctl status xrdp xrdp-sesman' and journal." >&2 +exit 1 + + + +#!/usr/bin/env bash +# Parse fleet configuration and validate executable shell scripts without running them. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL=0 +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } + +# Every Bash script under scripts/ must pass syntax parsing. Non-Bash assets are ignored. +while IFS= read -r -d '' script; do + if head -n 1 "$script" | grep -qE '^#!/usr/bin/env bash|^#!/bin/bash'; then + bash -n "$script" && pass "bash parses: ${script#$REPO_ROOT/}" || fail "bash parse error: ${script#$REPO_ROOT/}" + fi +done < <(find "$REPO_ROOT/scripts" -type f -print0) + +# All Ansible and deployable configuration YAML must parse through PyYAML safe_load. +while IFS= read -r -d '' yaml_file; do + if python3 - "$yaml_file" <<'PY' +import sys +import yaml +with open(sys.argv[1], encoding='utf-8') as handle: + yaml.safe_load(handle) +PY + then + pass "yaml parses: ${yaml_file#$REPO_ROOT/}" + else + fail "yaml parse error: ${yaml_file#$REPO_ROOT/}" + fi +done < <(find "$REPO_ROOT/ansible" "$REPO_ROOT/config" "$REPO_ROOT/monitoring" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + +# JSON configuration must parse as JSON, not merely as JavaScript. +while IFS= read -r -d '' json_file; do + if python3 - "$json_file" <<'PY' +import json +import sys +with open(sys.argv[1], encoding='utf-8') as handle: + json.load(handle) +PY + then + pass "json parses: ${json_file#$REPO_ROOT/}" + else + fail "json parse error: ${json_file#$REPO_ROOT/}" + fi +done < <(find "$REPO_ROOT/config" "$REPO_ROOT/monitoring" -type f -name '*.json' -print0) + +# Required values preserve the naming and wave policy in docs/09 and docs/10. +INV="$REPO_ROOT/ansible/inventory/hosts.example.yml" +GV="$REPO_ROOT/ansible/inventory/group_vars/all.yml" +[[ -f "$INV" ]] || fail "missing inventory example: $INV" +[[ -f "$GV" ]] || fail "missing shared group variables: $GV" +for group in lab pilot_1 pilot_2 wave_1 wave_2 production; do + grep -q "^[[:space:]]*$group:" "$INV" || fail "inventory missing wave group: $group" +done +for key in wg_subnet wg_interface monitoring_endpoint meg_image_tag reboot_grace_seconds support_bundle_dir; do + grep -q "^$key:" "$GV" || fail "group_vars/all.yml missing key: $key" +done +for pb in bf-collect-logs.yml bf-gui-on.yml bf-gui-off.yml bf-run-script.yml; do + grep -q '^ hosts: "{{ operation_wave' "$REPO_ROOT/ansible/playbooks/$pb" || fail "$pb must dynamically target operation_wave" + grep -q 'production_override: false' "$REPO_ROOT/ansible/playbooks/$pb" || fail "$pb must default production_override to false" +done +FIREWALL_POLICY="$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" +bash -n "$FIREWALL_POLICY" && pass 'bash parses: config/firewall/blueforce-docker-user-restore.sh' || fail 'bash parse error: config/firewall/blueforce-docker-user-restore.sh' +grep -q '^After=docker.service$' "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" || fail 'Docker firewall service must wait for Docker' +grep -q '^Wants=blueforce-docker-firewall.service$' "$REPO_ROOT/config/systemd/docker.service.d/blueforce-docker-firewall.conf" || fail 'Docker drop-in must start firewall policy' +if grep -RniE --include='*.yml' --include='*.yaml' --include='*.json' '^[[:space:]]*[^#].*(image:[^#]*:latest([[:space:]#]|$)|watchtower)' "$REPO_ROOT/ansible" "$REPO_ROOT/config" "$REPO_ROOT/monitoring"; then + fail "forbidden moving tag or Watchtower configuration found" +fi + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-configs: FAILED\n' >&2 + exit 1 +fi +printf 'check-configs: all passed\n' + + + +#!/usr/bin/env bash +# Validate repository specifications and Ansible safety contracts. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PB="$REPO_ROOT/ansible/playbooks" +ROLES="$REPO_ROOT/ansible/roles" +FAIL=0 +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } + +# The canonical documentation set is exactly docs/00 through docs/25. +mapfile -t DOCS < <(printf '%s\n' "$REPO_ROOT"/docs/[0-9][0-9]-*.md | sort) +if [[ ${#DOCS[@]} -ne 26 ]]; then + fail "expected 26 numbered docs, found ${#DOCS[@]}" +else + for number in $(seq -w 0 25); do + matches=("$REPO_ROOT"/docs/"$number"-*.md) + [[ -f ${matches[0]} ]] || fail "missing numbered doc: $number" + done + pass "26 numbered docs present" +fi + +# Each numbered doc must retain ordered sections 1 through 13 and at least one diagram. +mermaid_total=0 +for doc in "${DOCS[@]}"; do + heading_list="$(grep -E '^## [0-9]+\.' "$doc" | sed -E 's/^## ([0-9]+)\..*/\1/' | paste -sd, -)" + if [[ "$heading_list" != '1,2,3,4,5,6,7,8,9,10,11,12,13' ]]; then + fail "non-sequential numbered headings in ${doc#$REPO_ROOT/}: ${heading_list:-none}" + fi + count="$(grep -c '^```mermaid$' "$doc" || true)" + if [[ "$count" -lt 1 ]]; then + fail "missing Mermaid block in ${doc#$REPO_ROOT/}" + fi + mermaid_total=$((mermaid_total + count)) +done +if [[ "$mermaid_total" -ne 28 ]]; then + fail "expected 28 Mermaid blocks across docs, found $mermaid_total" +else + pass "28 Mermaid blocks present" +fi + +EXPECTED_PLAYBOOKS=(bf-ping.yml bf-uptime.yml bf-reboot.yml bf-docker-status.yml bf-service-restart.yml bf-wireguard-restart.yml bf-rustdesk-restart.yml bf-rdp-restart.yml bf-collect-logs.yml bf-disk-check.yml bf-package-check.yml bf-security-updates-check.yml bf-deploy-update.yml bf-run-script.yml bf-gui-on.yml bf-gui-off.yml) +for pb in "${EXPECTED_PLAYBOOKS[@]}"; do + [[ -f "$PB/$pb" ]] && pass "playbook present: $pb" || fail "missing playbook: $pb" +done + +for role in common wireguard docker monitoring; do + for file in tasks/main.yml defaults/main.yml README.md; do + [[ -f "$ROLES/$role/$file" ]] && pass "role file present: $role/$file" || fail "missing role file: $role/$file" + done +done + +# Safety contracts from docs/09 and docs/10. +grep -q 'serial' "$PB/bf-reboot.yml" || fail 'bf-reboot.yml must batch hosts with serial' +grep -q 'any_errors_fatal: true' "$PB/bf-reboot.yml" || fail 'bf-reboot.yml must halt on errors' +grep -q 'release_wave' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must require a release wave' +grep -q 'wave_gate_confirmed' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must require a wave gate' +grep -q 'halt_on_fail' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must enforce halt_on_fail' +grep -q 'max_fail_percentage: 0' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must stop the wave on failure' +grep -q 'serial: 5' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must use the fixed batch size' +grep -q 'production_override' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must require a separate production override' +grep -q 'ansible_play_hosts_all' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must verify selected hosts against the release wave' +grep -q 'Run mandatory post-batch health gate' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must run a post-batch health gate' +for pb in bf-collect-logs.yml bf-gui-on.yml bf-run-script.yml bf-gui-off.yml; do + grep -q 'operation_wave' "$PB/$pb" || fail "$pb must require operation_wave" + grep -q 'operation_confirmed' "$PB/$pb" || fail "$pb must require operation_confirmed" + grep -q 'ansible_play_hosts_all' "$PB/$pb" || fail "$pb must verify its selected hosts" + grep -q 'production_override' "$PB/$pb" || fail "$pb must require a separate production override" + grep -q "operation_wave != 'production' or production_override | bool" "$PB/$pb" || fail "$pb must refuse production without production_override=true" +done +if grep -q '^ hosts: blueforce$' "$PB/bf-gui-off.yml"; then + fail 'bf-gui-off.yml must not target the blueforce group by default' +fi +grep -q '^operation_wave:' "$REPO_ROOT/ansible/inventory/group_vars/all.yml" || fail 'shared operation_wave variable missing' +grep -q '^operation_confirmed:' "$REPO_ROOT/ansible/inventory/group_vars/all.yml" || fail 'shared operation_confirmed variable missing' +for asset in config/firewall/blueforce-docker-user-restore.sh config/systemd/blueforce-docker-firewall.service config/systemd/docker.service.d/blueforce-docker-firewall.conf; do + [[ -f "$REPO_ROOT/$asset" ]] || fail "missing persistent Docker firewall asset: $asset" +done +grep -q 'systemctl enable "$DOCKER_POLICY_UNIT"' "$REPO_ROOT/scripts/install/installer/modules/13-firewall.sh" || fail 'firewall module must enable persistent Docker firewall unit' +grep -q 'docker.service.d/blueforce-docker-firewall.conf' "$REPO_ROOT/scripts/install/installer/modules/13-firewall.sh" || fail 'firewall module must install Docker restart coupling' +grep -q 'DOCKER_POLICY_BIN" --check' "$REPO_ROOT/scripts/install/installer/modules/13-firewall.sh" || fail 'firewall module check must verify Docker policy rules' +grep -q '13-firewall' "$REPO_ROOT/scripts/install/installer/modules/18-final-check.sh" || fail 'final check must include the firewall rule verification gate' +grep -q 'bash "$SCRIPT_DIR/$g.sh" --check' "$REPO_ROOT/scripts/install/installer/modules/18-final-check.sh" || fail 'final check must execute every gate in check mode' +grep -q 'ctorigdstport' "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" || fail 'Docker firewall must consume published-port allowlist rules' +grep -q 'iptables -C DOCKER-USER' "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" || fail 'Docker firewall check must verify every installed allowlist rule' +grep -q -- '-i br+ -j RETURN' "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" || fail 'Docker firewall must permit egress from user-defined bridge interfaces' +grep -q '^BindsTo=docker.service$' "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" || fail 'Docker firewall service must bind to Docker lifecycle' +grep -q '^PartOf=docker.service$' "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" || fail 'Docker firewall service must restart with Docker' +grep -q '^Wants=blueforce-docker-firewall.service$' "$REPO_ROOT/config/systemd/docker.service.d/blueforce-docker-firewall.conf" || fail 'Docker must start the firewall service on every start' +grep -q 'BF_WG_HANDSHAKE_MAX_AGE' "$REPO_ROOT/scripts/install/installer/modules/06-wireguard.sh" || fail 'WireGuard module must enforce handshake freshness' +grep -q 'configured ||' "$REPO_ROOT/scripts/install/installer/modules/09-rustdesk.sh" || fail 'RustDesk module must prove client configuration' +grep -q 'service_allowlist' "$PB/bf-service-restart.yml" || fail 'bf-service-restart.yml must enforce an allowlist' +grep -q 'script_allowed_dirs' "$PB/bf-run-script.yml" || fail 'bf-run-script.yml must enforce allowed script paths' +grep -Fq "collect_logs_dir is match('^/var/lib/blueforce/support-bundles" "$PB/bf-collect-logs.yml" || fail 'bf-collect-logs.yml must restrict bundles to the approved support directory' +if [[ "$(grep -Fc '| quote' "$PB/bf-collect-logs.yml")" -lt 2 ]]; then + fail 'bf-collect-logs.yml must shell-quote every generated bundle path' +fi + +if grep -RniE --include='*.yml' '^[[:space:]]*[^#].*(image:[^#]*:latest([[:space:]#]|$)|watchtower)' "$PB" "$ROLES/docker"; then + fail "forbidden moving tag or Watchtower configuration found in deploy paths" +else + pass 'no moving tags or auto-updaters in deploy paths' +fi + +if grep -qniE 'state:[[:space:]]*(present|latest)|apt-get .*(upgrade|dist-upgrade)[[:space:]]+-y|apt:.*name:' "$PB/bf-security-updates-check.yml"; then + fail 'bf-security-updates-check.yml must remain report-only' +else + pass 'security update check is report-only' +fi + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-specs: FAILED\n' >&2 + exit 1 +fi +printf 'check-specs: all passed\n' + + + +# Semaphore Community limits (working rules for the Blueforce fleet) + +Source: `docs/09-FLEET-MANAGEMENT.md` section 9, "Semaphore Community sinirlari". +This file restates the limits in English as an executable checklist for +playbook authors and Semaphore operators. No Pro/Enterprise feature may be +used anywhere in `ansible/` or in any Semaphore task template. + +## Limits + +1. No OIDC/SSO: Semaphore users are local accounts; access only behind WireGuard. +2. No 2FA: password policy + mandatory VPN compensates; admin accounts are inventoried. +3. No external Vault integration: secrets live in the Semaphore built-in encrypted + Key Store; production secrets are never embedded in playbooks. +4. Concurrency limits (runners/forks): load-test against 700 nodes first, then tune + runner count + Ansible `serial`/`forks`/batch settings. Destructive playbooks + (`bf-reboot.yml`, `bf-deploy-update.yml`) already enforce `serial` + + `max_fail_percentage: 0` + `any_errors_fatal: true`. +5. No workflow may depend on a Pro-only feature. Anything that does not run on + Community is rejected in review (`tests/check-specs.sh` greps for it). + +## Operator checklist + +- [ ] Semaphore reachable only via WireGuard. +- [ ] Every destructive template runs `--check` dry run + `--limit ` first. +- [ ] Waves follow docs/10: lab(2) -> pilot-1(5) -> pilot-2(20) -> wave-1(50) -> wave-2(100) -> production. +- [ ] Secrets referenced from Key Store, never committed to Git. +- [ ] `tests/check-specs.sh` and `tests/check-configs.sh` pass before merge. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +# + +> Kısa özet: bu doküman neyi kapsar, kimin içindir, hangi kararı verir. (2–3 cümle) + +- Dosya: `docs/NN-KONU.md` +- İlgili kararlar: `24-DECISION-LOG.md#` +- Durum: [ ] Taslak / [ ] İncelemede / [ ] Onaylı + +--- + +## 1. Amaç + +Bu dokümanın neden var olduğu, hangi sorunu çözdüğü. + +## 2. Kapsam + +Kapsam içi ve kapsam dışı maddeler: + +- Kapsam içi: ... +- Kapsam dışı: ... + +## 3. Kararlar + +Her teknik karar için aşağıdaki karar bloğunu kullan: + +```text +KARAR: +GEREKÇE: +ALTERNATİF: +RİSK: +MALİYET: <Ücretsiz / Ücretli (işaretli) + not> +LİSANS: +``` + +Örnek: + +```text +KARAR: Filo yönetiminde Ansible + Semaphore UI Community kullanılır. +GEREKÇE: Agent gerektirmez, SSH tabanlıdır, 700 cihaz ölçeğinde yeterlidir. +ALTERNATİF: SaltStack — daha karmaşık kurulum nedeniyle elendi. +RİSK: Playbook hatası filoya yayılabilir; önce LAB(2) grubunda çalıştırılır. +MALİYET: Ücretsiz (self-hosted). +LİSANS: Semaphore Community (kaynak doğrulanacak) — URL + sürüm + tarih eklenecek. +``` + +## 4. Neden Bu Karar? + +Kararların arkasındaki teknik ve operasyonel gerekçeler. Sadelik kuralı burada denetlenir: Kubernetes, microservice, gereksiz agent/DB/cloud öneriliyorsa neden reddedildiği yazılır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| ... | ... | ... | Elendi / Yedek | + +## 6. Avantajlar + +- ... + +## 7. Dezavantajlar + +- ... + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| ... | Düşük/Orta/Yüksek | ... | ... | + +## 9. Uygulama Planı + +Numaralı adımlar; komutlar İngilizce orijinal adıyla, açıklamalar Türkçe: + +1. ... +2. ... + +```bash +# örnek komut bloğu +hostnamectl set-hostname bf-12010193 +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| ... | ... | LAB(2) | + +## 11. Rollback + +Başarısızlıkta önceki duruma dönüş adımları: + +1. ... +2. ... + +## 12. Kontrol Listesi + +- [ ] ... +- [ ] ... + +## 13. Açık Sorular + +- [ ] ... (sahibi + tarih ile) + +--- + +## Ek: Mermaid Örneği + +```mermaid +flowchart LR + Saha["bf-12010193
(saha PC'si)"] -->|WireGuard| Merkez["Merkez sunucu"] + Merkez --> Ansible["Ansible/Semaphore"] + Merkez --> Monitor["Monitoring"] +``` +
+ + +# 00 — Ana Plan (Master Plan) + +> Kısa özet: 700 cihazlık Blueforce Linux filosunun uçtan uca planı: hangi doküman neyi kapsar, fazlar nasıl ilerler, tüm işler hangi kararlara dayanır. Projeye yeni katılan herkes önce bu dosyayı okur. + +- Dosya: `docs/00-MASTER-PLAN.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-01…K-14` (tüm fazlar bu kararlara dayanır) +- Durum: [x] Onaylı (Faz 2 kilidi) + +--- + +## 1. Amaç + +700 saha PC'sinin Ubuntu tabanlı standardize filoya dönüşümünü tek planda toplamak: kapsam, fazlar, doküman haritası ve başarı kriterleri. Parça işlerin (imaj, kurucu, ağ, filo, monitoring) birbirine nasıl bağlandığı bu dosyadan izlenir. + +## 2. Kapsam + +- Kapsam içi: faz planı (LAB → PİLOT → WAVE → PROD), doküman haritası (00…24), karar bağımlılıkları (K-01…K-14), dalga boyutları ve kapı kriterleri. +- Kapsam dışı: kurulum komutları (03/04/05), playbook ve metrik detayları (09/14), VDS kurulum adımları (07/08) — ilgili dokümanlara havale. + +## 3. Kararlar + +```text +KARAR: Proje Faz 2'de kilitlenen 14 karara (K-01…K-14) göre yürütülür; karar değişikliği yalnızca 24-DECISION-LOG PR'ı ile yapılır. +GEREKÇE: 700 cihazda tutarlılığın tek dayanağı yazılı karardır; her doküman kararlara linklenir, varsayıma dayalı seçim bırakılmaz. +ALTERNATİF: Doküman başına bağımsız karar — çelişki ve tekrara yol açtığı için elendi. +RİSK: Karar kilidi gelişmeyi yavaşlatabilir; azaltma: LAB/PİLOT bulguları PR ile hızlı işletilir. +MALİYET: Ücretsiz (süreç kararı). +LİSANS: Yok (yöntem kararı). +``` + +```text +KARAR: Dağıtım dalgaları: LAB(2) → PİLOT-1(5) → PİLOT-2(20) → WAVE-1(50) → WAVE-2(100) → PROD (kalan ~523). +GEREKÇE: Onaysız update yasağı (K-11) gereği her değişiklik küçük dalgada kanıtlanmadan büyüğe geçmez; blast-radius üstten sınırlanır. +ALTERNATİF: Tek seferde tüm filo — tek hatanın 700 cihaza yayılması nedeniyle elendi. +RİSK: Dalga disiplini atlanırsa hata büyür; azaltma: Semaphore'da onay adımı + 10-UPDATE kapı kriterleri. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Öncelik sırası (kesintisiz çalışma > veri kaybı yok > uzaktan erişim > bakım kolaylığı) planı şekillendirdi. Tüm kararlarda üç filtre uygulandı: (1) %100 ücretsiz/self-hosted, (2) sadelik (K8s, gereksiz agent/DB/cloud yok), (3) onaysız update yasağı. 700 ölçeğinde her küçük varsayılan filo-çapı arızaya dönüşür; fazlar bu çarpanı küçültür. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Big-bang dağıtım | Hızlı | Tek hata = 700 cihaz arızası | Elendi | +| K8s-orkestrasyonlu saha | Esnek ölçek | 700 mini PC'de gereksiz yük | Elendi | +| Cloud-merkezli yönetim | Hızlı kurulum | Kota/lisans maliyeti, veri egemenliği | Elendi | + +## 6. Avantajlar + +- Tek kimlik (`BF-`/`bf-`) tüm katmanlarda join anahtarı — envanterden monitoring'e aynı ad. +- Her fazın giriş/çıkış kriteri yazılı; "hazır" demek ölçülebilir. +- Hiçbir faz ücretli lisansa dayanmaz (FREE sürümler baz). + +## 7. Dezavantajlar + +- Faz disiplini ilk kurulumu yavaşlatır (LAB+PİLOT ölçümleri beklenir). +- Merkez servis sayısı ilk kurulumda tek VDS'e sığsa da yük testinden geçmeli (sahibi: 25-ROADMAP). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Faz atlanarak sahaya çıkılması | Orta | Yüksek | Kapı kriterleri + Semaphore onay adımı | +| Merkez VDS kesintisi | Düşük | Yüksek | Hızlı yeniden kurulum + yedek VDS planı (17) | +| Heterojen donanımda imaj kırılması | Orta | Orta | Envanter taraması + LAB profil testi (04) | + +## 9. Uygulama Planı + +1. Faz 1 (Zemin): karar kilidi (24) + mimari (01) + bu plan — tamamlandı. +2. Faz 2 (İmaj+Kurucu): 02-03-04-05 dokümanları + `blueforce-install.sh` + LAB(2) imaj testi. +3. Faz 3 (Merkez): WireGuard hub → hbbs/hbbr → MeshCentral → Semaphore → Prometheus/Grafana/Kuma (07/08/09/14). +4. Faz 4 (PİLOT): P1(5)+P2(20) cihazda 7/24 sayaçlar (kopma, log hacmi, metrik kardinalitesi). +5. Faz 5 (WAVE+PROD): W1(50) → W2(100) → PROD (~523), her dalgada 10-UPDATE onay zinciri. + +```bash +# kapı örneği: LAB'dan P1'e geçiş koşulu +./scripts/install/blueforce-install.sh --check # exit 0 olmadan dalga büyümez +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Her dokümanın karar linkleri | Tüm karar blokları 24'teki K-no'ya bağlı | Repo CI (markdown-link-check) | +| LAB(2) uçtan uca akış | Erişim + boot + update zinciri çalışır | LAB(2) | +| Dalga kapı kriterleri | Kriter sağlanmadan sonraki dalga açılmaz | P1/P2/W1/W2 | + +## 11. Rollback + +1. Plan değişikliği 24-DECISION-LOG PR'ı ile yapılır; bağımlı dokümanlar aynı PR'da güncellenir. +2. Sahaya sürülmüş dalga hatalıysa 10-UPDATE rollback zinciri işletilir. +3. Son çare: karar öncesi imaj sürümüne dönüş (17-RECOVERY LEVEL 9). + +## 12. Kontrol Listesi + +- [ ] 14 kararın her biri ≥1 dokümana ve faza bağlı. +- [ ] Dalga boyutları tüm dokümanlarda tutarlı (2/5/20/50/100/~523). +- [ ] FREE sürüm kuralı tüm fazlarda korunuyor. +- [ ] `SUMMARY.md` Faz 4'te 27 soru → karar izlenebilirliğiyle üretilecek. + +## 13. Açık Sorular + +- [ ] Merkez servislerin tek VDS'e sığıp sığmadığı — LAB yük testi (sahibi: 25-ROADMAP). +- [ ] Faz takviminin bayi rollout kapasitesine göre netleşmesi (sahibi: 18-ROLLOUT). + +--- + +## Ek: Doküman Haritası + +```mermaid +flowchart LR + P0["00 Master Plan
(bu dosya)"] --> ARCH["01 Mimari
24 Karar Log"] + ARCH --> ZEMIN["02 Kimlik + Envanter
03 Ubuntu Baseline
04 Golden Image
05 Tek-Kurucu"] + ZEMIN --> MERKEZ["06 SSH 07 Erişim 08 WireGuard
09 Filo 11 Hardening"] + MERKEZ --> OPS["10 Update 12 Docker
14 Monitoring 15 Logging 16 Boot
17 Recovery 18 Rollout"] + OPS --> GOV["19 Test 20 Runbook 21 CI
22 Docs 23 Lisans"] +``` + +| Doküman | Konu | Dayandığı karar | +|---|---|---| +| 01 | Sistem mimarisi | K-01…K-14 | +| 02 | Cihaz adlandırma + envanter | K-12 | +| 03 | Ubuntu baseline | K-01, K-02, K-03, K-11 | +| 04 | Golden image + provisioning | K-09 | +| 05 | Tek-tık kurucu | K-09, K-11, K-12 | +| 06–09 | SSH, erişim, WireGuard, filo | K-13, K-04, K-05, K-06 | +| 10–12 | Update, hardening, Docker | K-11, K-10 | +| 13–16 | MEG, monitoring, logging, boot | K-02, K-07, K-14, K-03 | +| 17–18 | Recovery, rollout | K-04, K-09 | +| 19–23 | Test, runbook, CI, docs, lisans | K-08 + tümü | +| 24 | Karar günlüğü | Kilit kaynak | +
+ + +# 01 — Sistem Mimarisi + +> Kısa özet: 700 saha cihazlı Blueforce filosunun uçtan uca mimarisi: saha PC'si, WireGuard ağı, 4 erişim kanalı, filo otomasyonu, monitoring ve update zinciri tek bakışta. Kararların görsel karşılığıdır. +> +> - Dosya: `docs/01-ARCHITECTURE.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-01…K-14` (tüm mimari bu kararlara dayanır) +> - Durum: [x] Onaylı (Faz 2 kilidi) + +--- + +## 1. Amaç + +Başka bir DevOps mühendisinin filoyu tek dosyadan anlayıp kurmaya başlayabilmesi: hangi bileşen nerede koşar, trafik nasıl akar, cihaz nasıl boot eder, update nasıl yayılır. + +## 2. Kapsam + +- Kapsam içi: saha cihazı katmanları, merkez (VDS) servisleri, ağ/erişim akışı, boot zinciri, update onay akışı. +- Kapsam dışı: kurulum komutları (03/04/05), playbook detayları (09), metrik listesi detayı (14) — ilgili dokümanlara havale. + +## 3. Kararlar + +```text +KARAR: Filo mimarisi: saha = Ubuntu Server 26.04.1+ GNOME + Docker(MEG) + WireGuard spoke; merkez = WireGuard hub + RustDesk hbbs/hbbr + MeshCentral + Ansible/Semaphore + Prometheus/Grafana + Uptime Kuma (hepsi self-hosted, ücretsiz katman). +GEREKÇE: 24-DECISION-LOG K-01…K-08'in birleşimi; hareketli parça en aza indirildi (agent'sız filo, tek Go binary UI, tek konteyner uptime), iki WireGuard-bağımsız erişim kanalıyla tek-nokta körlüğü engellendi. +ALTERNATİF: K8s-tabanlı yönetim / Cloud-merkezli monitoring — sadelik kuralını bozduğu için elendi (bkz. 24 §4). +RİSK: Merkez VDS tek fiziksel nokta; azaltma: yedekleme + hızlı yeniden kurulum (17-RECOVERY), kritik kanalların ayrı VDS'e taşınabilirliği. +MALİYET: Ücretsiz (lisans $0; VDS kira bedeli altyapı maliyetidir). +LİSANS: Bileşen lisansları için 24-DECISION-LOG Ek A'ya bakılır. +``` + +## 4. Neden Bu Karar? + +Öncelik sırası (kesintisiz çalışma > veri kaybı yok > uzaktan erişim > bakım kolaylığı) mimariyi şekillendirdi: boot zinciri her katmanda otomatik toparlanır (systemd + `unless-stopped` + `wg-quick` enable), erişim 4 kanalla yedeklenir (ikisi WireGuard-bağımsız), update'ler onay zincirinden geçmeden sahaya inmez. Hiçbir katman ücretli lisansa dayanmaz. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Tek kanal (yalnızca SSH) | En sade | Tünel çökerse körlük | Elendi | +| Cloud monitoring (Netdata Cloud vb.) | Hızlı kurulum | 5 node free kotası, 700 cihaz ücretli | Elendi | +| K8s-orkestrasyonlu saha | Ölçek esnekliği | 700 mini PC'de gereksiz yük | Elendi | + +## 6. Avantajlar + +- Tek kimlik (`BF-`/`bf-`) tüm katmanlarda join anahtarı — envanterden monitoring'e aynı ad. +- Her akış (erişim/boot/update) diyagramla sabit; runbook'lar bu diyagramlara prosedür yazar. + +## 7. Dezavantajlar + +- Merkez servis sayısı (hub + 2 erişim + fleet + 2 monitoring + docs) ilk kurulumda tek VDS'e sığsa da yük testinden geçmeli. +- Diyagramlar sözleşmedir: bileşen değişirse 3 Mermaid de aynı PR'da güncellenmeli. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Merkez VDS kesintisi | Düşük | Yüksek | Hızlı yeniden kurulum + yedek VDS planı (17) | +| Boot zincirinde race (Docker WG'den önce kalkar) | Orta | Orta | systemd bağımlılıkları + After= kuralları (16) | +| Onaysız update zinciri bypass eder | Düşük | Yüksek | K-11 kilitleri + Semaphore'da onay adımı (10) | + +## 9. Uygulama Planı + +1. Merkez VDS: WireGuard hub → hbbs/hbbr → MeshCentral → Semaphore → Prometheus/Grafana/Kuma sırasıyla kurulur. +2. Saha imajı: Ubuntu Server → GNOME → Docker → WireGuard spoke → RustDesk/MeshCentral agent → Node Exporter. +3. İlk 2 LAB cihazı bu mimariye göre uca eklenir, 3 akış (erişim/boot/update) testlenir. + +```bash +# örnek: cihaz kimliği standardı (K-12) +hostnamectl set-hostname bf-12010193 +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| 3 Mermaid'deki her ok için bağlantı testi | Uçtan uca akış çalışır | LAB(2) | +| VDS down simülasyonu (hbbs kapalı) | SSH-over-WG + MeshCentral ayakta | P1(5) | +| Güç kesintisi simülasyonu | Boot zinciri sırayla toparlanır | LAB(2) | + +## 11. Rollback + +Mimari değişikliği 24-DECISION-LOG PR'ı ile yapılır; bu dosyanın diyagramları aynı PR'da güncellenir. Yanlış bileşen seçimi sahaya yayılmadan LAB'da geri alınır (10-UPDATE rollback zinciri). + +## 12. Kontrol Listesi + +- [ ] 3 Mermaid bloğu render oluyor (Docusaurus + GitHub preview). +- [ ] Her bileşen adının kararı 24'te var (K-01…K-14). +- [ ] `bf-` / `BF-` adlandırması diyagramlarda tutarlı. + +## 13. Açık Sorular + +- [ ] Merkez servislerin tek VDS'e sığıp sığmadığı — LAB yük testi (sahibi: 25-ROADMAP). +- [ ] Prometheus federasyonu gerekip gerekmediği — 700 node ölçümü (sahibi: 14-MONITORING). + +--- + +## Ek: Mermaid — 1. Erişim ve yönetim akışı (Internet → WireGuard → SSH/RDP/Mgmt) + +```mermaid +flowchart LR + subgraph SAHA["Saha (bf-)"] + PC["Ubuntu Server 26.04.1
GNOME + Docker/MEG"] + end + subgraph MERKEZ["Merkez VDS"] + WG["WireGuard hub"] + SEM["Ansible/Semaphore"] + MON["Prometheus/Grafana
Uptime Kuma"] + RD["RustDesk hbbs/hbbr"] + MC["MeshCentral"] + end + PC -->|Internet: WG tüneli
PersistentKeepalive=25| WG + WG --> SEM + WG --> MON + WG -->|SSH 22, RDP 3389
yalnızca tünel içi| PC + PC -->|Internet doğrudan
VDS genel adresi| RD + PC -->|Internet doğrudan
VDS genel adresi| MC + RD -.->|WG çökse bile
ayakta| PC + MC -.->|WG çökse bile
ayakta| PC +``` + +## Ek: Mermaid — 2. Power boot zinciri + +```mermaid +flowchart TB + PWR["Power ON
(BIOS: Restore on AC Power Loss = Power On)"] --> BIOS["BIOS/UEFI"] + BIOS --> UB["Ubuntu Server 26.04.1
multi-user terminal boot"] + UB --> NET["Network
(NetworkManager/systemd-networkd)"] + NET --> WGV["WireGuard
(wg-quick@wg0 enable)"] + WGV --> DKR["Docker daemon
(log rotasyonlu)"] + DKR --> MEG["MEG konteyneri
(unless-stopped)"] + MEG --> BF["Blueforce servisleri"] + BF --> REM["Remote: SSH + xRDP +
RustDesk + MeshCentral agent"] + REM --> MON["Monitoring:
Node Exporter + Uptime Push"] +``` + +## Ek: Mermaid — 3. Update onay akışı + +```mermaid +flowchart LR + F["Bulundu
(güvenlik bülteni / MEG sürümü)"] --> R["Rapor
(etki + geri alma planı)"] + R --> O{"Onay
(merkezi admin)"} + O -->|Red| F + O -->|Onay| LAB["LAB 2 cihaz"] + LAB -->|Başarılı| P1["PILOT-1 5 cihaz"] + P1 -->|Başarılı| P2["PILOT-2 20 cihaz"] + P2 -->|Başarılı| W1["WAVE-1 50 cihaz"] + W1 -->|Başarılı| W2["WAVE-2 100 cihaz"] + W2 -->|Başarılı| PROD["PRODUCTION
kalan ~523 cihaz"] + LAB -->|Başarısız| RB["Rollback
(10-UPDATE zinciri)"] + P1 -->|Başarısız| RB + P2 -->|Başarısız| RB + W1 -->|Başarısız| RB + W2 -->|Başarısız| RB +``` +
+ + +# 02 — Cihaz Adlandırma ve Envanter + +> Kısa özet: 8 haneli bayi numarasından türetilen tek cihaz kimliği (`BF-`/`bf-`) standardı ve 9 sistemde kullanımı; donanım taraması yapan `bf-hardware-inventory.sh` sözleşmesi. Filoda join anahtarıdır. + +- Dosya: `docs/02-DEVICE-NAMING-AND-INVENTORY.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-12` (cihaz kimliği), `K-09` (envanter önce, imaj sonra) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihazda tek, okunabilir, çakışmasız kimlik standardı kurmak ve bu kimliğin 9 sistemde birebir aynı değeri taşımasını sağlamak. Bayi numarası değişirse ne yapılacağı da bu dosyanın prosedürüdür. + +## 2. Kapsam + +- Kapsam içi: `BF-`/`bf-` formatı, doğrulama kuralı, 9 sistemdeki kullanım tablosu, `bf-hardware-inventory.sh` JSON+Markdown sözleşmesi, yeniden-numaralandırma prosedürü. +- Kapsam dışı: hostname atama komut akışı detayı (05-ONE-CLICK-INSTALLER), Ansible inventory şeması detayı (09-FLEET), monitoring etiket şeması detayı (14-MONITORING). + +## 3. Kararlar + +```text +KARAR: 8 haneli bayi no → Device ID `BF-` (büyük, insan arayüzü) + hostname `bf-` (küçük, makine adı); WireGuard peer adı, RustDesk etiketi, Ansible inventory adı, monitoring etiketi hepsi aynı ID'yi kullanır. +GEREKÇE: Tek kimlik 9 sistemde join anahtarıdır; büyük/küçük ayrımı insan-makine karışıklığını önler (insan `BF-`, makine `bf-`). Örnek: `BF-12010193` / `bf-12010193`. +ALTERNATİF: Rastgele UUID / MAC-tabanlı ad — saha teknisyeni için okunamaz, bayi eşleşmesi manuel olur → elendi. +RİSK: Bayi no değişirse kimlik çakışması; azaltma: §9'daki yeniden-no prosedürü + envanter benzersizlik kontrolü. +MALİYET: Ücretsiz. +LİSANS: Yok (isimlendirme standardı). +``` + +```text +KARAR: Donanım taraması `bf-hardware-inventory.sh` ile yapılır; çıktı hem JSON (makine) hem Markdown (insan) üretir; zorunlu alanlar §9'daki sözleşmededir. +GEREKÇE: Heterojen saha donanımı bilinmeden golden image dondurulamaz (K-09); tarama LAB/PİLOT öncesi her profili belgeler, imaj test matrisine girer. +ALTERNATİF: Manuel envanter tablosu — 700 cihazda eksik/hatalı veri riski nedeniyle elendi. +RİSK: Script farklı donanımda alan boş bırakır; azaltma: §10'daki şema doğrulama testi. +MALİYET: Ücretsiz. +LİSANS: Yok (şirket içi script). +``` + +## 4. Neden Bu Karar? + +Bayi numarası sahada zaten bilinen tek anahtardır; yeni bir numara üretmek eşleşme maliyeti doğurur. 8 hane sabit uzunluk (`^[0-9]{8}$`) yazım hatasını format düzeyinde yakalar. 9 sistemde aynı adın kullanılması, arıza anında "hangi cihaz?" sorusunu tek cevaba indirir: `BF-12010193` denen cihaz her panelde aynı cihazdır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| UUID | Çakışmasız | Okunamaz, bayi eşleşmesi manuel | Elendi | +| MAC-tabanlı ad | Donanıma bağlı benzersiz | Ağ kartı değişince ad değişir, okunamaz | Elendi | +| Seri-no tabanlı ad | Üretici garantili | Format üreticiye göre değişir, 9 sistemde normalize gerekir | Elendi | + +## 6. Avantajlar + +- Tek format kuralı (`^[0-9]{8}$`) tüm giriş noktalarında (kurucu, Ansible, envanter) aynı regex ile doğrulanır. +- Büyük/küçük ayrımı log aramalarında ve etiketlerde karışıklığı önler. +- Envanter JSON'u monitoring ve Ansible'a doğrudan beslenebilir. + +## 7. Dezavantajlar + +- Bayi no değişimi 9 sistemde atomik güncelleme gerektirir (prosedür §9). +- 8 hane dışı gerçek no'lar (kısa/uzun) sıfır-dolgu kuralına ihtiyaç duyar — kural: soldan sıfırla 8 haneye tamamlanır. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Yinelenen bayi no (çift kayıt) | Düşük | Yüksek | Merkezi envanterde benzersizlik kontrolü, kurucu kaydı reddeder | +| Bayi no değişiminde eski adın artıkları | Orta | Orta | Yeniden-no prosedürü (§9) + eski adın 30 gün alias tutulması | +| Envanter JSON şema kayması | Düşük | Orta | Şema sürümü (`schema_version`) + CI doğrulama | + +## 9. Uygulama Planı + +1. Bayi no doğrulama kuralı: `^[0-9]{8}$` — uymayan girdi reddedilir, kurucu devam etmez (05'teki akış). +2. 9 sistemde kimlik kullanımı: + +| # | Sistem | Kullanılan değer | Örnek | +|---|---|---|---| +| 1 | DNS / hostname | `bf-` | `bf-12010193` | +| 2 | WireGuard peer adı | `bf-` | `bf-12010193` | +| 3 | RustDesk cihaz etiketi | `BF-` | `BF-12010193` | +| 4 | MeshCentral cihaz adı | `BF-` | `BF-12010193` | +| 5 | Ansible inventory adı | `bf-` | `bf-12010193` | +| 6 | Prometheus/Grafana etiketi (`device_id`) | `BF-` | `BF-12010193` | +| 7 | Log alanı (`device_id`) | `BF-` | `BF-12010193` | +| 8 | Merkezi envanter kaydı (anahtar) | `BF-` | `BF-12010193` | +| 9 | Runbook/arıza kaydı başlığı | `BF-` | `BF-12010193` | + +3. `bf-hardware-inventory.sh` sözleşmesi — çalıştırma ve çıktılar: + +```bash +# saha PC'sinde (kurucu öncesi veya bağımsız tarama) +sudo ./bf-hardware-inventory.sh --dealer-id 12010193 --out-dir /var/lib/blueforce/inventory +# çıktılar: +# /var/lib/blueforce/inventory/BF-12010193.json +# /var/lib/blueforce/inventory/BF-12010193.md +``` + +JSON zorunlu alanları (`schema_version: 1`): + +```json +{ + "schema_version": 1, + "device_id": "BF-12010193", + "hostname": "bf-12010193", + "dealer_id": "12010193", + "collected_at": "2026-09-15T10:00:00+03:00", + "cpu": {"model": "...", "cores": 4, "arch": "x86_64"}, + "memory_mb": 8192, + "disks": [{"name": "sda", "size_gb": 256, "type": "ssd"}], + "network": {"macs": ["..."], "wireless": true}, + "gpu": {"model": "..."}, + "bios": {"vendor": "...", "version": "...", "ac_restore": "Power On"}, + "os": {"distro": "Ubuntu", "version": "26.04.1"}, + "notes": "" +} +``` + +Markdown çıktısı aynı alanların insan-okunur tablosudur (başlık: `# Donanım Envanteri — BF-12010193`, her bölüm tablo satırı). + +4. Yeniden-numaralandırma prosedürü (bayi no değişirse): + 1. Eski `BF-` kaydı envanterde `retired` işaretlenir, 30 gün alias tutulur. + 2. Yeni no ile kurucu yeniden koşar (`blueforce-install.sh --dealer-id `). + 3. WireGuard peer, RustDesk etiketi, MeshCentral adı, Ansible inventory, monitoring etiketi yeni ID'ye taşınır; eski peer anahtarı iptal edilir. + 4. Alias süresi dolunca eski kayıt arşivlenir. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `^[0-9]{8}$` dışı girdi (7/9 hane, harf) | Kurucu reddeder, exit ≠ 0 | LAB(2) | +| JSON şema doğrulama (`schema_version`, zorunlu alanlar) | Eksik alan = hata | LAB(2) | +| Yinelenen no ile kayıt | Merkezi envanter reddeder | LAB(2) | +| 9 sistemde ad tutarlılığı | Tümü aynı no'yu taşır | P1(5) | + +## 11. Rollback + +1. Yanlış no ile kurulan cihaz: `blueforce-install.sh --dealer-id ` idempotent yeniden koşar, hostname + peer + etiketler düzelir. +2. Envanter kaydı hatalıysa JSON yeniden üretilir, merkezi kayıt güncellenir; eski sürüm arşivde tutulur. + +## 12. Kontrol Listesi + +- [ ] Format regex'i kurucu + envanter + Ansible'da aynı (`^[0-9]{8}$`). +- [ ] JSON zorunlu alan listesi script ile eşleşiyor. +- [ ] 9 sistem tablosundaki her satırın sahibi doküman linkli (06/07/08/09/14/15/20). +- [ ] Yeniden-no prosedürü runbook'a işlendi. + +## 13. Açık Sorular + +- [ ] 8 haneden kısa/uzun gerçek no var mı — saha listesiyle doğrulanacak (sahibi: 18-ROLLOUT). +- [ ] Merkezi envanterin fiziksel konumu (VDS'de dosya mı, Semaphore Key Store mu) (sahibi: 09-FLEET yazarı). + +--- + +## Ek: Kimlik Akışı + +```mermaid +flowchart LR + BAYI["Bayi no
12010193"] -->|^[0-9]8$| KURUCU["blueforce-install.sh"] + KURUCU --> H["hostname
bf-12010193"] + KURUCU --> WG["WireGuard peer
bf-12010193"] + KURUCU --> ENV["Envanter JSON/MD
BF-12010193"] + ENV --> ANS["Ansible inventory"] + ENV --> MON["Prometheus etiketi"] + ENV --> RD["RustDesk / MeshCentral"] +``` +
+ + +# 03 — Ubuntu Baseline + +> Kısa özet: Saha PC'lerinin standart zemini: Ubuntu Server 26.04.1+ LTS minimal kurulum, GNOME sonradan eklenir, terminale boot edilir, `unattended-upgrades` kapalıdır. BIOS güç-kurtarma ayarı ve GUI aç/kapa anahtarları bu dosyadadır. + +- Dosya: `docs/03-UBUNTU-BASELINE.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-01` (OS/sürüm), `K-02` (GNOME), `K-03` (boot + bf-gui), `K-11` (otomatik update kapalı) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Her saha PC'sinin aynı minimal zeminden başlamasını garanti etmek: hangi ISO, hangi masaüstü, hangi boot hedefi, hangi servisler açık/kapalı. Bu zemin 04'teki imajın ve 05'teki kurucunun varsayımıdır. + +## 2. Kapsam + +- Kapsam içi: ISO seçimi, BIOS güç-kurtarma ayarı, boot hedefi (multi-user), GUI katmanı (GNOME + xRDP + `bf-gui-*`), `unattended-upgrades` kapatma, açılışta koşan servis listesi. +- Kapsam dışı: imaj üretim yöntemi (04), kurucu modül detayları (05), WireGuard/SSH sertleştirme (06/08/11). + +## 3. Kararlar + +```text +KARAR: Saha standardı Ubuntu Server 26.04.1+ LTS'tir (GUI'siz minimal kurulum; GNOME sonradan eklenir). +GEREKÇE: 26.04.1 LTS resmi indirme sayfasında yayında (kod adı Resolute Raccoon); Server ISO'su varsayılan olarak GUI kurmaz, minimal zemin + ihtiyaca göre GUI standart yoldur. Noktasürüm (.1+) baz alınır çünkü ilk .0 dalgasındaki kurulumcu hataları genelde .1'de toplanır. +ALTERNATİF: Ubuntu 24.04 LTS — 26.04 doğrulanamasaydı yedekti; doğrulandığı için yedekte bekler. Ubuntu Desktop 26.04 (6 GB RAM şartı) saha bütçesini zorladığı için elendi. +RİSK: 26.04.x noktasürümlerinde donanım sürücü farkı; azaltma: golden image LAB(2)'de her saha donanım profilinde test edilir. +MALİYET: Ücretsiz. +LİSANS: Ubuntu LTS ücretsiz (ESM kapsamına bel bağlanmaz) — https://releases.ubuntu.com/ (26.04.1, doğrulanma: 2026-09-15) + https://ubuntu.com/download/desktop (sistem gereksinimi, doğrulanma: 2026-09-15). +``` + +```text +KARAR: Saha masaüstü ortamı GNOME'dur (Ubuntu Desktop standardı); XFCE elendi. +GEREKÇE: Kullanıcı kararı + Ubuntu Desktop standardı: tanıdık modern masaüstü, teknisyen akışıyla birebir uyum. +ALTERNATİF: XFCE — hafif ve düşük kaynak tüketimine rağmen kullanıcı kararıyla elendi; yedekte tutulmaz. +RİSK: xRDP+GNOME kombinasyonu ek ayar isteyebilir (uyarı: GNOME oturumu RDP'de ek yapılandırma gerektirebilir); azaltma: 13-MEG-LINUX-ACCEPTANCE testi GNOME üzerinde koşar, `bf-gui-*` LAB'da 26.04.1 imajında doğrulanmadan dondurulmaz. +MALİYET: Ücretsiz. +LİSANS: GNOME (GPL/LGPL bileşenler), ücretsiz — https://www.gnome.org/ (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Cihazlar terminale (multi-user) boot eder; grafik katman `bf-gui-on` / `bf-gui-off` ile systemd üzerinden açılıp kapatılır. +GEREKÇE: xRDP mimarisi oturum yöneticisi (sesman) + systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerine kuruludur; GUI, display-manager + xRDP servisleri üzerinden yönetilebilir bir katman olarak modellenir. Varsayılan kapalı GUI = düşük RAM, düşük saldırı yüzeyi, öngörülebilir boot. +ALTERNATİF: Her zaman grafik boot — kaynak israfı ve 700 cihazda gereksiz hata yüzeyi nedeniyle elendi. +RİSK: Kesin birim adları ve GNOME oturum başlatma satırı 26.04.1'e göre değişebilir; azaltma: LAB'da 26.04.1 imajında doğrulanmadan `bf-gui-*` dondurulmaz. +MALİYET: Ücretsiz. +LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15). +``` + +```text +KARAR: Golden image'da `unattended-upgrades` kapatılır (`/etc/apt/apt.conf.d/20auto-upgrades` değerleri `0` + `apt-daily*.timer` maskeleme). +GEREKÇE: `unattended-upgrades` Ubuntu Server'da varsayılan kurulu ve etkindir, günde bir kez çalışır; kapatılmazsa 700 cihaz ilk açılışta güncellemeye kalkar ("onaysız update yasak" ilkesi delinir). +ALTERNATİF: Otomatik security update'e izin verme — eşzamanlı indirme + onaysız değişim riski nedeniyle elendi. +RİSK: Kapatma adımı imajda atlanırsa filo kendiliğinden güncellenir; azaltma: kurucu final-check + monitoring metriği. +MALİYET: Ücretsiz. +LİSANS: Yok (OS yapılandırması) — https://ubuntu.com/server/docs/how-to/software/automatic-updates/ (doğrulanma: 2026-09-15). +``` + +## 4. Neden Bu Karar? + +Server minimal kurulum + sonradan GNOME (Ubuntu Desktop standardı, kullanıcı kararı). Terminale boot, 700 cihazın varsayılan durumunu en öngörülebilir hale getirir; grafik yalnızca bakım penceresinde açılır. Otomatik update'in kapatılması, update zincirinin (10-UPDATE) "onay olmadan değişim yok" ilkesinin teknik kilididir. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Ubuntu 24.04 LTS | Olgun donanım desteği | Daha kısa destek penceresi | Yedek | +| Ubuntu Desktop 26.04 | Tek adımda GUI | 6 GB RAM şartı, şişkin zemin | Elendi | +| XFCE | Hafif, düşük kaynak | Kullanıcı kararıyla elendi (saha standardı GNOME) | Elendi | +| Her zaman grafik boot | Teknisyen rahatlığı | Kaynak israfı, geniş hata yüzeyi | Elendi | + +## 6. Avantajlar + +- Minimal zemin = küçük imaj, hızlı kurulum, az saldırı yüzeyi. +- Kapalı GUI varsayılanı = düşük RAM + öngörülebilir boot 700 cihazda çarpan etkisi yapar. +- Kapalı otomatik update = filo sürüm determinizmi. + +## 7. Dezavantajlar + +- GNOME oturum başlatma satırı ve display-manager birim adları 26.04.1'e göre LAB'da doğrulanmadan dondurulamaz. +- Teknisyen grafik istediğinde ek komut (`bf-gui-on`) gerekir — runbook'a işlenir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| 26.04.x sürücü farkı heterojen donanımda | Orta | Orta | LAB profil testi (04) | +| `bf-gui-*` birim adları 26.04.1'de farklı | Orta | Düşük | LAB doğrulaması, §13 açık soru | +| `unattended-upgrades` açık unutulur | Orta | Yüksek | final-check + monitoring metriği | + +## 9. Uygulama Planı + +1. BIOS ayarı (her saha PC'sinde, elektrik kesintisi sonrası otomatik açılış için): + - `Restore on AC Power Loss = Power On` (üretici menüsünde `After Power Loss: Power On` / `AC Recovery: Power On` adlarıyla da geçer; teknisyen imaj öncesi menüden doğrular). +2. Ubuntu Server 26.04.1+ minimal kurulum (GUI seçilmez). +3. Boot hedefi terminale sabitlenir: + +```bash +sudo systemctl set-default multi-user.target +systemctl get-default # multi-user.target beklenir +``` + +4. GNOME + xRDP katmanı kurulur (kurucu 05 üzerinden): + +```bash +sudo apt install -y ubuntu-desktop-minimal xrdp +sudo systemctl enable xrdp xrdp-sesman +# GNOME oturumu varsayılan yapılır (LAB'da 26.04.1'e göre doğrulanacak satır): +echo "gnome-session" > ~/.xsession +``` + +5. GUI aç/kapa anahtarları (kurucu tarafından `/usr/local/bin/` altına yazılır): + +```bash +sudo bf-gui-on # display-manager + xrdp/xrdp-sesman başlatılır, RDP oturumu açılır +sudo bf-gui-off # grafik katman durdurulur, cihaz terminale döner +``` + +6. Otomatik update kapatılır: + +```bash +# /etc/apt/apt.conf.d/20auto-upgrades içinde her iki değer 0: +# APT::Periodic::Update-Package-Lists "0"; +# APT::Periodic::Unattended-Upgrade "0"; +sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer +``` + +7. Açılışta koşması beklenen servisler (final-check bunları denetler): `NetworkManager` (veya `systemd-networkd`), `wg-quick@wg0`, `docker`, `xrdp`+`xrdp-sesman` (yalnızca GUI-açık modda), `node_exporter`, RustDesk/MeshCentral agent, `bf-health-push.timer`. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `get-default` | `multi-user.target` | LAB(2) | +| Güç kesintisi simülasyonu | BIOS ayarı ile cihaz kendiliğinden açılır | LAB(2) | +| `bf-gui-on/off` sonrası RDP | GNOME oturumu açılır/kapanır | LAB(2) | +| `20auto-upgrades` değerleri `0`, timer'lar maskeli | Otomatik apt çalışmaz | LAB(2) imaj denetimi | + +## 11. Rollback + +1. Yanlış boot hedefi: `sudo systemctl set-default multi-user.target` ile geri alınır. +2. Bozuk GNOME/xRDP katmanı: `bf-gui-off` + paket yeniden kurulumu; son çare imajdan yeniden kurulum (04). +3. Otomatik update yanlışlıkla açıldıysa §9 adım 6 yeniden uygulanır, `apt-daily*` timer durumu denetlenir. + +## 12. Kontrol Listesi + +- [ ] ISO sürümü 26.04.1+ (LAB'da `lsb_release -a` çıktısı kayıtlı). +- [ ] BIOS `Restore on AC Power Loss = Power On` her profilde doğrulandı. +- [ ] `get-default` = `multi-user.target`. +- [ ] `bf-gui-on/off` LAB 26.04.1 imajında test edildi. +- [ ] `unattended-upgrades` kapalı + timer'lar maskeli. + +## 13. Açık Sorular + +- [ ] GNOME oturum başlatma satırı + `bf-gui-*` kesin birim listesi — LAB 26.04.1 imajında (sahibi: Faz 3). +- [ ] `NetworkManager` vs `systemd-networkd` seçimi — netplan profiliyle birlikte LAB'da netleşecek (sahibi: Faz 3, 08 yazarı). + +--- + +## Ek: Zemin Katmanları + +```mermaid +flowchart TB + ISO["Ubuntu Server 26.04.1+ ISO
(GUI'siz minimal)"] --> BOOT["Boot hedefi: multi-user.target
(terminale boot)"] + BOOT --> GUI["GUI katmanı (kapalı varsayılan)
GNOME + xRDP + bf-gui-on/off"] + GUI --> KILIT["Update kilidi
unattended-upgrades kapalı + timer maskeli"] + KILIT --> SERVIS["Açılış servisleri
wg-quick + docker + node_exporter + agent'lar"] +``` +
+ + +# 04 — Golden Image ve Provisioning + +> Kısa özet: Saha imajının nasıl üretildiği ve cihaza nasıl basıldığı: autoinstall-tabanlı Ubuntu zemini + ilk açılışta kimlik enjeksiyonu. Hedef akış: USB → Ubuntu → tek script → bayi no → READY. + +- Dosya: `docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image yöntemi), `K-01` (OS), `K-11` (update kapalı), `K-12` (kimlik imajda değil, ilk boot'ta) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Heterojen 700 saha PC'sinde aynı zemini tekrarlanabilir şekilde kurmak: imajın ne içerdiği, ne içermediği (cihaz kimliği), hangi araçla üretildiği ve sahada hangi akışla basıldığı. + +## 2. Kapsam + +- Kapsam içi: yöntem seçimi (karşılaştırma tablosu), imaj içeriği, autoinstall rolü, USB basım akışı, kimlik enjeksiyonu, LAB test matrisi. +- Kapsam dışı: kurucu modül detayları (05), BIOS ayarı detayı (03), rollout lojistiği (18-RECOVERY/18-ROLLOUT). + +## 3. Kararlar + +```text +KARAR: Golden image = Ubuntu Server 26.04.1+ minimal + `unattended-upgrades` kapalı + `blueforce-install.sh` tek kurucu (bayi no → READY); imaj, preseed/autoinstall dosyasıyla üretilir, cihaz-spesifik kimlik (`bf-`) imajda DEĞİL ilk açılışta verilir. +GEREKÇE: Donanım heterojenliği bilinmediği için bit-kopya klon (Clonezilla) tek imajda kırılgandır; autoinstall + idempotent kurulum scripti her donanım profilinde aynı zemini kurar, kimlik ilk boot'ta enjekte edilir. Hedef akış: USB → Ubuntu → tek script → bayi no → READY. +ALTERNATİF: Clonezilla bit-kopya (hızlı ama donanım-fragil → yedek yöntem olarak tutulur); Packer/custom ISO (ek derleme hattı yükü → elendi); cloud-init (autoinstall'a göre saha-USB akışına daha az uygun → elendi). +RİSK: İlk imaj heterojen donanımda sürücü eksiltebilir; azaltma: önce `bf-hardware-inventory.sh` taraması (18-ROLLOUT), LAB'da her profilde test. +MALİYET: Ücretsiz. +LİSANS: Ubuntu autoinstall/subiquity (ücretsiz, Ubuntu lisans seti içinde) — https://releases.ubuntu.com/26.04/ (doğrulanma: 2026-09-15). NOT: resmi autoinstall sözdizimi LAB çıktısıyla kesinleşir. +``` + +## 4. Neden Bu Karar? + +Bit-kopya klon, kaynak makinenin sürücü setini dondurur; farklı anakart/ağ kartı olan sahada açılmayan cihaz üretir. Autoinstall her makinede donanıma göre kurulum yapar, ardından aynı kurucu script aynı zemini kurar — sonuç donanımdan bağımsız deterministik zemin + ilk boot'ta enjekte edilen benzersiz kimliktir. Custom ISO/Packer ek derleme hattı (imzalı ISO, build sunucusu, sürüm takibi) getirir; sadelik kuralını bozar. + +## 5. Alternatifler + +| Yöntem | Artı | Eksi | Sonuç | +|---|---|---|---| +| Ubuntu autoinstall (user-data) | Resmi yol, donanım-bağımsız, USB'den katılımsız | Sözdizimi LAB'da doğrulanmalı | **Seçildi** | +| Clonezilla bit-kopya | Çok hızlı klon, basit | Donanım-fragil, kimlik çakışması riski | Yedek yöntem | +| cloud-init (NoCloud USB) | Esnek ilk-boot yapılandırma | Saha-USB akışına autoinstall kadar uygun değil, ek katman | Elendi | +| Custom ISO (cubic/live-build) | Her şey gömülü tek ISO | Derleme hattı + imza + sürüm yükü | Elendi | +| Packer + QEMU build | Tekrarlanabilir build | Ek altyapı, öğrenme eğrisi, 700 PC için aşırı | Elendi | +| Ansible-only (kurulu OS üstüne) | İmaja gerek yok | Temel OS kurulumu yine manuel/USB gerektirir | Tamamlayıcı (kurucu sonrası filo) | + +## 6. Avantajlar + +- İmaj kimliksizdir: tek USB tüm sahada kullanılır, yanlış kimlikli cihaz çıkmaz. +- Kurucu idempotent olduğu için yarım kalan kurulum kaldığı yerden tamamlanır. +- Yedek yöntem (Clonezilla) hazırda bekler; autoinstall bir profilde takılırsa sahada blokaj olmaz. + +## 7. Dezavantajlar + +- Autoinstall sözdizimi Faz 1'de resmi-doğrulamalı değil; LAB çıktısı beklenir. +- İlk kurulum Clonezilla'ya göre daha yavaştır (paket kurulumu her cihazda tekrarlanır). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Heterojen donanımda sürücü eksikliği | Orta | Orta | Envanter taraması + LAB profil matrisi | +| Autoinstall sözdizimi sürüm farkı | Orta | Düşük | LAB 26.04.1 çıktısı dondurulur, dosyası repo'da sürümlenir | +| USB medyası bozuk/eskimiş | Düşük | Düşük | Çift USB + SHA256 doğrulama | + +## 9. Uygulama Planı + +1. İmaj içeriği (kimliksiz zemin): + - Ubuntu Server 26.04.1+ minimal (GUI yok). + - `unattended-upgrades` kapalı (03 §9 adım 6). + - `blueforce-install.sh` + modüller USB'de `/opt/blueforce-installer/` altında (veya ilk boot'ta merkezi depodan çekilir — LAB'da netleşir). + - Bootstrap SSH anahtarı (ilk Ansible run'ında rotasyon, K-13). +2. Saha akışı (hedef: teknisyen başına <15 dk aktif iş): + +```text +USB tak → Ubuntu Server kur (autoinstall, katılımsız) + → ilk açılışta: sudo ./blueforce-install.sh --dealer-id <8hane> + → script bitince: bf-status → READY + → USB çıkar, cihaz sahaya alınır +``` + +```bash +# sahada tek komut (kimlik ilk kez burada verilir) +sudo ./blueforce-install.sh --dealer-id 12010193 +``` + +3. Kimlik enjeksiyonu ilk boot'ta olur: hostname + WireGuard peer + envanter kaydı kurucu tarafından yazılır; imajda hiçbir `bf-` artığı bulunmaz (final-check denetler). +4. LAB test matrisi: envanterde çıkan her donanım profili × (kurulum + güç-kesintisi + `bf-gui-on/off` + final-check) — matris dolmadan WAVE açılmaz. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Aynı USB ile 2 farklı donanım profili | İkisi de READY | LAB(2) | +| İmajda kimlik artığı taraması (`bf-*`, peer key) | Hiçbir cihaz-spesifik değer yok | LAB(2) | +| Yarım kesilen kurulumun tekrarı | İkinci koşu tamamlar, exit 0 | LAB(2) | +| Clonezilla yedek yöntemi | Autoinstall takılan profilde READY | LAB(2) | + +## 11. Rollback + +1. Bozuk imaj: önceki USB sürümüne dönüş (USB'ler sürüm etiketli saklanır). +2. Kurulum sonrası hata: kurucu idempotent yeniden koşar; düzelmezse 17-RECOVERY LEVEL 9 (imajdan temiz kurulum). +3. Autoinstall dosyası hatalıysa düzeltme repo PR'ı ile sürümlenir, SHA256 yeniden yayımlanır. + +## 12. Kontrol Listesi + +- [ ] Autoinstall dosyası repo'da sürümlü + LAB çıktısıyla doğrulanmış. +- [ ] USB SHA256 değeri yayımlanmış ve sahada doğrulanıyor. +- [ ] İmajda kimlik artığı yok (otomatik tarama). +- [ ] Her donanım profili LAB matrisinde READY almış. +- [ ] Clonezilla yedek prosedürü yazılı ve test edilmiş. + +## 13. Açık Sorular + +- [ ] Kurucu USB'de mi taşınacak, ilk boot'ta merkezden mi çekilecek (saha interneti varsayımı) (sahibi: Faz 3). +- [ ] Autoinstall kesin direktif seti — LAB 26.04.1 çıktısı (sahibi: Faz 3, bu dosyanın yazarı). +- [ ] USB hazırlama standardı (Ventoy vs dd vs Balena) (sahibi: Faz 3). + +--- + +## Ek: Provisioning Akışı + +```mermaid +flowchart LR + USB["USB
(autoinstall + kurucu)"] --> KUR["Ubuntu Server 26.04.1
katılımsız kurulum"] + KUR --> TEK["sudo ./blueforce-install.sh
--dealer-id <8hane>"] + TEK --> KIMLIK["Kimlik enjeksiyonu
bf- + peer + envanter"] + KIMLIK --> HAZIR["bf-status → READY"] + HAZIR --> SAHA["Sahaya alım"] +``` +
+ + +# 05 — Tek-Tık Kurucu (One-Click Installer) + +> Kısa özet: Sahada tek komutla cihazı READY yapan `blueforce-install.sh`: bayi no doğrulaması, 18 modül, idempotent yeniden-koşu ve final-check kapısı. Teknisyenin çalıştırdığı tek şeydir. + +- Dosya: `docs/05-ONE-CLICK-INSTALLER.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-09` (tek kurucu), `K-11` (update kapalı), `K-12` (kimlik formatı), `K-03` (bf-gui), `K-10` (Docker disiplini) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Saha teknisyeninin uzmanlık gerektirmeden cihazı standarda getirmesi: tek komut, tek girdi (bayi no), net sonuç (READY / hata + log yolu). Yarım kalan veya hatalı kurulumun güvenli tekrarı. + +## 2. Kapsam + +- Kapsam içi: komut arayüzü, bayi-no doğrulama, 18 modül listesi ve sırası, idempotency kuralları, log/bundle çıktısı, çıkış kodları. +- Kapsam dışı: modül iç kodları (repo `scripts/install/`), imaj üretim yöntemi (04), filo-side yapılandırma (09). + +## 3. Kararlar + +```text +KARAR: Saha kurulumu tek giriş noktasıdır: `sudo ./blueforce-install.sh --dealer-id <8hane>`; bayi no `^[0-9]{8}$` ile doğrulanır, uymayan girdi kurucuyu başlatmaz. +GEREKÇE: Tek komut teknisyen hatasını en aza indirir; format kapısı yanlış kimliğin 9 sisteme yayılmasını baştan engeller (K-12). +ALTERNATİF: Çok scriptli kurulum (ağ.sh, gui.sh, …) — sıra hatası ve atlanan adım riski nedeniyle elendi. +RİSK: Tek dosya büyüdükçe bakımı zorlaşır; azaltma: 18 bağımsız modül + ana orkestratör ayrımı. +MALİYET: Ücretsiz. +LİSANS: Yok (şirket içi script). +``` + +```text +KARAR: Kurucu 18 modülden oluşur (`01-precheck` … `18-final-check`); her modül idempotenttir — yeniden koşu güvenli ve kaldığı yerden tamamlayıcıdır. +GEREKÇE: Saha elektriği/kesintisi yarım kurulum üretir; idempotency olmadan her kesinti temiz-kurulum demektir. Final-check kapısı (modül 18) exit 0 vermeden cihaz READY sayılmaz. +ALTERNATİF: Tek seferlik (non-idempotent) script — kesinti durumunda belirsiz ara durum nedeniyle elendi. +RİSK: Modülün idempotent yazılmaması (çift kayıt, çift key); azaltma: §10'daki çift-koşu testi her modül için zorunlu. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +700 cihaz × teknisyen başına değişen beceri = kurulumun "tek komut + tek soru" olması şarttır. 18 modül, her kararın (update-kapalı, Docker-disiplini, GUI-anahtarı, kimlik, servisler) ayrı denetlenebilir adımdır; modül numarası log satırında görünür, arıza hangi adımda kaldıysa oradan devam edilir. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Çok scriptli kurulum | Küçük dosyalar | Sıra/atlama hatası | Elendi | +| Non-idempotent tek script | Daha basit kod | Kesintide temiz-kurulum zorunluluğu | Elendi | +| İnteraktif sihirbaz (çok sorulu) | Esnek | Teknisyen hatası, otomasyona kapalı | Elendi | + +## 6. Avantajlar + +- Tek komut = eğitim maliyeti minimum, runbook tek sayfa. +- Idempotency = kesinti korkusu yok, "bir daha koş" her sorunun ilk cevabı. +- Final-check = READY iddiası ölçülebilir, dalga kapılarına bağlanır. + +## 7. Dezavantajlar + +- 18 modülün her biri için idempotency testi yazma maliyeti. +- Tek giriş noktası, modül-atlama (debug) ihtiyacında `--from/--only` bayraklarını zorunlu kılar. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Modül idempotent değil (çift kayıt) | Orta | Orta | Çift-koşu testi (§10) + code review kuralı | +| Teknisyen yanlış no girer | Orta | Orta | Format kapısı + onay ekranı (`BF-xxxx, onaylıyor musunuz?`) | +| Kurucu yarıda kesilir, cihaz ara durumda sahaya alınır | Düşük | Yüksek | Final-check exit 0 olmadan READY yok; `bf-status` bunu gösterir | + +## 9. Uygulama Planı + +1. Komut arayüzü: + +```bash +sudo ./blueforce-install.sh --dealer-id 12010193 [--from 07] [--only 18-final-check] [--check] +# --check: değişiklik yapmadan denetim (final-check + modül durumları) +# --from: belirtilen modülden devam et | --only: tek modül koş +``` + +2. Bayi-no doğrulama: `^[0-9]{8}$`; uymazsa exit 2 + kullanım mesajı. Geçerse onay ekranı (`BF-12010193` + hostname `bf-12010193` gösterilir). +3. 18 modül (sıra sözleşmedir, yeri `scripts/install/`): + +| # | Modül | İşlev (dayandığı karar) | +|---|---|---| +| 01 | `01-precheck` | OS sürümü (26.04.1+), disk/RAM minimumu, root yetkisi, internet kontrolü | +| 02 | `02-dealer-id` | No doğrulama + hostname `bf-` atama (K-12) | +| 03 | `03-base-packages` | Temel paketler + `unattended-upgrades` kapatma (K-11) | +| 04 | `04-gnome` | GNOME kurulumu (K-02) | +| 05 | `05-xrdp` | xRDP + sesman + `bf-gui-on/off` yazımı (K-03) | +| 06 | `06-ssh-hardening` | `blueforce` kullanıcısı, key-auth, root/password kapalı (K-13) | +| 07 | `07-wireguard` | Spoke config, peer `bf-`, keepalive 25, `wg-quick@wg0` enable (K-05) | +| 08 | `08-docker` | Docker CE + `unless-stopped` + log rotasyonu + localhost-bind (K-10) | +| 09 | `09-meg` | MEG konteyneri (sabit tag, `latest` yok) (K-10) | +| 10 | `10-rustdesk` | RustDesk istemci + VDS adresi + `BF-` etiketi (K-04) | +| 11 | `11-meshcentral` | MeshCentral agent + `BF-` adı (K-04) | +| 12 | `12-monitoring` | Node Exporter + textfile + Uptime Push (K-07) | +| 13 | `13-firewall` | UFW profili (SSH/RDP yalnızca WG) (K-05) | +| 14 | `14-inventory` | `bf-hardware-inventory.sh` koşusu, JSON+MD üretimi (02) | +| 15 | `15-logging` | journald + Docker rotasyon + `bf-status/bf-diagnostics` (K-14) | +| 16 | `16-boot` | multi-user default + BIOS notu + servis enable denetimi (K-03) | +| 17 | `17-ansible-bootstrap` | Bootstrap SSH key + ilk pull/Ansible kaydı (K-06/K-13) | +| 18 | `18-final-check` | Tüm kapılar: kimlik, servisler, update-kapalı, Docker politikası, READY damgası | + +4. Idempotency kuralları (her modül için zorunlu): + - Varlık kontrolü önce (`id blueforce`, `dpkg -l`, `systemctl is-enabled`): varsa atla, yoksa kur. + - Dosya yazımları atomik (tmp + rename) ve tekrarlanabilir (aynı girdi → aynı çıktı). + - Anahtar/secret üretimi bir kez (`/var/lib/blueforce/.modül-done` damgası); `--force` olmadan yeniden üretilmez. + - Her modül `/var/log/blueforce-install.log` dosyasına `MODÜL-NO + OK/SKIP/FAIL` satırı yazar. +5. Çıkış kodları: `0` READY, `1` modül hatası (no logda), `2` argüman/format hatası. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Geçersiz no (7/9 hane, harf, boş) | exit 2, kurulum başlamaz | LAB(2) | +| Her modülün çift-koşusu | İkinci koşu exit 0, durum değişmez (diff boş) | LAB(2) | +| Elektrik-kesintisi simülasyonu (modül 09 ortasında kill) | `--from 09` ile tamamlanır, final exit 0 | LAB(2) | +| `18-final-check` tüm kapıları | Eksik adımda FAIL + modül nosu, READY yok | LAB(2) | +| `--check` kuru koşusu | Değişiklik yapmaz, rapor üretir | P1(5) | + +## 11. Rollback + +1. Modül hatası: logdaki modül nosundan `--from ` ile devam edilir. +2. Yanlış no: doğru no ile tam yeniden koşu (idempotent, kimlik modülü üzerine yazar). +3. Düzelmeyen cihaz: 17-RECOVERY LEVEL 9 (temiz imaj + kurucu). + +## 12. Kontrol Listesi + +- [ ] 18 modül dosyası `scripts/install/` altında numaralı ve çalışır durumda. +- [ ] Her modülde varlık-kontrolü + damga + log satırı var. +- [ ] Format regex'i 02 ile aynı (`^[0-9]{8}$`). +- [ ] `18-final-check` K-10/K-11 kapılarını denetliyor (unless-stopped, latest yok, update kapalı). +- [ ] `--check/--from/--only` bayrakları çalışıyor. + +## 13. Açık Sorular + +- [ ] Kurucu USB'de mi, merkezden `curl` ile mi alınacak (saha interneti varsayımı) (sahibi: Faz 3, 04 yazarı ile ortak). +- [ ] MEG sabit tag'inin kim tarafından, hangi onay ile güncelleneceği (sahibi: 10-UPDATE / 12-DOCKER yazarları). + +--- + +## Ek: Kurucu Akış + +```mermaid +flowchart TB + CMD["sudo ./blueforce-install.sh
--dealer-id <8hane>"] --> FMT{"^[0-9]8$ ?"} + FMT -->|Hayır| EXIT2["exit 2
kullanım mesajı"] + FMT -->|Evet| M01["01-precheck … 17-bootstrap
(idempotent modüller)"] + M01 --> M18["18-final-check"] + M18 -->|Tümü OK| READY["READY damgası
bf-status yeşil"] + M18 -->|FAIL| LOG["modül nosu + log yolu
--from ile devam"] +``` +
+ + +# 06 — Kullanıcılar, SSH ve Yetkiler + +> Kısa özet: Saha cihazlarındaki kullanıcı modeli (`blueforce` bakım kullanıcısı), SSH kimlik doğrulama mimarisi (anahtar zorunlu, password kapalı, root kapalı) ve tek private key riskine karşı per-admin anahtar + SSH CA hedefi. 700 cihazda güvenli ve iptal edilebilir erişimin sözleşmesidir. + +- Dosya: `docs/06-USERS-SSH-AND-PERMISSIONS.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-13` (SSH mimarisi), `K-05` (SSH yalnızca WireGuard arkası), `K-12` (cihaz kimliği) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 saha cihazına kimlerin, hangi kullanıcıyla, nasıl bağlanacağını sabitlemek: tek bakım kullanıcısı, anahtar-zorunlu SSH, kapalı root/password, sızıntıda blast-radius'u sınırlayan anahtar dağıtım modeli. + +## 2. Kapsam + +- Kapsam içi: `blueforce` kullanıcısı, servis kullanıcıları, sudo politikası, `sshd_config` kilitleri, per-admin anahtar dağıtımı (Ansible), anahtar iptal prosedürü, SSH CA hedefi. +- Kapsam dışı: WireGuard tünel kurulumu (08), Ansible playbook detayları (09), UFW/firewall kuralları (11). + +## 3. Kararlar + +```text +KARAR: Saha cihazlarında tek insan bakım kullanıcısı `blueforce`'tur; servisler ayrı systemsız-login kullanıcılarla çalışır, sudo kontrollüdür. +GEREKÇE: Tek bakım kimliği = 9 sistemde (02-NAMING, WireGuard, Ansible, monitoring) join anahtarı; servislerin ayrı kullanıcıyla çalışması bir servisin ele geçirilmesinin bakım yetkisine sıçramasını engeller. +ALTERNATİF: Kişi başına ayrı login — 700 cihaz × N teknisyen authorized_keys kaosu nedeniyle elendi; ayrım SSH anahtarı seviyesinde (per-admin key) yapılır, kullanıcı seviyesinde değil. +RİSK: Tek kullanıcı adı hedefli saldırıya odaklanır; azaltma: password kapalı + yalnızca WireGuard arayüzünden SSH (08). +MALİYET: Ücretsiz. +LİSANS: OpenSSH (BSD-lisanslı), ücretsiz. +``` + +```text +KARAR: Root SSH girişi kapalıdır (`PermitRootLogin no`); password ile SSH kapalıdır (`PasswordAuthentication no`); anahtar zorunludur (`PubkeyAuthentication yes`). +GEREKÇE: Kaba-kuvvet yüzeyi root+password ikilisinden gelir; ikisi de kapatılınca uzaktan giriş yalnızca dağıtılmış anahtarlarla mümkündür. Root yetkisi yalnızca `sudo` ile, denetim izi bırakarak alınır. +ALTERNATİF: Password auth açık tutma — 700 internete çıkabilen cihazda kaba-kuvvet + zayıf parola riski nedeniyle elendi. +RİSK: Anahtarını kaybeden admin erişemez; azaltma: bootstrap anahtarı + acil erişim prosedürü (§9) + RustDesk/MeshCentral yedek kanalı (07). +MALİYET: Ücretsiz. +LİSANS: OpenSSH (BSD-lisanslı), ücretsiz. +``` + +```text +KARAR: 700 cihaza tek private key YAYILMAZ — her adminin kendi anahtar çifti vardır; cihazlardaki `authorized_keys` Ansible ile merkezi dağıtılır; uzun vadede SSH CA (sertifika tabanlı giriş) hedeflenir. +GEREKÇE: Tek private key'in sızıntı blast-radius'u 700 cihazdır; per-admin anahtar + merkezi dağıtım, sızıntıyı tek anahtarın iptali + tek Ansible run'ına indirger. SSH CA hedefi anahtar dağıtım gecikmesini ortadan kaldırır (kısa ömürlü sertifika). +ALTERNATİF: Tek paylaşılan private key — basit ama 700 cihazlık blast-radius nedeniyle elendi. +RİSK: Anahtar dağıtım gecikmesi yeni cihazda erişimsizlik; azaltma: golden image'da bootstrap anahtarı + ilk Ansible run'ında rotasyon (04-GOLDEN-IMAGE). +MALİYET: Ücretsiz. +LİSANS: OpenSSH (BSD-lisanslı), ücretsiz. +``` + +## 4. Neden Bu Karar? + +Savunma katmanları üst üste biner: (1) SSH yalnızca WireGuard arayüzüne bağlıdır (08), internetten port 22 görünmez; (2) o dar kapıda bile password/root kapalıdır; (3) anahtarlar kişi bazlıdır, çalınan tek anahtar tek komutla iptal edilir. Tek paylaşılan key, 700 cihazda "bir sızıntı = filo kaybı" demektir; bu risk, per-admin anahtarın getirdiği küçük dağıtım yükünden ağır basar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Tek paylaşılan private key | Dağıtım çok basit | 700 cihazlık blast-radius | Elendi | +| Password auth açık | Acil girişte kolay | Kaba-kuvvet yüzeyi | Elendi | +| Kişi başına ayrı login | Denetimde ad bazlı iz | 700 cihazda kullanıcı kaosu | Elendi | +| SSH CA (hemen) | En temiz iptal/rotasyon | Kurulum + Faz 1 yükü | Hedef (ileride) | + +## 6. Avantajlar + +- Sızıntıda iptal tek Ansible run'ı ile filoya yayılır. +- Root'un doğrudan girişi olmadığı için tüm yetkili işlem `sudo` log'unda iz bırakır. +- Servis/servis-dışı ayrımı, MEG veya agent zafiyetinin bakım yetkisine sıçramasını engeller. + +## 7. Dezavantajlar + +- Yeni admin için anahtar üret + dağıt + test döngüsü gerekir (runbook'a işlenir). +- SSH CA kurulana kadar `authorized_keys` dağıtım gecikmesi yaşanabilir (bootstrap anahtarı ile kapatılır). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Tek private key sızıntısı (yasak ihlali) | Düşük | Yüksek (700 cihaz) | Politika + denetim (§12); sızıntıda tüm filo rotasyonu (17-RECOVERY) | +| Admin anahtar kaybı | Orta | Düşük | Bootstrap anahtarı kasada; acil erişim prosedürü (§9) | +| `authorized_keys` senkron kayması | Orta | Orta | Ansible run sonunda `ssh -i` doğrulama testi (09) | +| SSH CA'ye hiç geçilememesi | Orta | Düşük | Per-admin model CA'sız da sürdürülebilir; CA Faz-sonrası hedef | + +## 9. Uygulama Planı + +1. Golden image: `blueforce` kullanıcısı + bootstrap `authorized_keys` + kilitli `sshd_config` ile gelir (04). +2. Her admin kendi anahtar çiftini üretir, public key merkez envantere işlenir. +3. Ansible `users/ssh` rolü `authorized_keys`'i dağıtır, bootstrap anahtarını ilk run'da rotasyona sokar (09). +4. Sızıntı/ayrılışta ilgili public key envanterden çıkarılır, playbook tüm filoya koşar. +5. Faz-sonrası: SSH CA kurulumu değerlendirilir (CA private key merkezde, host sertifikaları ilk boot'ta). + +```bash +# örnek: sshd kilitleri (hedef durum) +grep -E '^(PermitRootLogin|PasswordAuthentication|PubkeyAuthentication)' /etc/ssh/sshd_config +# beklenen: PermitRootLogin no / PasswordAuthentication no / PubkeyAuthentication yes + +# örnek: acil erişim testi (merkezden, WireGuard üzerinden) +ssh -i ~/.ssh/admin-emirhan -o BatchMode=yes blueforce@10.8.0.15 echo OK +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `ssh root@` | Red (Permission denied) | LAB(2) | +| Parola ile giriş denemesi | Red | LAB(2) | +| Geçerli admin anahtarı ile giriş | Başarılı | LAB(2) | +| İptal edilen anahtar ile giriş | Red (dağıtım sonrası) | LAB(2) | +| `sudo -l` çıktısı | Yalnızca tanımlı komutlar | LAB(2) | + +## 11. Rollback + +1. Hatalı `sshd_config` dağıtımında önceki sürüm Ansible ile geri basılır (`sshd -t` doğrulamalı). +2. Yanlışlıkla silinen geçerli anahtar, bootstrap anahtarı veya yedek kanaldan (RustDesk/MeshCentral, 07) girilip geri eklenir. +3. Son çare: cihaz 17-RECOVERY prosedürüyle yeniden imajlanır. + +## 12. Kontrol Listesi + +- [ ] `PermitRootLogin no`, `PasswordAuthentication no` tüm saha cihazlarında doğrulandı. +- [ ] Hiçbir cihazda paylaşılan/ortak private key yok (denetim scripti 11'de). +- [ ] Her admin anahtarı envanterde kayıtlı, ayrılanlarınki çıkarılmış. +- [ ] Bootstrap anahtarı fiziksel kasada + erişim log'lu. +- [ ] SSH yalnızca WireGuard arayüzünde dinliyor (08 ile birlikte test). + +## 13. Açık Sorular + +- [ ] SSH CA için zaman çizelgesi ve CA altyapısı (sahibi: Faz-sonrası plan, 25-ROADMAP). +- [ ] `sudo` komut beyaz listesinin kesin içeriği — LAB'da bakım akışlarıyla netleşecek (sahibi: 11-SECURITY yazarı). + +--- + +## Ek: Mermaid — anahtar yaşam döngüsü + +```mermaid +flowchart LR + GEN["Admin anahtar üretir
(public key envantere)"] --> DIST["Ansible authorized_keys
dağıtımı"] + DIST --> USE["SSH-over-WG
günlük kullanım"] + USE --> REV["Ayrılış/sızıntı:
key envanterden çıkar"] + REV --> REDIST["Playbook tüm filoya
yeniden koşar"] + REDIST --> VER["İptal testi:
eski key reddedilir"] +``` +
+ + +# 07 — Uzaktan Erişim (4 Kanal) + +> Kısa özet: Saha cihazlarına 4 bağımsız erişim kanalı: SSH-over-WireGuard, xRDP + GNOME, RustDesk OSS self-hosted (birincil grafik), MeshCentral self-hosted (ikincil yönetim). AnyDesk mimariden çıkarılmıştır; yalnızca müşteri sahasında zaten lisanslıysa opsiyonel modül olarak kurulur. + +- Dosya: `docs/07-REMOTE-ACCESS.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-04` (4 kanal + AnyDesk hükmü), `K-03` (xRDP + bf-gui), `K-05` (WireGuard arkası), `K-02` (GNOME) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +WireGuard çökse bile sahaya erişimi sürdürecek yedekli uzak-erişim mimarisini sabitlemek: hangi kanal ne için kullanılır, hangisi hangi arızada ayakta kalır, AnyDesk neden yoktur. + +## 2. Kapsam + +- Kapsam içi: 4 kanalın rolü/kurulumu/boot davranışı, WG-çöküşü analizi, AnyDesk hükmü + opsiyonel modül notu, kanal seçim matrisi. +- Kapsam dışı: WireGuard tünel kurulumu (08), SSH anahtar politikası (06), filo otomasyonu (09). + +## 3. Kararlar + +```text +KARAR: Dört erişim kanalı: (1) SSH-over-WireGuard (yönetim/otomasyon), (2) xRDP + GNOME (grafik bakım), (3) RustDesk OSS self-hosted hbbs/hbbr (birincil grafik uzak erişim, WireGuard-bağımsız), (4) MeshCentral self-hosted (ikincil yönetim: terminal/dosya/envanter, WireGuard-bağımsız). AnyDesk mimariden ÇIKARILDI. +GEREKÇE: RustDesk OSS sınırsız istemci, kendi ID+röle altyapısı, E2E şifreli oturum, dosya transferi ve katılımsız erişimi ücretsiz verir; istemcide VDS genel adresi kullanıldığında WireGuard çökse bile kanal ayakta kalır. MeshCentral Apache-2.0 ile ikinci bağımsız düzlemi sağlar. AnyDesk ücretsiz sürümü yalnızca kişisel kullanımı kapsar; 700 kurumsal cihaz ticari kullanımdır ve lisanssız kullanım ihlal olur. +ALTERNATİF: AnyDesk (lisans maliyeti + kapalı kaynak + self-hosted yalnızca en üst planda → elendi; yalnızca müşteri sahasında zaten lisanslıysa opsiyonel installer modülü). Tailscale/ZeroTier (freemium, merkezi kimlik → elendi). +RİSK: İki VDS-bağımlı kanalın tek ortak noktası VDS + genel internet; azaltma: hbbs/hbbr + MeshCentral health monitörü (Uptime Kuma) + yedek VDS planı 17-RECOVERY'de. +MALİYET: Ücretsiz (kendi VDS'i üzerinde; VDS kira bedeli hariç — altyapı maliyeti, lisans değil). +LİSANS: RustDesk istemci+sunucu AGPL-3.0 (1.4.9 / 1.1.16) — https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ + https://rustdesk.com/docs/en/self-host/client-deployment/ (doğrulanma: 2026-09-15); MeshCentral Apache-2.0 (1.2.5) — https://github.com/Ylianst/MeshCentral (doğrulanma: 2026-09-15); AnyDesk hükmü — https://support.anydesk.com/docs/anydesk-licenses.md (doğrulanma: 2026-09-15) + https://anydesk.com/en/pricing. +``` + +```text +KARAR: xRDP grafik bakımı verir (GNOME oturumu), yalnızca WireGuard arkasında dinler; GUI varsayılan kapalıdır, bakım penceresinde `bf-gui-on` ile açılır. +GEREKÇE: xRDP systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerinden yönetilebilir bir katmandır; WG-arkası dinleme RDP'yi internet taramasından gizler, kapalı GUI varsayılanı RAM'i korur. +ALTERNATİF: RDP'yi doğrudan internete açma — tarama + kaba-kuvvet riski nedeniyle elendi. +RİSK: Kesin birim adları ve GNOME oturum satırı 26.04.1'de değişebilir; azaltma: LAB doğrulaması olmadan `bf-gui-*` dondurulmaz (03). +MALİYET: Ücretsiz. +LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15). +``` + +```text +KARAR: Dört kanalın agent/servisleri boot'ta otomatik başlar (systemd enable): `wg-quick@wg0`, `rustdesk` katılımsız servis, MeshCentral agent, xRDP servisleri (GUI kapalı olsa da sesman hazır). +GEREKÇE: Elektrik kesintisi sonrası cihaz açılınca VPN + yedek kanallar kendiliğinden gelir; 16-POWER-LOSS boot zincirinin varsayımıdır. +ALTERNATİF: Manuel başlatma — 700 dokunulmaz cihazda her kesintide saha ziyareti demek → elendi. +RİSK: Boot race (agent ağdan önce kalkar); azaltma: systemd `After=network-online.target` + yeniden deneme, LAB güç-kesinti testiyle doğrulanır. +MALİYET: Ücretsiz. +LİSANS: Yok (OS yapılandırması). +``` + +## 4. Neden Bu Karar? + +Tek tünel körlüğü engellenir: SSH ve RDP WireGuard'a bağımlıdır (güvenlik için), RustDesk ve MeshCentral ise VDS genel adresine doğrudan çıkar (erişilebilirlik için). RustDesk birincil grafiktir çünkü katılımsız erişim + dosya transferini OSS'te verir; MeshCentral ikinci düzlemdir çünkü terminal/dosya/envanter sunar ve RustDesk arızasında devreye girer. AnyDesk'in ücretsiz katmanı ticari kullanımı kapsamadığı için 700 cihazda lisans ihlali doğurur — bu hukuki risk teknik kolaylığa ağır basar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| AnyDesk (ücretsiz) | Tanınmış, kolay | Ticari kullanımda lisans ihlali | Elendi | +| AnyDesk (lisanslı) | Kurumsal destek | 700 cihaz lisans bedeli + kapalı kaynak | Opsiyonel modül (bkz. §9) | +| Tailscale/ZeroTier | Kolay mesh | Freemium, merkezi kimlik | Elendi | +| Tek kanal (yalnızca SSH) | En sade | Tünel çökerse körlük | Elendi | +| VNC doğrudan | Basit | Şifresiz/zayıf varyantlar, WG-dışı risk | Elendi | + +## 6. Avantajlar + +- WireGuard çökse bile 2 kanal (RustDesk, MeshCentral) ayakta — filo kör kalmaz. +- Tüm kanallar ücretsiz/self-hosted; veri üçüncü parti buluta çıkmaz. +- Kanal başına rol ayrımı (otomasyon / grafik bakım / grafik erişim / envanter) runbook'ları sadeleştirir. + +## 7. Dezavantajlar + +- 4 kanal = 4 agent/servis bakımı, 4 izleme hedefi (14-MONITORING'e yük). +- RustDesk + MeshCentral aynı VDS'teyse ortak arıza noktası (yedek VDS planı gerekir). +- Teknisyenin hangi kanalı ne zaman kullanacağı eğitim gerektirir (kanal matrisi §9). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| WireGuard çöküşü (istemci/saha tarafı) | Orta | Orta | RustDesk/MeshCentral bağımsız kanallar (§9 analizi) | +| VDS kesintisi (hbbs+hbbr+MeshCentral) | Düşük | Yüksek | Health monitörü + yedek VDS planı (17) | +| RustDesk ID çakışması / key sızıntısı | Düşük | Orta | Self-hosted key, `bf-` etiketi, key rotasyon prosedürü | +| xRDP + GNOME ek ayar ihtiyacı (26.04.1) | Orta | Orta | LAB'da `bf-gui-*` doğrulaması (03) | + +## 9. Uygulama Planı + +1. Merkez VDS: `hbbs`/`hbbr` (`--net=host` önerilir) + MeshCentral kurulumu, health monitörü (Uptime Kuma) bağlanır. +2. Golden image: RustDesk istemcisi (self-hosted key + VDS adresi gömülü) + MeshCentral agent + xRDP kurulu gelir; dördü de systemd enable. +3. Cihaz kimliği (`bf-`, 02) RustDesk etiketi + MeshCentral adı + Ansible inventory adı olarak aynı yazılır. +4. Opsiyonel AnyDesk modülü: SADECE müşterinin sahasında geçerli ticari AnyDesk lisansı varsa, `blueforce-install.sh --with-anydesk` bayrağıyla kurulur; lisans anahtarı müşteri tarafından sağlanır, AMC lisans sağlamaz. Lisanssız cihaza kurulmaz. + +```bash +# örnek: kanal durum denetimi (saha cihazında) +systemctl is-active wg-quick@wg0 xrdp rustdesk meshcentral 2>/dev/null +ss -tlnp | grep -E ':(22|3389)' # yalnızca WG arayüzünde dinlemeli +``` + +### Kanal seçim matrisi + +| Durum | Kullanılacak kanal | +|---|---| +| Normal bakım (komut/config) | SSH-over-WireGuard | +| Grafik bakım (teknisyen) | xRDP + GNOME (WG üzerinden) | +| WireGuard çöktü, grafik lazım | RustDesk OSS (birincil yedek) | +| WireGuard + RustDesk çöktü | MeshCentral terminal/dosya | +| Otomasyon (filo) | Ansible-over-SSH (09), kanal değil araç | + +### WG çökerse RustDesk analizi + +RustDesk istemcisi tünele değil VDS genel adresine (`hbbs`/`hbbr` portları) doğrudan internetten çıkar; WireGuard arayüzü down olsa bile `eth0/wlan0` üzerinden röle/ID sunucusuna ulaşır. Bu bağımsızlığın iki önkoşulu vardır: (1) saha interneti ayaktadır (elektrik + ISS), (2) VDS ayaktadır. İkisi de varsa teknisyen RustDesk'ten girip `wg-quick@wg0`'ı onarır — yani RustDesk hem yedek kanal hem WG kurtarma aracıdır. Tek ortak arıza: saha internet kesintisi (o zaman hiçbir uzaktan kanal çalışmaz, 16/17 prosedürleri) veya VDS kesintisi (o zaman MeshCentral da gider; yedek VDS planı 17'de). + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| 4 kanalın normal durumda erişilebilirliği | Tümü OK | LAB(2) | +| `wg-quick@wg0` stop → RustDesk oturumu | Bağlantı kurulur | LAB(2) | +| WG down + RustDesk kapalı → MeshCentral terminal | Komut çalışır | P1(5) | +| Güç kesintisi → 4 kanalın otomatik dönüşü | Tümü 5 dk içinde OK | LAB(2) | +| RDP portu internetten tarama | Kapalı (yalnızca WG) | LAB(2) | + +## 11. Rollback + +1. Arızalı kanalın önceki agent sürümüne dönüşü 10-UPDATE dalga zinciriyle yapılır. +2. RustDesk key değişikliğinde eski key geçiş süresi boyunca tutulur, sonra iptal edilir. +3. Son çare: kanal agent'ı kaldırılıp kalan 3 kanalla işletim sürdürülür (17-RECOVERY). + +## 12. Kontrol Listesi + +- [ ] hbbs/hbbr + MeshCentral VDS'te ayakta, Uptime Kuma izliyor. +- [ ] 4 servisin tamamı boot-enable, güç-kesinti testinden geçti. +- [ ] SSH/RDP yalnızca WG arayüzünde dinliyor. +- [ ] RustDesk etiketleri `bf-` ile eşleşiyor. +- [ ] AnyDesk hiçbir lisanssız cihaza kurulu değil. + +## 13. Açık Sorular + +- [ ] hbbs/hbbr ile MeshCentral aynı VDS'te mi, ayrı mı — yük testiyle karar (sahibi: 25-ROADMAP). +- [ ] RustDesk self-hosted key rotasyon sıklığı (sahibi: 11-SECURITY yazarı). + +--- + +## Ek: Mermaid — kanal bağımsızlığı + +```mermaid +flowchart LR + PC["Saha bf-"] -->|WG tüneli| WG["WireGuard hub"] + WG --> SSH["SSH / Ansible"] + WG --> RDP["xRDP + GNOME"] + PC -->|doğrudan internet
VDS genel adresi| RD["RustDesk hbbs/hbbr"] + PC -->|doğrudan internet
VDS genel adresi| MC["MeshCentral"] + WG -.->|çökerse| DOWN{"WG down?"} + DOWN -->|evet| RD + DOWN -->|evet| MC +``` +
+ + +# 08 — WireGuard Ağı ve Network + +> Kısa özet: Hub-spoke WireGuard topolojisi: merkez hub, her saha cihazı `bf-<8hane>` peer'ı, `PersistentKeepalive = 25`, boot'ta otomatik tünel. SSH/RDP yalnızca tünel içinden erişilir; saha internetindeki Turkcell paket bitimi/dönüşü etkileri bu dosyadadır. + +- Dosya: `docs/08-WIREGUARD-AND-NETWORK.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-05` (hub-spoke + keepalive + otomatik reconnect), `K-12` (peer adı = cihaz kimliği), `K-04` (WG çökerse yedek kanallar) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihazlık VPN topolojisini, peer adlandırma sözleşmesini, NAT arkasında tüneli ayakta tutan keepalive değerini ve saha interneti (Turkcell mobil veri) gerçekleriyle başa çıkma planını sabitlemek. + +## 2. Kapsam + +- Kapsam içi: hub-spoke topoloji, peer = bayi ID eşleşmesi, keepalive, `wg-quick@wg0` otostart, subnet/IP planı, SSH/RDP'nin WG-arkası kuralı, Turkcell paket bitimi/dönüşü notu. +- Kapsam dışı: SSH anahtar politikası (06), RustDesk/MeshCentral kurulumu (07), firewall detayları (11). + +## 3. Kararlar + +```text +KARAR: Hub-spoke WireGuard: merkez sunucu hub, her saha cihazı spoke (peer adı = `bf-<8hane>`); istemcide `PersistentKeepalive = 25` + `wg-quick@wg0` systemd enable; RDP/SSH dış dünyaya kapalı, yalnızca WireGuard arkasında. +GEREKÇE: Resmi QuickStart NAT arkasındaki eş için 25 sn keepalive'i "geniş güvenlik-duvarı yelpazesiyle çalışan makul aralık" olarak verir; `wg-quick@.service` Ubuntu paketinden gelir, enable = açılışta otomatik VPN. Elektrik kesintisi sonrası cihaz açılınca VPN kendiliğinden gelir. +ALTERNATİF: Keepalive kapalı / agresif 10 sn — kapalı NAT eşlemesini düşürür, agresif değer saha verisi olmadan yazılmaz; PİLOT-1 kopma sayacına göre ayarlanır. +RİSK: Turkcell CGNAT davranışı resmi kaynaktan doğrulanamaz (operatör bilgisi); azaltma: PİLOT-1'de 7/24 kopma sayacı, aralık saha verisiyle ayarlanır. +MALİYET: Ücretsiz. +LİSANS: WireGuard OSS (Ubuntu paketi `wireguard-tools 1.0.20250521-1ubuntu1`) — https://www.wireguard.com/quickstart/ + https://packages.ubuntu.com/resolute/wireguard-tools (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Her WireGuard peer adı cihaz kimliğine eşittir (`bf-<8hane>` = hostname = Ansible inventory adı = monitoring etiketi); hub tarafında peer başına sabit tünel IP'si atanır. +GEREKÇE: Tek kimlik (K-12) 9 sistemde join anahtarıdır; peer adı farklı olsaydı WG log'undan cihaz bulmak manuel eşleştirme gerektirirdi. Sabit IP = envanterde `bf- → 10.x` haritası, runbook ve monitoring sorguları deterministik olur. +ALTERNATİF: Rastgele peer adı / dinamik IP — okunamaz, izlenemez → elendi. +RİSK: Bayi no değişirse peer yeniden adlandırılmalı; azaltma: 02-NAMING yeniden-no prosedürü. +MALİYET: Ücretsiz. +LİSANS: Yok (isimlendirme standardı). +``` + +## 4. Neden Bu Karar? + +Saha cihazları NAT/CGNAT arkasındadır; merkez onlara ulaşamaz, onlar merkeze çıkar. Hub-spoke bu gerçeğe uyar: spoke keepalive ile NAT eşlemesini canlı tutar, hub yalnızca dinler. 25 sn, resmi dokümanın "makul aralık" değeridir — saha ölçümü olmadan daha agresif yazmak veri israfı, kapatmak kopma demektir. Peer = bayi ID kuralı, 700 peer'lı hub config'ini insan tarafından okunabilir tutar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Keepalive kapalı | Az trafik | NAT eşlemesi düşer, tünel ölür | Elendi | +| Keepalive 10 sn | Daha canlı eşleme | Saha verisi olmadan veri israfı | Yedek (PİLOT-1 verisine göre) | +| Full-mesh | Spoke'lar arası doğrudan | 700 cihazda key/config patlaması | Elendi | +| Tailscale/ZeroTier | Kolay | Freemium, merkezi kimlik | Elendi (K-04) | + +## 6. Avantajlar + +- Kernel-seviyesi VPN: düşük CPU, düşük gecikme, mini PC dostu. +- Otostart: elektrik kesintisi sonrası teknisyen müdahalesi gerekmez. +- Sabit IP + peer adı = monitoring ve Ansible'da doğrudan adresleme. + +## 7. Dezavantajlar + +- Hub tek darboğaz: 700 eşin handshake + trafiği tek VDS'ten geçer (yük testi gerekir). +- CGNAT davranışları (özellikle mobil) resmi kaynaktan doğrulanamaz; ayar saha verisine bağımlıdır. +- Peer ekleme/çıkarma merkez config'inde yapılır; otomasyonsuz 700 peer yönetilemez (09'a bağımlılık). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Turkcell CGNAT NAT eşlemesini erken düşürür | Orta | Orta | PİLOT-1 7/24 kopma sayacı; aralık saha verisiyle ayarlanır | +| Turkcell paket bitimi → veri durur | Orta | Yüksek | Paket izleme + bitim alarmı (bkz. §9); kota-dostu keepalive | +| Hub VDS darboğazı (700 eş) | Orta | Yüksek | Yük testi + peer başına trafik limiti; gerekirse 2. hub | +| Peer private key sızıntısı | Düşük | Orta | Peer bazlı iptal (tek peer silinir, filo etkilenmez) | + +## 9. Uygulama Planı + +1. Merkez hub kurulur, subnet planı dondurulur (ör. `10.8.0.0/16`, hub `10.8.0.1`, cihazlara sabit `/32`). +2. Her cihaz için peer üretilir: adı `bf-<8hane>`, tünel IP'si envanter haritasından. +3. İstemci config'i (`/etc/wireguard/wg0.conf`): hub endpoint + `PersistentKeepalive = 25` + `wg-quick@wg0 enable`. +4. Peer ekleme/çıkarma Ansible rolüyle yapılır (09); hub config'i Git'te sürümlenir. +5. PİLOT-1'de kopma sayacı koşar (handshake yaşı metriği 14'te), keepalive saha verisiyle ayarlanır. + +```bash +# örnek: istemci konfigürasyonu (değerler envanterden gelir) +# /etc/wireguard/wg0.conf +# [Interface] PrivateKey = / Address = 10.8.0.15/32 +# [Peer] PublicKey = / Endpoint = :51820 +# PersistentKeepalive = 25 / AllowedIPs = 10.8.0.0/16 + +sudo systemctl enable --now wg-quick@wg0 +sudo wg show wg0 latest-handshakes # kopma sayacı girdisi +``` + +### Turkcell paket bitimi / dönüşü notu + +Saha bağlantısı Turkcell mobil veri ise iki gerçek vardır: (1) hat CGNAT arkasındadır — merkez cihaza ulaşamaz, keepalive'lı giden tünel zorunludur; (2) kota bitiminde veri durur veya hız düşer — tünel sessizce ölür, cihaz "çevrimdışı" görünür ama arıza değildir. Önlemler: kota-dostu keepalive (25 sn varsayılan, PİLOT verisiyle ayarlanır; gereksiz agresif değer kotayı eritir), paket bitimine Uptime Kuma "last seen" alarmı + sorumlu bayiye bildirim akışı (14), kota dönüşünde tünelin kendiliğinden toparlanması (`wg-quick` + keepalive yeniden handshake eder, ek işlem gerekmez). Paket yenileme/dönüş prosedürü (hat sahibi, yenileme adımı) 18-ROLLOUT runbook'una yazılır — bu dosya yalnızca teknik davranışı sabitler. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Boot sonrası `wg show` handshake | 3 dk içinde güncel handshake | LAB(2) | +| NAT arkasında 24 saat tünel | Kopma yok (sayaç temiz) | P1(5) | +| Kota simülasyonu (veri kes) → geri ver | Tünel kendiliğinden toparlanır | LAB(2) | +| SSH/RDP internetten tarama | Kapalı; yalnızca `10.8.0.0/16` içinden açık | LAB(2) | +| 700 eş yük projeksiyonu | Hub CPU/bant genişliği sınırlar içinde | Yük testi | + +## 11. Rollback + +1. Hatalı hub config'i Git'teki önceki sürüme döndürülür, `systemctl reload wg-quick@wg0`. +2. Keepalive değişikliği sorun çıkarırsa önceki değere Ansible ile toplu dönüş (10-UPDATE dalga sırası). +3. Son çare: WG down iken RustDesk/MeshCentral (07) ile cihaza girilip config elle düzeltilir. + +## 12. Kontrol Listesi + +- [ ] Peer adları `bf-<8hane>` ile birebir, envanterle eşleşiyor. +- [ ] Tüm istemcilerde `PersistentKeepalive = 25` (veya PİLOT-onaylı değer). +- [ ] `wg-quick@wg0` tüm sahada enable. +- [ ] SSH/RDP yalnızca WG arayüzünde dinliyor. +- [ ] PİLOT-1 kopma sayacı koşuyor, sonuç 24-DECISION-LOG'a işlenecek. + +## 13. Açık Sorular + +- [ ] Keepalive'ın PİLOT-1 verisine göre 25 sn'de kalıp kalmayacağı (sahibi: 08 yazarı, P1 sonrası). +- [ ] Hub'un 700 eşi tek VDS'te taşıyıp taşımayacağı — yük testi (sahibi: 25-ROADMAP). +- [ ] Turkcell kota/bitim prosedürünün işletme sahibi (sahibi: 18-ROLLOUT yazarı). + +--- + +## Ek: Mermaid — hub-spoke + keepalive + +```mermaid +flowchart TB + subgraph SAHA["Saha (NAT/CGNAT arkası)"] + S1["bf-12010193
10.8.0.15"] + S2["bf-12010194
10.8.0.16"] + end + HUB["WireGuard hub
10.8.0.1:51820"] + S1 -->|handshake + keepalive 25sn
giden bağlantı| HUB + S2 -->|handshake + keepalive 25sn
giden bağlantı| HUB + HUB -->|SSH/RDP yalnızca
tünel içi| S1 +``` +
+ + +# 09 — Filo Yönetimi (Ansible + Semaphore) + +> Kısa özet: 700 cihazın otomasyonu Ansible CLI (agent'sız, SSH) ile yapılır; operatör arayüzü Semaphore UI Community'dir (self-hosted, MIT). 15 standart işlem, tek/grup/pilot/tüm-filo hedefleme ve Semaphore Community sınırları bu dosyadadır. + +- Dosya: `docs/09-FLEET-MANAGEMENT.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-06` (Ansible + Semaphore Community), `K-13` (SSH anahtar dağıtımı), `K-12` (inventory adı = `bf-`), `K-11` (dalgalı yayılım) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihaza komut/config güncellemesini güvenli, tekrarlanabilir ve onaylı yaymanın yolunu sabitlemek: hangi araç, hangi 15 standart işlem, hangi hedefleme seviyeleri, Community sürümünün sınırlarında nasıl çalışılır. + +## 2. Kapsam + +- Kapsam içi: Ansible rolü/inventory düzeni, 15 standart işlem, hedefleme seviyeleri (tek/grup/pilot/tüm-filo), Semaphore kurulumu + Community sınırları, Semaphore-down yedeği. +- Kapsam dışı: monitoring (14), update onay zinciri detayı (10), SSH anahtar politikası (06). + +## 3. Kararlar + +```text +KARAR: Birincil filo otomasyonu Ansible CLI (SSH, agent'sız); operatör arayüzü Semaphore UI Community (self-hosted, MIT). Hiçbir playbook Pro/Enterprise özelliğine bağlanmaz. +GEREKÇE: En düşük hareketli parça (SSH + YAML + tek Go binary); 700 cihazda ek agent/master/K8s maliyeti yok. Community ücretsizliği resmi fiyat sayfasıyla kanıtlı ("$0, free forever"); Pro en fazla 500 managed node desteklediği için 700 cihazda ücretli yol zaten kapalı. +ALTERNATİF: AWX (K8s yükü + release'ler refactoring nedeniyle duraklatıldı → elendi); Salt (minion agent + master PKI → elendi); Rudder Community (relay/Enterprise modülleri + öğrenme eğrisi → elendi); MeshCentral (erişim aracı, filo otomasyonu değil → bu rolde elendi). +RİSK: Semaphore tek nokta arızası → Ansible CLI bağımsız çalışır (UI çökse filo durmaz). Community'de OIDC/2FA/Vault yok → Semaphore yalnızca WireGuard arkasında, erişim VPN + SSH anahtarı ile; gizliler yerleşik şifreli Key Store'da. +MALİYET: Ücretsiz (kendi VDS'i üzerinde). +LİSANS: Ansible GPL-3.0 — https://github.com/ansible/ansible; Semaphore Community MIT — https://github.com/semaphoreui/semaphore + https://semaphoreui.com/pricing (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Tüm filo işlemleri 15 standart işlemden biri olarak Semaphore task şablonu + Ansible playbook/rolüyle tanımlanır; serbest komut yalnızca acil kır cam prosedürüyle koşar. +GEREKÇE: 15 sabit işlem = denetlenebilir, test edilebilir, rollback'i tanımlı yüzey; serbest komut 700 cihazda yazım hatasını filo arızasına çevirir. +ALTERNATİF: Serbest ad-hoc komut serbestliği — insan hatası blast-radius'u nedeniyle elendi. +RİSK: 16. ihtiyaçta şablon dışı kalma; azaltma: yeni işlem teklifi PR ile 15 listesine eklenir (§13). +MALİYET: Ücretsiz. +LİSANS: Yok (operasyon standardı). +``` + +```text +KARAR: Hedefleme 4 seviyelidir: tek cihaz → grup → pilot dalga → tüm filo; tüm-filoya çıkış yalnızca pilot dalga başarısından sonra, onay adımlı Semaphore pipeline ile yapılır. +GEREKÇE: 10-UPDATE dalga zincirinin (LAB/P1/P2/W1/W2/PROD) filo-operasyon karşılığıdır; playbook hatası LAB(2)'de yakalanır, 700 cihaza yayılmaz. +ALTERNATİF: Doğrudan tüm-filo çalıştırma — tek hatanın 700 katı etki nedeniyle elendi. +RİSK: Yanlış grup etiketi yanlış hedefe koşar; azaltma: `--limit` + `--check` kuru koşu zorunlu ön adım. +MALİYET: Ücretsiz. +LİSANS: Yok (operasyon standardı). +``` + +## 4. Neden Bu Karar? + +700 cihazda ajanlı mimariler (Salt minion, Rudder agent, AWX+K8s) her cihaza bir ek yük ve merkeze bir ek servis bindirir. Ansible'da ajan yoktur: SSH varsa filo vardır. Semaphore, CLI bilgisi olmayan operatörün (saha sorumlusu) güvenli düğmelere basmasını sağlar; CLI ise Semaphore çöktüğünde bağımsız yedektir. Pro sürüm 500 node ile sınırlı olduğundan 700 cihazda zaten kullanılamaz — Community sınırı bir eksik değil, mimari sabittir. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| AWX | Güçlü UI, RBAC | K8s zorunlu, release'ler duraklatıldı | Elendi | +| Salt | Ölçeklenir | Minion agent + master PKI | Elendi | +| Rudder Community | Politika motoru | Relay/Enterprise modülleri, öğrenme eğrisi | Elendi | +| Semaphore Pro | Ek özellikler | 500 node limiti (700'e yetmez) + ücret | Elendi | +| MeshCentral (filo rolü) | Zaten kurulu | Toplu config/rollback disiplini yok | Bu rolde elendi | + +## 6. Avantajlar + +- Agent'sız: saha imajına ek yük yok, yeni cihaz envantere girer girmez yönetilir. +- Semaphore down olsa filo durmaz (Ansible CLI bağımsız). +- 15 sabit işlem + 4 hedefleme seviyesi = hata yüzeyi küçük, denetim izi tam. + +## 7. Dezavantajlar + +- Community'de OIDC/2FA/Vault yok — erişim disiplini (WG-arkası + Key Store) manuel korunmalı. +- Eşzamanlı 700 SSH bağlantısı Semaphore/Ansible fork ayar + hub bant genişliği ister (yük testi §13). +- Windows-odaklı teknisyen için YAML/playbook eşiği (Semaphore düğmeleriyle kapatılır). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Playbook hatası filoya yayılır | Orta | Yüksek | Dalgalı yayılım + `--check` kuru koşu + LAB zorunluluğu | +| Semaphore çöker | Düşük | Orta | Ansible CLI bağımsız çalışır (K-06) | +| Community'de kimlik disiplini bozulur | Orta | Yüksek | WG-arkası erişim + per-admin SSH key + Key Store; denetim (§12) | +| 700 eşzamanlı SSH hub'ı yorar | Orta | Orta | Fork/batch limitleri + dalga yayılımı; yük testi | + +## 9. Uygulama Planı + +1. Merkez: Semaphore Community (tek binary, WG-arkası) + Ansible CLI + Git-sürümlü playbook deposu kurulur. +2. Inventory: `bf-` adları, grup etiketleri (bölge/pilot-dalga/donanım-profili), `ansible_user=blueforce`, per-admin key (06). +3. 15 standart işlem playbook/rol + Semaphore task şablonu olarak yazılır, LAB(2)'de test edilir. +4. Hedefleme boru hattı: tek → grup → pilot → tüm-filo; son adıma onay kapısı konur. +5. Semaphore-down tatbikatı: CLI ile aynı playbook'un koştuğu kanıtlanır (10-Test). + +```bash +# örnek: kuru koşu + sınırlı hedef (zorunlu ön adım) +ansible-playbook -i inventory/prod.yml playbooks/bf-gui-off.yml --limit 'pilot1' --check +ansible-playbook -i inventory/prod.yml playbooks/bf-gui-off.yml --limit 'pilot1' + +# örnek: tek cihaz teşhisi +ansible -i inventory/prod.yml 'bf-12010193' -m ping +``` + +### 15 standart işlem + +| # | İşlem | Playbook/rol (ad) | +|---|---|---| +| 1 | ping (erişilebilirlik yoklaması) | `bf-ping.yml` | +| 2 | status (bf-status çıktısı toplama) | `bf-status.yml` | +| 3 | diagnostics (bf-diagnostics + bundle) | `bf-diagnostics.yml` | +| 4 | gui-on (grafik katmanı aç) | `bf-gui-on.yml` | +| 5 | gui-off (grafik katmanı kapat) | `bf-gui-off.yml` | +| 6 | reboot (kontrollü yeniden başlatma) | `bf-reboot.yml` | +| 7 | shutdown (kontrollü kapatma) | `bf-shutdown.yml` | +| 8 | ssh-keys (authorized_keys dağıtım/rotasyon) | `bf-ssh-keys.yml` | +| 9 | wg-rekey (WireGuard peer anahtar yenileme) | `bf-wg-rekey.yml` | +| 10 | docker-restart (MEG konteyner yeniden başlatma) | `bf-docker-restart.yml` | +| 11 | docker-update (sabit tag'e onaylı imaj geçişi) | `bf-docker-update.yml` | +| 12 | apt-update (onaylı paket güncellemesi, dalgalı) | `bf-apt-update.yml` | +| 13 | config-push (dosya/şablon dağıtımı) | `bf-config-push.yml` | +| 14 | log-collect (journald/destek paketi toplama) | `bf-log-collect.yml` | +| 15 | decommission (envanter + peer + key temizliği) | `bf-decommission.yml` | + +### Hedefleme seviyeleri + +| Seviye | Hedef | Onay | +|---|---|---| +| Tek | `bf-` (arıza/teşhis) | Gerekmez (loglanır) | +| Grup | bölge / donanım profili etiketi | Grup sorumlusu | +| Pilot | P1(5) → P2(20) dalgaları | Merkezi admin | +| Tüm-filo | kalan cihazlar (W1/W2/PROD sırasıyla) | Merkezi admin + değişiklik kaydı (10) | + +### Semaphore Community sınırları (çalışma kuralları) + +- OIDC/SSO yok → kullanıcılar Semaphore yerel hesabı, erişim yalnızca WireGuard arkasından. +- 2FA yok → parola politikası + VPN zorunluluğu bu açığı kapatır; admin hesapları envanterde kayıtlı. +- Harici Vault entegrasyonu yok → gizliler Semaphore yerleşik şifreli Key Store'da; prod secret'ları playbook'a gömülmez. +- Global Runner / yüksek eşzamanlılık sınırları → 700 node yük testi yapılır; gerekirse runner sayısı + fork/batch ayarlanır (§13 açık soru). +- Hiçbir iş akışı Pro özelliğine bağlanmaz (Community'de çalışmayan şablon yazılmaz). + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| 15 işlemin her biri LAB(2)'de | Başarılı + log'lu | LAB(2) | +| `--check` kuru koşu, değişimsiz playbook | "changed=0" | LAB(2) | +| Yanlış `--limit` denemesi | Onay kapısında durur / hedef dışı etkilenmez | LAB(2) | +| Semaphore kapalı → CLI run | Playbook başarıyla koşar | LAB(2) | +| Pilot dalga → tüm-filo boru hattı | Onay adımları çalışır | P1(5) | + +## 11. Rollback + +1. Her işlemin geri alma adımı playbook'ta tanımlıdır (config-push önceki sürümü basar, docker-update önceki tag'e döner). +2. Yayılım ortasında arıza: dalga durdurulur, etkilenmiş dalga 10-UPDATE rollback zinciriyle geri alınır. +3. Semaphore veritabanı bozulursa playbook deposu (Git) + inventory'den CLI ile işletim sürer; Semaphore yeniden kurulur. + +## 12. Kontrol Listesi + +- [ ] 15 işlemin playbook + Semaphore şablonu LAB-onaylı. +- [ ] Inventory adları `bf-`, grup etiketleri güncel. +- [ ] `--check` ön adımı tüm şablonlarda zorunlu. +- [ ] Semaphore yalnızca WG arkasından erişilebilir. +- [ ] CLI yedeği tatbikatla kanıtlandı. +- [ ] Hiçbir şablon Pro/Enterprise özelliği kullanmıyor. + +## 13. Açık Sorular + +- [ ] Semaphore Global Runner sayısı + eşzamanlılık (fork/batch) — 700 node yük testi (sahibi: 25-ROADMAP). +- [ ] 16. standart işlem teklifi süreci (PR şablonu) (sahibi: 09 yazarı). +- [ ] Inventory kaynak gerçeği (statik dosya mı, API mi) (sahibi: 02-NAMING yazarı). + +--- + +## Ek: Mermaid — dalgalı yayılım + +```mermaid +flowchart LR + DEV["Playbook PR
(Git review)"] --> LAB["LAB 2
--check + run"] + LAB --> P1["PILOT-1 5"] + P1 --> P2["PILOT-2 20"] + P2 --> W["W1 50 → W2 100"] + W --> PROD["PROD kalan"] + LAB -->|hata| FIX["Düzelt +
yeniden LAB"] +``` +
+ + +# 10 — Update ve Rollback Politikası + +> Kısa özet: Sahaya onaysız update yasaktır: `unattended-upgrades` kapalıdır, her değişiklik LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD dalgalarından ve merkezi onaydan geçer. Her dalganın geri alma planı yayından önce tanımlıdır. + +- Dosya: `docs/10-UPDATE-AND-ROLLBACK-POLICY.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-11` (otomatik update yasağı + dalgalar), `K-10` (`latest`/Watchtower yasakları), `K-09` (imaj bağı) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihazda "kendiliğinden değişen hiçbir şey olmasın" ilkesini teknik ve prosedürel kilitlerle garanti etmek: otomatik updater'ların kapatılması, onay zinciri, dalga planı ve rollback sözleşmesi. + +## 2. Kapsam + +- Kapsam içi: onaysız update yasağı, `unattended-upgrades` kapatma, dalga tanımı ve geçiş kriterleri, onay rolleri, rollback zinciri. +- Kapsam dışı: playbook yazım detayları (09), imaj üretim adımları (04), Docker tag disiplini detayı (12). + +## 3. Kararlar + +```text +KARAR: Golden image'da `unattended-upgrades` kapatılır (`/etc/apt/apt.conf.d/20auto-upgrades` değerleri `0` + `apt-daily*.timer` maskeleme); tüm güncellemeler LAB→PİLOT→WAVE→PROD onay zincirinden geçer (dalgalar: LAB(2)/P1(5)/P2(20)/W1(50)/W2(100)/PROD). +GEREKÇE: `unattended-upgrades` Ubuntu Server'da varsayılan kurulu ve etkindir, günde bir kez çalışır; kapatılmazsa 700 cihaz ilk açılışta güncellemeye kalkar ("onaysız update yasak" ilkesi delinir). Kaçan timer makine açılışında hemen tetiklenir (`Persistent=true` davranışı) — saha PC'leri sık kapanan makinelerdir. +ALTERNATİF: Otomatik security update'e izin verme — 700 cihazda eşzamanlı indirme + onaysız değişim riski nedeniyle elendi; security update'ler öncelik işaretli onaylı dalga ile çıkar. +RİSK: Kapatma adımı imajda atlanırsa filo kendiliğinden güncellenir; azaltma: `blueforce-install.sh` final-check + monitoring'de `Unattended-Upgrade` durumu metriği. +MALİYET: Ücretsiz. +LİSANS: Yok (işletim sistemi yapılandırması) — https://ubuntu.com/server/docs/how-to/software/automatic-updates/ + https://manpages.ubuntu.com/manpages/resolute/en/man8/unattended-upgrade.8.html (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Onaysız update yasaktır: sahaya inen her değişiklik (OS paketi, Docker imajı, config, agent sürümü) değişiklik kaydı + merkezi admin onayı + dalga planı olmadan uygulanmaz; `latest` etiketi ve Watchtower gibi otomatik updater'lar yasaktır. +GEREKÇE: 700 ölçeğinde her "küçük varsayılan" (günlük otomatik update, hareketli etiket, otomatik imaj çekme) filo-çapı arızaya dönüşür; determinizm yalnızca sabit sürüm + onaylı yayılımla korunur. +ALTERNATİF: Güvenlik güncellemelerine otomatik izin — eşzamanlı indirme fırtınası + onaysız değişim riski nedeniyle elendi; acil güvenlik yaması "hızlandırılmış dalga" prosedürüyle (aynı zincir, kısaltılmış bekleme) çıkar. +RİSK: Onay kuyruğu kritik yamayı geciktirir; azaltma: security dalgasına öncelik SLA'sı (§9). +MALİYET: Ücretsiz. +LİSANS: Yok (politika) — Docker yasak dayanağı için 12-DOCKER ve K-10'a bakılır. +``` + +```text +KARAR: Rollback her yayının parçasıdır: dalgaya çıkmadan önce geri alma adımı (önceki paket sürümü / önceki imaj tag'i / önceki config) tanımlanır ve LAB'da geri dönüş testi yapılır; başarısız dalga bir sonrakine geçmez, etkilenmiş dalga geri alınır. +GEREKÇE: Geri alması test edilmemiş yayın, 700 cihazda tek yönlü kapıdır; LAB'da kanıtlanan rollback, PROD'da gece yarısı kurtarma prosedürüne dönüşür. +ALTERNATİF: "Gerekirse bakarız" yaklaşımı — arıza anında doğaçlama riski nedeniyle elendi. +RİSK: Geri alma adımı da bozuk çıkabilir; azaltma: son çare 17-RECOVERY yeniden-imaj prosedürü. +MALİYET: Ücretsiz. +LİSANS: Yok (operasyon standardı). +``` + +## 4. Neden Bu Karar? + +Üç yasak tek ilkeden doğar: `unattended-upgrades` (işletim sistemi kendi kendine değişmesin), `latest` (hangi sürümün koştuğu her cihazda aynı bilinsin), Watchtower (hiçbir daemon izinsiz imaj çekmesin). Dalgalar (2 → 5 → 20 → 50 → 100 → ~523) hatanın blast-radius'unu geometrik olarak sınırlar: LAB'da yakalanan hata 2 cihazı etkiler, PROD'a sızan hata ancak 5 dalga kapısından geçmiş demektir. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Otomatik security update | Hızlı yama | Eşzamanlı indirme + onaysız değişim | Elendi (hızlandırılmış dalga ile karşılanır) | +| Doğrudan tüm-filo yayın | Hızlı | Tek hatanın 700 katı | Elendi | +| Watchtower ile oto-imaj | Eforsuz güncellik | Politika ihlali + upstream arşivli | Elendi (K-10) | +| `latest` etiketi | Kolay | Determinizm kaybı | Elendi (K-10) | + +## 6. Avantajlar + +- Filo sürüm determinizmi: her cihazın ne koşturduğu envanterde bilinir. +- Hata erken dalgada yakalanır; PROD'a ulaşan değişiklik 5 kez kanıtlanmıştır. +- Acil yama da aynı zincirden geçer — hız, disiplinsizlikle değil kısaltılmış beklemeyle kazanılır. + +## 7. Dezavantajlar + +- Yayın döngüsü yavaştır (özellikle PROD'a tam yayılım günler sürebilir). +- Onay kuyruğu ve dalga beklemeleri operasyonel disiplin ister. +- Her yayın için rollback testi yazma maliyeti. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| `unattended-upgrades` imajda açık unutulur | Orta | Yüksek | install.sh final-check + monitoring metriği (K-11) | +| Onay kuyruğu kritik yamayı geciktirir | Orta | Yüksek | Security dalgasına öncelik SLA'sı (§9) | +| Dalga atlama ("acil" bahanesiyle) | Orta | Yüksek | Semaphore onay kapısı + değişiklik kaydı zorunluluğu | +| Rollback adımı da bozuk | Düşük | Yüksek | LAB geri-dönüş testi + 17-RECOVERY son çare | + +## 9. Uygulama Planı + +1. Golden image: `20auto-upgrades` değerleri `0`, `apt-daily*.timer` maskeli, final-check'te doğrulamalı (04). +2. Değişiklik kaydı açılır: kapsam (paket/imaj/config), etkilenen sürümler, geri alma adımı, dalga takvimi. +3. Merkezi admin onaylar; yayın Semaphore pipeline ile dalga sırasına girer (09). +4. Her dalgada sağlık kapısı: monitoring (14) + `bf-status` örneklemesi temizse sonraki dalga açılır. +5. Başarısız dalga: yayılım durdurulur, etkilenmiş dalga rollback zinciriyle geri alınır, kök neden kayda işlenir. + +```bash +# örnek: otomatik update kilit denetimi (saha cihazında) +cat /etc/apt/apt.conf.d/20auto-upgrades # iki değer de 0 olmalı +systemctl status apt-daily.timer apt-daily-upgrade.timer # maskeli/inactive +``` + +### Dalga tanımı ve geçiş kriterleri + +| Dalga | Büyüklük | Geçiş kriteri (sonraki dalgaya) | +|---|---|---| +| LAB | 2 cihaz | Fonksiyonel test + rollback testi geçti | +| P1 (PİLOT-1) | 5 cihaz | 24–48 saat sorunsuz + metrikler temiz | +| P2 (PİLOT-2) | 20 cihaz | 48–72 saat sorunsuz + saha geri bildirimi | +| W1 (WAVE-1) | 50 cihaz | Sağlık kapısı + onay yenileme | +| W2 (WAVE-2) | 100 cihaz | Sağlık kapısı + onay yenileme | +| PROD | kalan ~523 cihaz | W2 temizliği + final onay | + +### Security yaması öncelik SLA'sı + +Kritik güvenlik bülteninde aynı zincir kısaltılmış beklemeyle koşar (LAB saatler içinde, P1 aynı gün); bekleme kısaltılır, kapılar kaldırılmaz — onay ve rollback testi security dalgasında da zorunludur. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `20auto-upgrades` değerleri + timer durumu | `0` / maskeli, 24 saatte otomatik apt yok | LAB(2) imaj denetimi | +| Kuru yayın (tüm dalgalar `--check`) | Değişim öngörüsü kayıtla eşleşir | LAB(2) | +| LAB rollback testi | Önceki sürüme dönüş + servis ayakta | LAB(2) | +| P1 48 saat gözlem | Metrik anomalisi yok | P1(5) | +| Dalga-durdurma tatbikatı | Yayılım durur, P1 geri alınır | P1(5) | + +## 11. Rollback + +1. Etkilenmiş dalga, değişiklik kaydındaki geri alma adımıyla önceki sürüme döndürülür (paket downgrade / önceki imaj tag'i / önceki config). +2. Geri alma sonrası sağlık kapısı (monitoring + `bf-status`) doğrulanır, kayda işlenir. +3. Geri alma da başarısızsa: cihaz 17-RECOVERY prosedürüyle önceki golden image'a döndürülür (LEVEL 9 son çare). +4. Kök neden bulunmadan yayına devam edilmez; zincir başa (LAB) sarar. + +## 12. Kontrol Listesi + +- [ ] `unattended-upgrades` tüm sahada kapalı (monitoring metriğiyle sürekli denetim). +- [ ] `latest`/`Watchtower` hiçbir saha cihazında yok (12 ile birlikte denetim). +- [ ] Her yayının değişiklik kaydı + onayı + rollback adımı var. +- [ ] Dalga kapıları atlanmadan işletildi (Semaphore log'uyla kanıtlı). +- [ ] Security SLA'sı runbook'ta yazılı, sorumlusu belli. + +## 13. Açık Sorular + +- [ ] Dalga bekleme sürelerinin (24–72 saat) PİLOT verisine göre kalibrasyonu (sahibi: 10 yazarı, P2 sonrası). +- [ ] Paket/imaj aynası (yerel mirror) gerekip gerekmediği — 700 cihazın eşzamanlı indirme yükü (sahibi: 25-ROADMAP). + +--- + +## Ek: Mermaid — update onay akışı + +```mermaid +flowchart LR + F["Bulundu
(bülten / MEG sürümü)"] --> R["Kayıt
(etki + rollback planı)"] + R --> O{"Onay
(merkezi admin)"} + O -->|Red| F + O -->|Onay| LAB["LAB 2"] + LAB -->|Başarılı| P1["P1 5"] + P1 -->|Başarılı| P2["P2 20"] + P2 -->|Başarılı| W1["W1 50"] + W1 -->|Başarılı| W2["W2 100"] + W2 -->|Başarılı| PROD["PROD ~523"] + LAB -->|Başarısız| RB["Rollback +
kök neden"] + P1 -->|Başarısız| RB + P2 -->|Başarısız| RB + W1 -->|Başarısız| RB + W2 -->|Başarısız| RB +``` +
+ + +# 11 — Güvenlik Sertleştirme (Security Hardening) + +> Kısa özet: saha cihazının güvenlik tabanı: UFW default DENY, WireGuard arayüz izinleri, Docker-UFW bypass çözümü, SSH sertleştirme. Teknik ekip ve denetim içindir. +> +> - Dosya: `docs/11-SECURITY-HARDENING.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-05`, `#K-10`, `#K-13` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 saha cihazında varsayılan-kapalı ağ + en az ayrıcalık ilkesiyle saldırı yüzeyini küçültmek; Docker'ın UFW'yi baypas etme davranışını standart çözüme bağlamak. + +## 2. Kapsam + +- Kapsam içi: UFW politikası, WireGuard arayüz kuralları, Docker bypass çözümü, SSH/userspace sertleştirme, denetim komutları. +- Kapsam dışı: WireGuard kurulum detayı (08), Docker işletimi (12), kullanıcı/anahtar dağıtımı (06). + +## 3. Kararlar + +```text +KARAR: UFW varsayılan politika: incoming DENY, outgoing ALLOW; izinler tek tek açılır. +GEREKÇE: Varsayılan-kapalı, unutulan portu dışa kapatır; 700 cihazda en güvenli varsayılan budur. +ALTERNATİF: Varsayılan ALLOW + kara liste — unutulan servis dışa açılır, elendi. +RİSK: Kural unutulursa meşru trafik kesilir; azaltma: LAB'da kural seti test edilir, bf-status denetler. +MALİYET: Ücretsiz. +LİSANS: UFW (Ubuntu paketi, GPL bileşenler) — https://manpages.ubuntu.com/manpages/resolute/en/man8/ufw.8.html (doğrulanma: 2026-09-15). +``` + +```text +KARAR: SSH (22) ve RDP (3389) yalnızca WireGuard arayüzünden (`wg0`) kabul edilir; herkese açık internete kapalıdır. +GEREKÇE: K-05 gereği yönetim düzlemi tünel arkasındadır; dış taramada port görünmez. +ALTERNATİF: İnternete açık SSH + fail2ban — kaba-kuvvet yüzeyini gereksiz büyütür, elendi. +RİSK: WireGuard çökerse SSH erişimi kesilir; azaltma: RustDesk/MeshCentral bağımsız kanallar (K-04). +MALİYET: Ücretsiz. +LİSANS: Yok (yapılandırma). +``` + +```text +KARAR: Docker-UFW bypass sorununun çözümü: yayımlanan tüm saha portları `127.0.0.1`'e bağlanır; `iptables:false` varsayılan çözüm değildir. +GEREKÇE: Docker, yayımlanan portlarda UFW kurallarını baypas eder (resmi belgeli); localhost-bind, servisi dış arayüzden görünmez kılar, erişim WireGuard üzerinden olur. +ALTERNATİF: `iptables:false` — resmi doküman "çoğu kullanıcı için uygun değil" diye uyarır; yedek seçenek olarak tutulur. +RİSK: Yanlış bind ile port dışa açılır; azaltma: `docker ps` + `ss -tlnp` denetimi imaj final-check'te. +MALİYET: Ücretsiz (Docker CE). +LİSANS: Docker CE ücretsiz — https://docs.docker.com/engine/network/packet-filtering-firewalls/ (doğrulanma: 2026-09-15). +``` + +```text +KARAR: SSH sertleştirme: `blueforce` bakım kullanıcısı + anahtar zorunlu, password auth kapalı, root SSH kapalı (K-13 uygulanır). +GEREKÇE: Kaba-kuvvet ve tek-key blast-radius riskini kapatır. +ALTERNATİF: Password auth — saldırı yüzeyi nedeniyle elendi. +RİSK: Anahtar kaybı erişimsizlik; azaltma: per-admin anahtar + Ansible ile merkezi dağıtım. +MALİYET: Ücretsiz. +LİSANS: OpenSSH (BSD-lisanslı), ücretsiz. +``` + +## 4. Neden Bu Karar? + +Üç filtre: (1) varsayılan-kapalı (UFW DENY + WG-arkası yönetim), (2) Docker gerçeğine uyum (bypass inkâr edilmez, localhost-bind ile etkisizleştirilir), (3) ücretsiz katman (ek güvenlik ürünü yok). + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Varsayılan ALLOW + kara liste | Kolay | Unutulan port dışa açılır | Elendi | +| İnternete açık SSH + fail2ban | Basit | Kaba-kuvvet yüzeyi | Elendi | +| `iptables:false` | Bypass'ı kökten keser | Docker ağ işlevlerini bozar, resmi uyarı var | Yedek | +| Ücretli EDR/host güvenlik ajanı | Merkezi konsol | Lisans maliyeti, sadelik ihlali | Elendi | + +## 6. Avantajlar + +- Tek standart kural seti 700 cihaza aynen uygulanır; denetimi Ansible ile otomatik. +- Docker bypass'ı mimari düzeyde çözülür, kural-dışı port açığı kalmaz. + +## 7. Dezavantajlar + +- localhost-bind, konteyner portunu doğrudan LAN'dan kullanılamaz kılar (erişim WG üzerinden olur — bilinçli tercih). +- UFW kural değişikliği hatalı playbook ile filoya yayılabilir; önce LAB(2). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Bypass yüzünden MEG portu dışa açılır | Orta | Yüksek | localhost-bind + final-check denetimi | +| Kural hatası WG trafiğini keser | Düşük | Yüksek | LAB testi + `ufw --dry-run` + konsol erişimi | +| Semaphore Community'de 2FA yok | Orta | Orta | Semaphore yalnızca WG arkasında | + +## 9. Uygulama Planı + +1. UFW varsayılanları ve taban kuralları uygulanır. +2. WireGuard arayüzüne SSH/RDP izni, dış dünyaya ret yazılır. +3. Docker portları `127.0.0.1` bind ile doğrulanır. +4. SSH sertleştirme (`PasswordAuthentication no`, `PermitRootLogin no`) uygulanır. + +```bash +# UFW taban politikası +ufw default deny incoming +ufw default allow outgoing +ufw allow in on wg0 to any port 22 proto tcp +ufw allow in on wg0 to any port 3389 proto tcp +ufw allow 51820/udp +ufw enable + +# durum denetimi +ufw status verbose +ss -tlnp | grep -v '127\.0\.0\.1' +``` + +```bash +# SSH sertleştirme denetimi +sshd -T | grep -Ei 'passwordauthentication|permitrootlogin' +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `ufw status verbose` | default DENY incoming | LAB(2) | +| Dış taramada 22/3389 | Kapalı (filtreli) | P1(5) | +| `ss -tlnp` bind denetimi | `0.0.0.0` bind yok | LAB(2) | +| Password ile SSH denemesi | Red | LAB(2) | +| WG down iken dış SSH | Bağlantı yok | P1(5) | + +## 11. Rollback + +1. Hatalı kuralda önceki UFW profili geri yüklenir (`ufw reset` + bilinen-iyi kural seti Ansible'dan). +2. SSH kilitlenmesinde konsol/RustDesk üzerinden düzeltilir. +3. Son çare: golden image yeniden kurulumu (17-RECOVERY). + +## 12. Kontrol Listesi + +- [ ] `ufw default deny incoming` tüm sahada aktif. +- [ ] 22/3389 yalnızca `wg0` üzerinden erişilebilir. +- [ ] Docker `0.0.0.0` bind yok. +- [ ] `PasswordAuthentication no`, `PermitRootLogin no` doğrulandı. + +## 13. Açık Sorular + +- [ ] WireGuard port numarası standardı (51820 varsayılan mı?) — 08 yazarı + LAB (sahibi: Faz 3). +- [ ] Ek UFW log seviyesi/rate-limit değerleri — PİLOT ölçümü (sahibi: 11 yazarı). + +--- + +## Ek: Mermaid — Güvenlik katmanları + +```mermaid +flowchart LR + INT["Internet"] --> UFW["UFW
default DENY in"] + UFW -->|51820/udp| WG["WireGuard wg0"] + WG -->|22, 3389| MGMT["SSH/RDP"] + UFW -->|RustDesk/MeshCentral
giden| RD["Bağımsız kanallar"] + DKR["Docker
127.0.0.1 bind"] -.->|dışa kapalı| UFW +``` +
+ + +# 12 — Docker Operasyonları + +> Kısa özet: saha Docker disiplini: otostart, `unless-stopped`, pinli imaj, `latest` yasağı, log rotasyonu, Watchtower yok. Konteyner işleten teknik ekip içindir. +> +> - Dosya: `docs/12-DOCKER-OPERATIONS.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-10` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Elektrik kesintisi sonrası konteynerlerin kendiliğinden ve doğru sürümle ayağa kalkması; disiplinsiz imaj/log davranışının 700 cihazda filo arızasına dönüşmesini engellemek. + +## 2. Kapsam + +- Kapsam içi: restart politikası, imaj pinleme, `latest`/Watchtower yasakları, log rotasyonu, port bind kuralı, otostart denetimi. +- Kapsam dışı: MEG kabul testleri (13), log içerik politikası (15), boot zinciri systemd sırası (16). + +## 3. Kararlar + +```text +KARAR: Tüm saha konteynerlerinde restart politikası `unless-stopped` kullanılır; Docker daemon açılışta otomatik başlar. +GEREKÇE: `unless-stopped`, teknisyenin bakım için durdurduğunu reboot sonrası sürpriz çalıştırmaz; elektrik kesintisi "manuel stop" sayılmadığı için kurtarma aynen çalışır. Daemon enable olmadan otostart anlamsızdır. +ALTERNATİF: `always` — bakım-stop'u reboot'ta deler, elendi. `on-failure`/`no` — kesinti kurtarması sağlamaz, elendi. +RİSK: Yanlış politikayla kritik konteyner kalkmaz; azaltma: `docker inspect` final-check'te. +MALİYET: Ücretsiz (Docker CE). +LİSANS: Docker CE ücretsiz — https://docs.docker.com/engine/containers/start-containers-automatically/ (doğrulanma: 2026-09-15). +``` + +```text +KARAR: `latest` etiketi yasaktır; her imaj sabit tag ile pinlenir (örn. `meg:1.2.3`); digest pinleme (`@sha256`) PİLOT-2'de değerlendirilir. +GEREKÇE: Hareketli `latest` 700 cihazda determinizmi bozar; "onaysız update yasak" ilkesi sabit tag gerektirir. +ALTERNATİF: `latest` + periyodik pull — onaysız değişim riski, elendi. +RİSK: Tag yeniden yazılırsa (mutable tag) pinleme delinir; azaltma: PİLOT-2'de digest kararı. +MALİYET: Ücretsiz. +LİSANS: Yok (operasyon politikası). +``` + +```text +KARAR: Watchtower (veya eşdeğeri otomatik güncelleyici) sahada yasaktır ve kurulmaz. +GEREKÇE: Hem "onaysız update yasak" ilkesini ihlal eder hem upstream'de arşivlenmiştir. +ALTERNATİF: Programlı otomatik pull — politika ihlali, elendi. +RİSK: Manuel update gecikmesi; azaltma: 10-UPDATE dalga zinciri. +MALİYET: Ücretsiz. +LİSANS: Watchtower arşiv durumu — https://github.com/containrrr/watchtower (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Log rotasyonu `daemon.json`'da zorunludur (`json-file`: `max-size` + `max-file`); rotasyonsuz log ile cihaz sahaya çıkmaz. +GEREKÇE: Docker varsayılan `json-file` logu rotasyon yapmaz; dolan disk = kilitlenen cihaz (700 katı etki). +ALTERNATİF: Rotasyonsuz + periyodik manuel temizlik — unutulur, elendi. +RİSK: Değerler MEG hacmine göre yanlışsa log kaybolur veya disk dolar; azaltma: PİLOT'ta hacim ölçülür, değer sabitlenir. +MALİYET: Ücretsiz. +LİSANS: Docker CE ücretsiz — https://docs.docker.com/engine/logging/configure/ (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Yayımlanan portlar `127.0.0.1`'e bağlanır (UFW bypass'a karşı); kural 11-HARDENING ile birlikte denetlenir. +GEREKÇE: Docker yayımlanan portlarda UFW'yi baypas eder (resmi belgeli); localhost-bind dışa açılmayı kapatır. +ALTERNATİF: `iptables:false` — resmi uyarı nedeniyle varsayılan değil, yedek. +RİSK: Hatalı compose ile `0.0.0.0` bind; azaltma: final-check'te `ss -tlnp` denetimi. +MALİYET: Ücretsiz. +LİSANS: Docker CE ücretsiz — https://docs.docker.com/engine/network/packet-filtering-firewalls/ (doğrulanma: 2026-09-15). +``` + +## 4. Neden Bu Karar? + +Tek ilke — öngörülebilir filo: cihaz neyle kapandıysa onunla açılır (unless-stopped), hangi sürüm onaylandıysa o çalışır (pinli tag), disk logla dolmaz (rotasyon), hiçbir şey kendiliğinden güncellenmez (Watchtower yok). + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| `always` | Her koşulda ayakta | Bakım davranışını bozar | Elendi | +| `latest` serbest | Kolay pull | Determinizm yok | Elendi | +| Watchtower | Otomatik güncellik | Politika ihlali + arşivli | Elendi | +| `iptables:false` (varsayılan) | Bypass'ı keser | Resmi "çoğu kullanıcı için uygun değil" uyarısı | Yedek | + +## 6. Avantajlar + +- Kesinti kurtarma ile bakım öngörülebilirliği aynı politikada birleşir. +- Deterministik sürüm + rotasyonlu log = uzaktan tanı güvenilirliği. + +## 7. Dezavantajlar + +- Sabit tag disiplini update prosedürüne yük bindirir (10-UPDATE ile işletilir). +- Rotasyon değerleri PİLOT ölçümüne kadar kesinleşmez. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| `max-size/max-file` yanlış ayar | Orta | Orta | PİLOT hacim ölçümü | +| Mutable tag yeniden yazımı | Düşük | Yüksek | Digest pinleme değerlendirmesi (PİLOT-2) | +| Daemon enable unutulur | Düşük | Yüksek | install.sh final-check | + +## 9. Uygulama Planı + +1. `daemon.json` yazılır (log rotasyonu), daemon enable edilir. +2. Compose dosyalarında restart + sabit tag + localhost bind uygulanır. +3. Final-check ile `docker inspect` / `ss` denetimi yapılır. + +```bash +# daemon.json (örnek — boyut değerleri PİLOT ölçümüyle kesinleşir) +cat /etc/docker/daemon.json +# {"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}} + +systemctl enable --now docker + +# compose parçası (örnek) +# ports: ["127.0.0.1:8080:8080"] +# restart: unless-stopped +# image: meg:1.2.3 # latest YASAK +``` + +```bash +# denetim +docker inspect --format '{{.HostConfig.RestartPolicy.Name}} {{.Config.Image}}' $(docker ps -aq) +docker info --format '{{json .LoggingDriver}}' +ss -tlnp | grep -v '127\.0\.0\.1' +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `docker inspect` restart | `unless-stopped` tamamında | LAB(2) | +| İmaj tag denetimi | `latest` yok, sabit tag var | LAB(2) | +| Güç kesintisi simülasyonu | Konteynerler otomatik kalkar | LAB(2) | +| Manuel stop + reboot | Durdurulan kalkmaz | LAB(2) | +| Log boyutu denetimi | `max-size/max-file` aşımı yok | P1(5) | + +## 11. Rollback + +1. Yanlış imajda önceki pinli tag'e dönüş (`docker compose pull/pull + up -d` onaylı sürümle). +2. Bozuk daemon.json'da bilinen-iyi dosya + `systemctl restart docker`. +3. Son çare: 10-UPDATE rollback zinciri. + +## 12. Kontrol Listesi + +- [ ] `systemctl is-enabled docker` → enabled. +- [ ] Tüm saha konteynerleri `unless-stopped`. +- [ ] `latest` yok, sabit tag var. +- [ ] Watchtower kurulu değil. +- [ ] `daemon.json` rotasyon aktif. +- [ ] `0.0.0.0` bind yok. + +## 13. Açık Sorular + +- [ ] `max-size/max-file` kesin değerleri — PİLOT hacim ölçümü (sahibi: 12 yazarı). +- [ ] Digest pinleme (`@sha256`) kararı — PİLOT-2 (sahibi: 12 yazarı). + +--- + +## Ek: Mermaid — Konteyner yaşam döngüsü + +```mermaid +flowchart TB + BOOT["Boot: docker enable"] --> UP["unless-stopped
otomatik kalkış"] + UP --> PIN["Pinli tag
(latest yok)"] + PIN --> LOG["Rotasyonlu log
daemon.json"] + LOG --> BIND["127.0.0.1 bind
(UFW bypass kapalı)"] + STOP["Manuel stop
(bakım)"] -.->|reboot sonrası kalkmaz| UP +``` +
+ + +# 13 — MEG Linux Kabul Kriterleri (Acceptance) + +> Kısa özet: MEG'in saha Linux'unda çalışır sayılması için 16 maddelik kabul listesi; paket içyapısı varsayılmaz, yalnızca gözlenebilir davranış test edilir. Kabul ekibi ve LAB sorumlusu içindir. +> +> - Dosya: `docs/13-MEG-LINUX-ACCEPTANCE.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-02`, `#K-10`, `#K-14` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +"Çalışıyor" iddiasını denetlenebilir eşiğe bağlamak: MEG sürümü, GNOME + Docker zemininde 16 maddeyi geçmeden PİLOT dalgasına alınmaz. + +## 2. Kapsam + +- Kapsam içi: kurulum/çalışma/sağlık/log/kurtarma gözlemleri (16 madde). +- Kapsam dışı: MEG paket içyapısı, kaynak kodu, lisans koşulları (varsayılmaz; satıcı dokümanına havale). + +## 3. Kararlar + +```text +KARAR: MEG kabulü 16 maddelik davranış listesidir; paket detayı varsayılmaz, her madde PASS/FAIL ile kapatılır. +GEREKÇE: İçyapı varsayımı her satıcı sürümünde kırılır; gözlenebilir davranış (kurulur, kalkar, yanıt verir, log üretir, kurtarır) sürümler arası sabittir. +ALTERNATİF: Paket-içi dosya yolu kontrolü — satıcı değişikliğinde test çöp olur, elendi. +RİSK: Davranış testleri içsel bozulmayı geç fark eder; azaltma: metrik + log izleme (14/15) tamamlayıcıdır. +MALİYET: Ücretsiz (test emeği). +LİSANS: Yok (kabul prosedürü). +``` + +```text +KARAR: Kabul GNOME + `unless-stopped` + pinli tag zemininde koşar; `latest` ile kabul geçersizdir. +GEREKÇE: K-02/K-10 zemini dışında geçen test saha gerçeğini temsil etmez. +ALTERNATİF: XFCE/latest zeminde kabul — saha dışı, elendi. +RİSK: Yok (zemin standardı 03/12'de kilitli). +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +MEG satıcı bağımlılığının en kırılgan noktasıdır; kabul listesi satıcının ne dağıttığına değil cihazın ne gösterdiğine bakar. Böylece sürüm değişse bile liste yaşar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Paket-içi yol kontrolü | Hızlı | Sürümde kırılır | Elendi | +| Tek "açılıyor mu" testi | Çok kolay | Kurtarma/log körlüğü | Elendi | +| Satıcı beyanıyla kabul | Emeksiz | Doğrulanmamış | Elendi | + +## 6. Avantajlar + +- 16 madde tek sayfada denetlenir; LAB(2) sonucu PİLOT kapısı olur. +- Satıcı sürümü değişse liste değişmez. + +## 7. Dezavantajlar + +- Liste, MEG işlevsel doğruluğunu (iş mantığı) test etmez; yalnızca platform uyumunu test eder. +- 16 maddenin tamamı her sürümde yeniden koşulmalıdır. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| GNOME grafik bağımlılığı/ek ayar eksik | Orta | Yüksek | Madde 9–10 erken yakalar | +| Log hacmi diski doldurur | Orta | Orta | Madde 12 + 12-DOCKER rotasyonu | +| Kesinti sonrası MEG kalkmaz | Düşük | Yüksek | Madde 13–14 | + +## 9. Uygulama Planı + +Kabul, LAB(2) cihazında pinli tag ile koşar; her madde PASS/FAIL işlenir, sonuç kabul tutanağına yazılır. + +```bash +# kabul öncesi zemin denetimi +docker inspect --format '{{.HostConfig.RestartPolicy.Name}} {{.Config.Image}}' +systemctl is-enabled docker +ufw status verbose +``` + +### MEG ACCEPTANCE — 16 madde + +| # | Madde | Nasıl bakılır | PASS eşiği | +|---|---|---|---| +| 1 | Kurulum tamamlanır | Onaylı prosedürle kurulum uca kadar gider | Hatasız tamamlanır | +| 2 | Sabit sürüm kimliği | Sürüm/etiket sorgulanır | `latest` yok, sürüm okunur ve tutanakla eşleşir | +| 3 | Konteyner/servis ayakta | `docker ps` / servis durumu | Çalışıyor (restart döngüsü yok) | +| 4 | Restart politikası | `docker inspect` | `unless-stopped` | +| 5 | Port bind | `ss -tlnp` | Dışa açık `0.0.0.0` bind yok | +| 6 | Sağlık yanıtı | Sağlık endpoint'i/komutu (varsa) veya süreç canlılığı | Yanıt veriyor / süreç yaşıyor | +| 7 | Temel işlev duman testi | Tanımlı en küçük iş akışı uçtan uca | Hatasız tamamlanır | +| 8 | Yeniden başlatmada toparlanma | Konteyner restart edilir | ≤ tanımlı sürede sağlığa döner | +| 9 | GNOME uyumu | GNOME oturumunda grafik gereksinim (varsa) açılır | Görüntü/oturum hatasız | +| 10 | RDP üzerinden erişim | xRDP + GNOME ile uzaktan oturum | Oturum açılır, kritik hata yok | +| 11 | Log üretimi | Log çıktısı gözlenir | Tanımlı olaylar logda görülür | +| 12 | Log rotasyonu altında çalışma | Rotasyon aktifken 24 saat çalışma | Disk taşması yok, log kaybı kritik değil | +| 13 | Güç kesintisi kurtarması | Güç kesintisi simülasyonu | Boot sonrası kendiliğinden sağlığa döner | +| 14 | WireGuard yokluğunda çalışma | WG down edilir | Yerel işlev sürer (bağımlılık varsa tutanağa yazılır) | +| 15 | Monitoring görünürlüğü | Prometheus/Grafana + Kuma | Cihaz ve MEG sağlığı panoda görünür | +| 16 | Destek paketi üretimi | `bf-support-bundle` çalıştırılır | Secret sızdırmadan paket üretilir | + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| 16 maddenin tamamı LAB(2)'de | Tümü PASS | LAB(2) | +| Başarısız maddede sürüm reddi | PİLOT'a alınmaz, satıcıya iade | LAB(2) | +| PASS sürümün P1(5) tekrarı | 1,3,6,8,13,15 yeniden PASS | P1(5) | + +## 11. Rollback + +1. Kabulü geçemeyen sürüm sahaya sürülmez; önceki kabul görmüş pinli tag sahada kalır. +2. Yanlışlıkla sürülen sürüm 10-UPDATE rollback zinciriyle geri alınır. + +## 12. Kontrol Listesi + +- [ ] 16 maddenin PASS/FAIL kaydı tutanakta var. +- [ ] Sürüm kimliği tutanakla eşleşiyor. +- [ ] `latest` kullanılmadı. +- [ ] Başarısız madde varsa PİLOT onayı verilmedi. + +## 13. Açık Sorular + +- [ ] Madde 8/13 için süre eşiği (örn. ≤5 dk) — PİLOT ölçümüyle sabitlenecek (sahibi: 13 yazarı). +- [ ] Madde 7 duman testinin kapsamı — MEG işlev sahibiyle tanımlanacak (sahibi: Faz 3). + +--- + +## Ek: Mermaid — Kabul kapısı + +```mermaid +flowchart LR + VER["MEG sürümü"] --> LAB["LAB 2: 16 madde"] + LAB -->|Tümü PASS| P1["PILOT-1 5: kritik 6 madde"] + LAB -->|Herhangi FAIL| REJ["RED: sahaya alınmaz"] + P1 -->|PASS| WAVE["Dalgalar (10-UPDATE)"] + P1 -->|FAIL| REJ +``` + + + +# 14 — İzleme ve Sağlık (Monitoring & Health) + +> Kısa özet: 17 metrik + Prometheus/Node Exporter/Grafana OSS + Uptime Kuma + `bf-status`/`bf-diagnostics`/`bf-support-bundle` sözleşmeleri. Monitoring operatörü ve saha teknisyeni içindir. +> +> - Dosya: `docs/14-MONITORING-AND-HEALTH.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-07`, `#K-14` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihazda "hangisi ölü, hangisi hasta, hangisi sağlıklı" sorusuna iki perdede cevap: mühendis için metrik (Prometheus), teknisyen için UP/DOWN + last-seen (Uptime Kuma). + +## 2. Kapsam + +- Kapsam içi: 17 metrik listesi, Prometheus/Node Exporter/Grafana OSS/Uptime Kuma rolleri, scrape/retention ilkeleri, 3 saha komutu sözleşmesi. +- Kapsam dışı: log içerik politikası (15), alarm bildirim hedefleri detayı (kurulum dokümanına havale), merkez donanım siparişi. + +## 3. Kararlar + +```text +KARAR: Birincil metrik yığını Prometheus + Node Exporter + Grafana OSS'tir; 17 metriğin tamamı burada toplanır. +GEREKÇE: 17 metriğin tamamı yalnızca bu yığında ücretsiz ve kotasız toplanır (WireGuard/Docker/MEG sağlığı textfile collector ile). +ALTERNATİF: Netdata Cloud — Free 5 node kotası 700 cihazda ücrete düşer, elendi. Yalnızca Kuma — metrik körlüğü, elendi. +RİSK: 700 node kardinalite/saklama yükü; azaltma: 60 sn scrape, 30–90 gün retention, `BF-` etiket şeması. +MALİYET: Ücretsiz. +LİSANS: Prometheus/Node Exporter Apache-2.0 — https://github.com/prometheus/prometheus + https://prometheus.io/docs/guides/node-exporter/ (2026-09-15); Grafana OSS AGPL-3.0 — https://grafana.com/oss/ (2026-09-15). +``` + +```text +KARAR: Tamamlayıcı olarak Uptime Kuma: saha-teknisyeni dostu UP/DOWN panosu + Push "last seen" + bildirimler. +GEREKÇE: Metrik bilmeyen teknisyene en basit çevrimdışı alarmını verir (20 sn aralık, 90+ bildirim servisi, tek konteyner). +ALTERNATİF: Yalnızca Grafana alarmı — teknisyen için karmaşık, elendi. +RİSK: Kuma metrik vermez; azaltma: Prometheus ile birlikte koşar, rol çakışması yok. +MALİYET: Ücretsiz. +LİSANS: Uptime Kuma MIT — https://github.com/louislam/uptime-kuma (2026-09-15). +``` + +```text +KARAR: Özel metrikler (WireGuard handshake, Docker, MEG) Node Exporter textfile collector `.prom` dosyalarıyla verilir. +GEREKÇE: Standart Prometheus yolu; özel exporter yazma yükü yok. +ALTERNATİF: Özel exporter geliştirme — bakım yükü, elendi. +RİSK: `.prom` yazım hatası scrape'i bozar; azaltma: cron/systemd timer çıktısı atomik yazılır. +MALİYET: Ücretsiz. +LİSANS: Node Exporter Apache-2.0 — https://github.com/prometheus/node_exporter (2026-09-15). +``` + +```text +KARAR: Saha sağlık sözleşmeleri: `bf-status` (hızlı bakış), `bf-diagnostics` (derin tanı), `bf-support-bundle` (secret sızdırmaz paket). +GEREKÇE: 700 dokunulmaz cihazda logsuz metrik kör, metriksiz log sağırdır; bundle secret dışlama kuralıyla güvenli uzaktan tanı sağlar. +ALTERNATİF: Harici log platformu (Loki/ELK) ilk fazda — ek servis yükü, elendi (Faz-sonrası hedef). +RİSK: Bundle'a secret kaçması; azaltma: dışlama listesi + üreticide maskeleme testi. +MALİYET: Ücretsiz. +LİSANS: systemd-journald / textfile (Apache-2.0) — https://github.com/prometheus/node_exporter (2026-09-15). +``` + +## 4. Neden Bu Karar? + +İki kullanıcı, iki hız: mühendis kök nedeni metrikten bulur, teknisyen kesintiyi Kuma'dan görür. Scrape 60 sn + retention 30–90 gün, 700 node'da merkez yükünü taşınabilir tutar. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Yalnızca Uptime Kuma | Çok kolay | Metrik körlüğü | Elendi | +| Netdata Cloud | Zengin agent | Free 5 node kotası | Elendi | +| Netdata Parent self-hosted | Zengin | Merkezi tasarım yükü + kota riski | Elendi | +| Loki/ELK ilk fazda | Güçlü log | Ek servis yükü, sadelik ihlali | Faz-sonrasına ertelendi | + +## 6. Avantajlar + +- Tamamı ücretsiz ve kotasız; 700 cihaz lisans duvarına çarpmaz. +- Tek kimlik (`BF-`) metrikten loga join anahtarıdır. + +## 7. Dezavantajlar + +- Prometheus 700 node işletme bilgisi ister; retention LAB ölçümü olmadan donanım siparişi verilmemeli. +- Kuma + Prometheus çift kurulum merkezde iki servis demektir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Kardinalite patlaması | Orta | Orta | 60 sn scrape, etiket disiplini, federasyon hedefi | +| Retention şişmesi | Orta | Orta | 30–90 gün, PİLOT hacim ölçümü | +| Bundle secret sızıntısı | Düşük | Yüksek | Dışlama listesi + maskeleme testi | + +## 9. Uygulama Planı + +1. Merkez: Prometheus + Grafana OSS + Uptime Kuma kurulur. +2. Saha: Node Exporter + textfile cron/timer + Kuma Push kurulur. +3. Etiket şeması `device_id="BF-"` tüm metriklerde uygulanır. +4. `bf-status` / `bf-diagnostics` / `bf-support-bundle` sahaya dağıtılır. + +```bash +# saha: Node Exporter + textfile dizini (örnek) +systemctl enable --now node_exporter +ls /var/lib/node_exporter/textfile/ # *.prom buraya atomik yazılır + +# WireGuard handshake örneği (üretici script özeti) +# wg show wg0 latest-handshakes > /var/lib/node_exporter/textfile/wireguard.prom.tmp \ +# && mv .../wireguard.prom.tmp .../wireguard.prom +``` + +### 17 metrik listesi + +| # | Metrik | Kaynak | Ne için | +|---|---|---|---| +| 1 | `up` | Prometheus scrape | Cihaz scrape edilebilir mi (ölü/diri) | +| 2 | `node_cpu_seconds_total` | Node Exporter | CPU yükü | +| 3 | `node_memory_MemAvailable_bytes` | Node Exporter | RAM baskısı | +| 4 | `node_filesystem_avail_bytes` | Node Exporter | Disk dolumu | +| 5 | `node_load1/5/15` | Node Exporter | Yük ortalaması | +| 6 | `node_network_receive/transmit_bytes_total` | Node Exporter | Ağ trafiği | +| 7 | `node_systemd_unit_state` | Node Exporter (systemd) | Kritik servis ayakta mı | +| 8 | `node_timex_offset_seconds` | Node Exporter | Saat kayması (TLS/WG için kritik) | +| 9 | `node_boot_time_seconds` | Node Exporter | Beklenmedik reboot tespiti | +| 10 | `wireguard_last_handshake_seconds` | textfile | Tünel canlılığı (stale = kopuk) | +| 11 | `wireguard_transfer_bytes` | textfile | Tünel trafiği | +| 12 | `docker_container_running` | textfile | MEG/yan konteyner ayakta mı | +| 13 | `docker_container_restart_count` | textfile | Crash-loop tespiti | +| 14 | `meg_up` | textfile | MEG sağlığı (kabul madde 6 ile aynı kaynaktan) | +| 15 | `meg_last_success_timestamp` | textfile | MEG iş yapıyor mu (stale = takılı) | +| 16 | `unattended_upgrade_active` | textfile | K-11 kilidi delinmiş mi (1 = ihlal) | +| 17 | `kuma_push_last_seen` | Uptime Kuma Push | Teknisyen "last seen" görünümü | + +Scrape: 60 sn. Retention: 30–90 gün (LAB ölçümüyle kesinleşir). + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Scrape + 17 metrik | Grafana panosu dolu | P1(5) | +| WG down simülasyonu | Metrik 10 stale + Kuma DOWN | P1(5) | +| Konteyner kill | Metrik 12/13 alarm | LAB(2) | +| `unattended-upgrade` açma | Metrik 16 = 1 alarm | LAB(2) | +| Kuma Push kesintisi | last-seen bayatlar | P1(5) | + +## 11. Rollback + +1. Bozuk exporter/push, önceki sürüme Ansible ile geri alınır. +2. Prometheus config hatasında önceki config + `promtool check config` ile dönüş. +3. Son çare: monitoring'siz işletim (erişim kanalları bağımsızdır — 01). + +## 12. Kontrol Listesi + +- [ ] 17 metriğin tamamı Grafana'da görünüyor. +- [ ] Kuma UP/DOWN + Push last-seen çalışıyor. +- [ ] Etiket şeması `BF-` tutarlı. +- [ ] Scrape 60 sn, retention 30–90 gün aralığında. + +### `bf-status` örnek çıktısı (16 satır sözleşmesi) + +```text +BF-12010193 2026-09-15 12:00 +03 OK +uptime: 6d 4h 12m | load: 0.31 0.28 0.22 +cpu: 8% | mem: 1.1/3.8G (29%) | disk /: 9.2/28G (33%) +net: wg0 UP, handshake 12s ago | rx 41M tx 18M +docker: daemon UP | meg:1.2.3 running 6d (restarts 0) +meg: UP, last success 40s ago +systemd: docker OK | wg-quick@wg0 OK | xrdp OFF | node_exporter OK +unattended-upgrades: DISABLED (timer masked) +ufw: active, default DENY in | 22/3389 wg0-only +gui: OFF (multi-user) | rdp sessions: 0 +kuma push: OK 30s ago | prometheus scrape: OK 45s ago +rustdesk: online | meshcentral: online +boot: 2026-09-09 07:48 (power-loss recovery OK) +alerts: 0 critical, 1 warning (disk trend) +next: support bundle: bf-support-bundle +``` + +`bf-diagnostics`: yukarıdakinin detaylı sürümü (servis log kuyrukları + `ss` + `df -i` + textfile içerikleri). `bf-support-bundle`: aynı paketin tarball'ı; WireGuard private key, RustDesk şifresi, SSH private key ve `authorized_keys` dışı secret'lar ASLA pakete girmez (üretici maskeleme testinden geçer). + +## 13. Açık Sorular + +- [ ] Prometheus retention/kardinalite hesabı (700 × 60 sn) — LAB ölçümü (sahibi: 14 yazarı). +- [ ] Bildirim hedefleri (SMS/e-posta/webhook) — merkez kurulumunda netleşecek (sahibi: Faz 3). +- [ ] Federasyon gerekip gerekmediği — 700 node ölçümü (sahibi: 14 yazarı). + +--- + +## Ek: Mermaid — Metrik akışı + +```mermaid +flowchart LR + SAHA["Saha bf-
Node Exporter + textfile"] -->|scrape 60sn| PROM["Prometheus"] + PROM --> GRAF["Grafana OSS"] + SAHA -->|Push last-seen| KUMA["Uptime Kuma"] + KUMA --> TECH["Teknisyen panosu"] + GRAF --> ENG["Mühendis panosu"] + SAHA -->|bf-status| CONSOLE["Saha konsolu"] +``` +
+ + +# 15 — Loglama (Logging) + +> Kısa özet: 7 log kaynağı (journald/Docker/RustDesk/WG/MEG/Blueforce/installer/update-history) için yer, rotasyon ve saklama limitleri. Tanı koyan mühendis ve denetçi içindir. +> +> - Dosya: `docs/15-LOGGING.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-10`, `#K-14` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Disk doldurmayan ama tanı koydurmaya yeten log disiplini: her kaynağın nerede tutulduğu, ne kadar büyüyebileceği ve ne zaman silineceği bellidir. + +## 2. Kapsam + +- Kapsam içi: 7 kaynağın konumu + rotasyon + saklama limiti; secret maskeleme kuralı. +- Kapsam dışı: metrik tanımları (14), merkezi log platformu (Faz-sonrası hedef), log içeriklerinin iş anlamı. + +## 3. Kararlar + +```text +KARAR: journald kalıcı + boyut sınırlı tutulur (`SystemMaxUse`/`MaxFileSec` ile); sınırsız journal yasaktır. +GEREKÇE: Sınırsız journal saha diskini sessizce doldurur; sınırlı journal boot sonrası tanıya yetecek kadar geçmişi korur. +ALTERNATİF: Volatil journal (RAM'de) — reboot sonrası tanı kaybolur, elendi. +RİSK: Limit küçükse kesinti kök nedeni silinir; azaltma: PİLOT hacim ölçümü. +MALİYET: Ücretsiz. +LİSANS: systemd-journald (LGPL bileşenler), ücretsiz. +``` + +```text +KARAR: Docker `json-file` rotasyonu (`max-size`/`max-file`) tüm saha konteynerlerinde zorunludur (K-10). +GEREKÇE: Rotasyonsuz Docker logu disk doldurur = kilitlenen cihaz. +ALTERNATİF: Rotasyonsuz + manuel temizlik — unutulur, elendi. +RİSK: Değerler MEG hacmine göre ayarlanmazsa kayıp/taşma; azaltma: PİLOT ölçümü (12 ile ortak). +MALİYET: Ücretsiz. +LİSANS: Docker CE ücretsiz — https://docs.docker.com/engine/logging/configure/ (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Harici log platformu (Loki/ELK) ilk fazda kurulmaz; Faz-sonrası hedef olarak izlenir. +GEREKÇE: Ek servis yükü sadelik kuralını bozar; 7 kaynak + bundle ilk faz tanısı için yeterlidir. +ALTERNATİF: İlk fazda merkezi log — merkez yükü + işletme maliyeti, ertelendi. +RİSK: Filo-çapı log araması ilk fazda yoktur; azaltma: Ansible ile hedefli log toplama. +MALİYET: Ücretsiz (erteleme). +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +700 cihaz × sınırsız log = 700 dolu disk. Her kaynağa tavan konur; tavanlar PİLOT'ta ölçülen gerçek hacimle sabitlenir. Merkezi platform yokluğunda tanı, cihaz-üzeri komutlar + hedefli toplama ile yapılır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Sınırsız journal | Kayıpsız geçmiş | Disk taşması | Elendi | +| Volatil journal | Disk dostu | Reboot sonrası körlük | Elendi | +| İlk fazda Loki/ELK | Merkezi arama | Servis yükü | Ertelendi | +| Rotasyonsuz Docker log | Sıfır ayar | Kilitlenen cihaz | Elendi | + +## 6. Avantajlar + +- Tanı için yeterli geçmiş, disk için güvenli tavan aynı tabloda. +- 7 kaynak standardı `bf-support-bundle` ile birebir eşleşir. + +## 7. Dezavantajlar + +- Merkezi arama yok; filo-çapı örüntü avı Ansible hedefli toplamayla sınırlı. +- Limitler PİLOT ölçümüne kadar taslak değerlerdir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Limit küçük → kök neden silinir | Orta | Orta | PİLOT hacim ölçümü + kritik olayda bundle | +| MEG log patlaması | Orta | Yüksek | Rotasyon + metrik 13 alarmı | +| Secret loga yazılır | Düşük | Yüksek | Maskeleme kuralı + bundle dışlama listesi | + +## 9. Uygulama Planı + +1. journald limitleri yazılır, kalıcı depolama açılır. +2. `daemon.json` rotasyonu uygulanır (12 ile ortak). +3. Uygulama log dizinleri + logrotate kurulur. +4. Update-history append-only tutulur. + +```bash +# journald (taslak değerler — PİLOT ölçümüyle kesinleşir) +# /etc/systemd/journald.conf.d/blueforce.conf +# [Journal] +# Storage=persistent +# SystemMaxUse=500M +# MaxFileSec=14day +systemctl restart systemd-journald +journalctl --disk-usage + +# Docker rotasyon denetimi (12 ile ortak) +cat /etc/docker/daemon.json +``` + +### 7 kaynak — yer ve limit tablosu + +| # | Kaynak | Yer | Rotasyon/limit (taslak) | +|---|---|---|---| +| 1 | journald (sistem + servisler) | `/var/log/journal/` | `SystemMaxUse=500M`, `MaxFileSec=14day` | +| 2 | Docker konteyner logları | `json-file` (daemon.json) | `max-size=10m`, `max-file=3` (PİLOT ile kesinleşir) | +| 3 | RustDesk istemci | `~/.config/RustDesk/logs/` + journald | 50M tavan, 14 gün (istemci varsayılanını aşmayacak şekilde) | +| 4 | WireGuard | journald (`wg-quick@wg0`) + textfile sayaç | journald limitine tabi; handshake sayacı metrikte (14) | +| 5 | MEG | Konteyner logu (json-file) + varsa `/var/log/meg/` | Docker rotasyonuna tabi; dosya logu varsa logrotate 7×10M | +| 6 | Blueforce servisleri | journald + `/var/log/blueforce/` | logrotate 7×10M, 30 gün | +| 7 | installer + update-history | `/var/log/blueforce/install.log`, `/var/log/blueforce/update-history.log` | Append-only, rotasyonsuz saklanır (denetim izi); 50M üstünde arşivlenir, silinmez | + +Secret kuralı: private key, parola, token loga ve bundle'a ASLA yazılmaz; tanı için gereken kimlik yalnızca `BF-` etiketidir. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `journalctl --disk-usage` | Tavan altında | LAB(2) | +| 7 gün çalışma sonrası disk | Taşma yok | P1(5) | +| Logrotate kuru çalıştırma | Hatasız döner | LAB(2) | +| Bundle secret taraması | Secret yok | LAB(2) | +| update-history append | Silinmemiş, sıralı | LAB(2) | + +## 11. Rollback + +1. Hatalı journald/logrotate config'de önceki dosya geri yüklenir, servis restart edilir. +2. Aşırı agresif rotasyonda limit genişletilir, PİLOT ölçümü yenilenir. + +## 12. Kontrol Listesi + +- [ ] journald kalıcı + tavanlı. +- [ ] Docker rotasyonu aktif (12 ile ortak denetim). +- [ ] 7 kaynağın tamamı bundle'a dahil veya bilerek hariç. +- [ ] Secret taraması temiz. +- [ ] update-history append-only. + +## 13. Açık Sorular + +- [ ] journald/Docker/MEG kesin limitleri — PİLOT hacim ölçümü (sahibi: 15 yazarı, 12 ile ortak). +- [ ] Merkezi log platformu (Loki) Faz-sonrası eşiği — 700 node ölçümü (sahibi: 14 yazarı). + +--- + +## Ek: Mermaid — Log akışı + +```mermaid +flowchart LR + J["journald
sistem + servisler"] --> B["bf-support-bundle
(secret sızdırmaz)"] + D["Docker json-file
rotasyonlu"] --> B + A["/var/log/blueforce/
+ update-history"] --> B + B --> T["Merkez tanı
(Ansible hedefli toplama)"] +``` +
+ + +# 16 — Elektrik Kesintisi ve Otomatik Kurtarma + +> Kısa özet: güç geldikten sonra cihazın dokunulmadan işe dönmesi: BIOS ayarı, boot zinciri, systemd bağımlılıkları, kurtarma doğrulaması. Saha ve LAB ekibi içindir. +> +> - Dosya: `docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md` +> - İlgili kararlar: `24-DECISION-LOG.md#K-03`, `#K-05`, `#K-10` +> - Durum: [ ] Taslak + +--- + +## 1. Amaç + +Elektrik kesintisini sıradan olay haline getirmek: güç gelir, cihaz açılır, VPN kurulur, konteynerler kalkar, MEG işe döner — teknisyen dokunmaz. + +## 2. Kapsam + +- Kapsam içi: BIOS güç ayarı, boot hedefi, systemd sıralaması ve bağımlılıkları, kurtarma testi. +- Kapsam dışı: UPS yatırımı, veri yedekleme (17), update rollback (10). + +## 3. Kararlar + +```text +KARAR: BIOS "Restore on AC Power Loss = Power On" tüm sahada standarttır; cihaz terminale (multi-user) boot eder. +GEREKÇE: Güç gelince açılmayan cihaz kurtarma zincirine hiç giremez; terminal boot en düşük kaynakla en öngörülebilir zemindir (K-03). +ALTERNATİF: Grafik boot / manuel açma — kaynak israfı + saha ziyareti, elendi. +RİSK: Heterojen BIOS menülerinde ayar adı farklı olabilir; azaltma: donanım profil başına LAB kaydı. +MALİYET: Ücretsiz. +LİSANS: Yok (donanım ayarı). +``` + +```text +KARAR: Boot zinciri systemd bağımlılıklarıyla sıralanır: network → `wg-quick@wg0` → `docker.service` → MEG/uygulama birimleri → remote/monitoring ajanları. +GEREKÇE: Sırasız boot race üretir (Docker WG'den önce kalkar, MEG ağ bulamaz); `After=`/`Wants=` zinciri her açılışta aynı sırayı garanti eder. +ALTERNATİF: Bağımlılıksız "hepsi enable" — açılışta rastgele sıra, elendi. +RİSK: Birim adları 26.04.1'e göre değişebilir; azaltma: LAB imajında doğrulanmadan dondurulmaz (K-03 riski). +MALİYET: Ücretsiz. +LİSANS: Yok (systemd yapılandırması). +``` + +```text +KARAR: Kurtarma `unless-stopped` + `wg-quick enable` + daemon enable üzerine kurulur; kesinti "manuel stop" sayılmaz. +GEREKÇE: K-10 gereği kesinti sonrası konteynerler kendiliğinden kalkar; K-05 gereği VPN kendiliğinden gelir. Ek kurtarma servisi gerekmez. +ALTERNATİF: Özel kurtarma scripti/cron — hareketli parça artar, elendi. +RİSK: Yanlışlıkla stop edilen kritik konteyner kalkmaz (tasarlanan davranış); azaltma: `bf-status` ile görünürlük. +MALİYET: Ücretsiz. +LİSANS: Docker CE ücretsiz — https://docs.docker.com/engine/containers/start-containers-automatically/ (doğrulanma: 2026-09-15). +``` + +## 4. Neden Bu Karar? + +Kurtarma, ek yazılımla değil sıralamayla çözülür: her katman bir öncekinin hazır olduğuna bakarak kalkar. Sadelik kuralı burada en literally uygulanır — zincirde özel bileşen yok. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| UPS ile kesintiyi önleme | Kesinti yaşanmaz | 700 cihazda donanım maliyeti | Saha kararına bırakıldı (bu dokümanın dışı) | +| Özel kurtarma servisi | Esnek | Ek bakım yükü | Elendi | +| Grafik boot | Teknisyene tanıdık | Kaynak + hata yüzeyi | Elendi | + +## 6. Avantajlar + +- Kurtarma testi tek komutla tekrarlanır (fişi çek / reboot); sonuç deterministiktir. +- Zincirdeki her halka standart systemd mekanizmasıdır, özel bilgi gerektirmez. + +## 7. Dezavantajlar + +- Uzun kesintide saat kayması olabilir; NTP senkronu zincire dahildir ama ilk WG handshake gecikebilir. +- Bozuk diskte (kirli filesystem) boot fsck'e takılabilir; journal + ext4 varsayılanı bu riski küçültür. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Boot race (sıra bozuk) | Orta | Orta | `After=` zinciri + LAB güç testi | +| BIOS ayarı profilde yok | Düşük | Yüksek | Profil başına LAB kaydı | +| Saat kaymasıyla WG/TLS gecikmesi | Orta | Düşük | NTP bekleme + handshake metriği (14) | + +## 9. Uygulama Planı + +1. BIOS güç ayarı profillere işlenir. +2. Boot hedefi multi-user yapılır; GUI kapalı standarttır. +3. systemd bağımlılıkları yazılır, enable zinciri kurulur. +4. Güç kesintisi simülasyonuyla doğrulanır. + +```bash +# boot hedefi (terminal standart) +systemctl set-default multi-user.target +systemctl get-default + +# enable zinciri +systemctl enable wg-quick@wg0 docker node_exporter +# MEG/uygulama birimleri docker sonrası kalkar: +# /etc/systemd/system/meg-app.service.d/override.conf +# [Unit] +# After=docker.service wg-quick@wg0.service network-online.target +# Wants=network-online.target +# [Install] +# WantedBy=multi-user.target + +# zincir denetimi +systemd-analyze critical-chain docker.service +systemd-analyze critical-chain wg-quick@wg0.service +``` + +```bash +# kurtarma doğrulaması (güç testi sonrası) +uptime -s +systemctl is-active wg-quick@wg0 docker +docker ps --format '{{.Names}} {{.Status}}' +bf-status +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Fiş çekme simülasyonu (güç kesintisi) | Müdahalesiz tam zincir kalkar | LAB(2) | +| Reboot sonrası sıra | network → WG → Docker → MEG | LAB(2) | +| Manuel stop + reboot | Durdurulan kalkmaz (tasarlanan) | LAB(2) | +| 10 ardışık güç döngüsü | 10/10 kurtarma | P1(5) | +| WG handshake süresi | Metrik 10 makul aralıkta tazelenir | P1(5) | + +## 11. Rollback + +1. Bozuk bağımlılıkta override dosyası kaldırılır, daemon-reload + reboot ile önceki sıra geri gelir. +2. Boot hedefine takılmada konsoldan `systemctl set-default` düzeltilir. +3. Son çare: golden image yeniden kurulumu (17-RECOVERY LEVEL 9). + +## 12. Kontrol Listesi + +- [ ] BIOS güç ayarı profilde kayıtlı. +- [ ] Boot hedefi multi-user. +- [ ] `wg-quick@wg0`, `docker`, ajanlar enable. +- [ ] `After=` zinciri dosyada ve LAB'da doğrulandı. +- [ ] Güç testi 10/10 geçti (P1). + +## 13. Açık Sorular + +- [ ] Kesin birim adları + GNOME oturum satırı — LAB 26.04.1 imajı (sahibi: Faz 3, 03/04 yazarları). +- [ ] NTP bekleme süresi eşiği — P1 ölçümü (sahibi: 16 yazarı). + +--- + +## Ek: Mermaid — Boot zinciri ve systemd bağımlılıkları + +```mermaid +flowchart TB + PWR["Power ON
(BIOS Power On)"] --> TGT["multi-user.target
(terminal boot)"] + TGT --> NET["network-online.target"] + NET --> WG["wg-quick@wg0
After network-online"] + WG --> DKR["docker.service
After network"] + DKR --> MEG["MEG / uygulama birimleri
After docker + wg-quick"] + MEG --> AGT["RustDesk + MeshCentral
+ node_exporter + Kuma Push"] + AGT --> BF["bf-status OK"] +``` +
+ + +# 17 — Yedekleme, Kurtarma ve Yeniden Kurulum (Backup, Recovery & Reinstall) + +> Kısa özet: 9 seviyeli kurtarma merdiveni (LEVEL 1…9): servis yeniden başlatmadan merkez VDS'in sıfırdan inşasına kadar her seviyede merkezi prosedür + saha/yerel prosedür. Saha teknisyeni ve merkezi admin için ortak başvuru noktasıdır. + +- Dosya: `docs/17-BACKUP-RECOVERY-AND-REINSTALL.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-04` (kanal bağımsızlığı), `#K-05` (WireGuard), `#K-06` (Ansible/Semaphore), `#K-09` (golden image), `#K-10` (unless-stopped), `#K-11` (onaysız update yasağı), `#K-12` (cihaz kimliği), `#K-14` (support bundle) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihazlı filoda her arızanın boyuna uygun, önceden tanımlı bir kurtarma yolu olması. Küçük arıza küçük müdahaleyle (servis restart), büyük arıza büyük müdahaleyle (yeniden imaj) çözülür; teknisyen "ne yapsam?" diye doğaçlama yapmaz, LEVEL tablosuna bakar. + +## 2. Kapsam + +- Kapsam içi: saha cihazı LEVEL 1…8 kurtarma, merkez VDS yedeği ve yeniden inşası (LEVEL 9), her seviyede merkezi (uzaktan) + yerel (sahada) prosedür, yedeklenecek veri listesi, yedek sıklığı. +- Kapsam dışı: update dalgalarının onay akışı (10-UPDATE), MEG uygulama verisi yedeği detayı (13-MEG-ACCEPTANCE'a havale), elektrik kesintisi sonrası otomatik toparlanma zinciri (16-POWER-LOSS). + +## 3. Kararlar + +```text +KARAR: Kurtarma 9 seviyeli merdivenle işletilir: L1 servis restart → L2 reboot → L3 son değişikliği geri alma → L4 WireGuard/RustDesk/MeshCentral kanalıyla uzaktan onarım → L5 yedekten config geri yükleme → L6 Docker/MEG yeniden kurulum → L7 OS onarımı → L8 USB ile sıfırdan imaj → L9 merkez VDS yeniden inşası. +GEREKÇE: Merdiven, en ucuz müdahaleyi önce dener; gereksiz yeniden imaj (veri kaybı + teknisyen saati) ve gereksiz VDS müdahalesi önlenir. Her seviyenin "merkezi" ve "yerel" kolu vardır, erişim durumuna göre yol seçilir. +ALTERNATİF: Tek "USB ile sıfırla" prosedürü — her arızada saha ziyareti gerektirir, 700 cihazda sürdürülemez; elendi. +RİSK: Yanlış seviye seçimi (L8 gerekirken L1'de oyalanma); azaltma: her seviyede "bir üst seviyeye geç" kriteri yazılıdır. +MALİYET: Ücretsiz (yerleşik araçlar + Ansible). +LİSANS: Yok (işletim prosedürü; araç lisansları 24-DECISION-LOG Ek A). +``` + +```text +KARAR: Merkez VDS'ler dosya-seviyesi yedek + IaC (Ansible playbook + compose dosyaları Git'te) ile kurtarılır; blok-imaj yedeği zorunlu değildir. +GEREKÇE: Servislerin tamamı kodla yeniden kurulabilir durumda tutulur (playbook/compose Git'te); veri (envanter DB, Prometheus verisi, Semaphore DB) dosya yedeğiyle alınır. Blok imaj hem pahalı hem VDS sağlayıcıya bağımlıdır. +ALTERNATİF: Sağlayıcı snapshot'ına bel bağlama — taşınabilir değil, ikinci VDS'e geçemez; yedek yöntem olarak tutulur, birincil yapılmaz. +RİSK: Git'teki IaC ile gerçek VDS drift eder; azaltma: üç ayda bir "boş VDS'e sıfırdan kur" tatbikatı (L9 testi). +MALİYET: Ücretsiz (VDS kira + varsa snapshot ücreti hariç). +LİSANS: Yok. +``` + +```text +KARAR: Saha cihazında kullanıcı verisi yedeği alınmaz; cihaz "değiştirilebilir" (disposable) sayılır — kimlik (`bf-`) + config merkezden yeniden basılır. +GEREKÇE: Tek kimlik standardı (K-12) sayesinde cihazın tüm kişiliği merkezde tanımlıdır; yeniden imaj + kimlik enjeksiyonu cihazı eşdeğerine döndürür. 700 cihazda tek tek veri yedeği işletilemez. +ALTERNATİF: Cihaz-başı tam yedek — bant genişliği ve operasyon yükü nedeniyle elendi. +RİSK: MEG yerel verisi kaybolur; azaltma: MEG verisi gerekiyorsa merkezi yedek hedefi 13-MEG-ACCEPTANCE'ta tanımlanır, teknisyen L8 öncesi uyarılır. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Kurtarma merdiveni üç ilkeye dayanır: (1) erişim varsa sahaya gidilmez — 4 erişim kanalından (K-04) en az biri ayaktaysa onarım uzaktan yapılır; (2) config kodludur — IaC Git'te olduğu sürece merkez de saha da yeniden üretilebilir; (3) cihaz disposable'dır — veri cihazda değil merkezde yaşar. LEVEL 9 (merkez inşası) bu yüzden dosya yedeği + playbook ikilisine dayanır, pahalı imaj altyapısına değil. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Tek-seviye "sıfırla" | Basit anlatım | Her arızada saha ziyareti, veri kaybı | Elendi | +| Sağlayıcı snapshot birincil | Hızlı geri dönüş | Taşınabilir değil, sağlayıcı kilidi | Yedek | +| Cihaz-başı tam yedek (Borg/restic per-node) | Veri korunur | 700 cihazda bant/işletme yükü | Elendi (MEG verisi ayrı ele alınır) | +| Clonezilla ile bit-kopya geri yükleme | Hızlı | Donanım-fragil (K-09) | Yedek yöntem | + +## 6. Avantajlar + +- Her arızada "hangi seviye" sorusunun cevabı tabloda hazır; teknisyen doğaçlama yapmaz. +- Merkezi kol sayesinde çoğu arıza saha ziyaretsiz çözülür. +- L9 tatbikatı merkezin gerçekten yeniden kurulabildiğini kanıtlar (kağıt üstünde kalmaz). + +## 7. Dezavantajlar + +- Merdiven disiplini eğitim ister; acemi teknisyen L1'i atlayıp doğrudan L8'e koşabilir. +- IaC drift'i (Git ≠ gerçek) L9'u yavaşlatır; periyodik tatbikat şarttır. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Yanlış seviye seçimi, arızayı büyütür | Orta | Orta | Her seviyede "yükseltme kriteri" + runbook yönlendirmesi (20/21) | +| Yedek alınmıyor ama alınıyor sanılıyor | Orta | Yüksek | Yedek doğrulama job'ı + Uptime Kuma/Prometheus alarmı | +| L8'de bayi no yanlış girilir, kimlik çakışır | Düşük | Yüksek | 02-NAMING benzersizlik kontrolü + kurucu script onayı | +| Merkez yedeği şifresiz saklanır | Düşük | Yüksek | Yedek şifreleme + erişim yalnızca merkezi admin (21) | + +## 9. Uygulama Planı + +### LEVEL tablosu (her seviyede Merkezi + Yerel kol) + +| LEVEL | Ad | Merkezi prosedür (uzaktan) | Yerel prosedür (sahada) | Üst seviyeye geç kriteri | +|---|---|---|---|---| +| L1 | Servis restart | Ansible/Semaphore ile `systemctl restart` (Docker, xrdp, wg-quick, agent) | `sudo systemctl restart ` | Servis 2 restartta ayağa kalkmıyorsa → L2 | +| L2 | Reboot | `reboot` (Semaphore job veya SSH); boot zinciri izlenir (16) | Güç düğmesi / fiş-çek-tak (tek sefer) | Boot sonrası aynı arıza sürüyorsa → L3 | +| L3 | Son değişikliği geri alma | 10-UPDATE rollback zinciri (önceki config/imaj) | Teknisyen değişikliği yaptıysa manuel geri alma (20) | Geri alma sonrası düzelmiyorsa → L4 | +| L4 | Alternatif kanaldan onarım | WG çöktüyse RustDesk/MeshCentral ile bağlan, WG config'ini düzelt; tersi durumda SSH-over-WG ile agent düzelt | Teknisyen telefonla merkezden komut alır, uygular | Hiçbir kanaldan erişilemiyorsa → L5/L8 hazırlığı | +| L5 | Config geri yükleme | Ansible ile bilinen-iyi config'i bas (sshd, wg0, daemon.json, exporter) | USB'deki `config/` klasöründen kopyalama (kurucu medyasında) | Config doğru ama servis kalkmıyorsa → L6 | +| L6 | Docker/MEG yeniden kurulum | Playbook ile Docker + MEG stack yeniden kurulumu | `blueforce-install.sh --only-docker-meg` (varsa) | İmaj bozulmuş görünüyorsa → L7/L8 | +| L7 | OS onarımı | Kırık paket/çekirdek onarımı (`apt --fix-broken`, eski çekirdekle boot) | Recovery mod + teknisyen adımları (20) | Kök dosya sistemi hasarlıysa → L8 | +| L8 | USB ile sıfırdan imaj | Merkez L8 kararı verir, teknisyeni yönlendirir (21) | USB → Ubuntu → `blueforce-install.sh` → bayi no → READY (20) | Cihaz donanımsal arızalıysa → donanım değişimi; donanım sağlamsa L8 son duraktır | +| L9 | Merkez VDS yeniden inşası | Boş VDS'e playbook/compose + dosya yedeğiyle kurulum; DNS/kimlikler güncellenir | — (teknisyen işi değil) | — | + +### Yedeklenecekler ve sıklık + +1. Merkez dosya yedeği (günlük, otomatik): Ansible inventory + `host_vars`/`group_vars`, WireGuard anahtarları (şifreli), Semaphore DB dump, Prometheus config + kurallar, Grafana dashboard JSON'ları, compose dosyaları, RustDesk keypair, MeshCentral config/DB. +2. IaC Git deposu (her değişiklikte commit+push): playbook'lar, roller, compose'lar, autoinstall/preseed, `blueforce-install.sh`. +3. Yedek doğrulama (aylık): en yeni yedeğin açılabilirliği + kritik dosya hash kontrolü; sonuç monitoring'e işlenir. + +```bash +# örnek: saha cihazı hızlı durum (L1 öncesi tanı) +bf-status +# örnek: tanılama paketi (secret sızdırmaz) +bf-support-bundle --out /tmp/bf-12010193-bundle.tar.gz +# örnek: merkezden tek cihazda servis restart (Ansible) +ansible-playbook playbooks/restart.yml --limit bf-12010193 +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| L1→L2 merdiven tatbikatı (servis kill) | L1 çözer, kayıt düşer | LAB(2) | +| WG config bozma → L4 (RustDesk ile onarım) | Tünel uzaktan onarılır | P1(5) | +| L8 tam yeniden imaj + bayi no | READY < hedef süre | LAB(2) | +| L9 boş VDS'e sıfırdan kurulum tatbikatı | Tüm servisler ayağa kalkar | İzole VDS | +| Yedekten geri yükleme (Semaphore DB) | İş geçmişi ve envanter döner | Merkez test | + +## 11. Rollback + +1. Kurtarma müdahalesi arızayı büyütürse bir üst LEVEL'e geçilir, geri dönülmez (merdiven tek yönlüdür). +2. L3 geri alması yanlışsa 10-UPDATE zincirindeki önceki-önceki sürüme geçilir. +3. L9 inşası başarısızsa eski VDS (kapatılmadıysa) ayakta tutulur; DNS geri alınır. + +## 12. Kontrol Listesi + +- [ ] L1…L9 tablosu 20 (teknisyen) ve 21 (merkez) runbook'larına linkli. +- [ ] Yedek listesi + sıklığı merkezde job'a bağlı, doğrulaması alarmlı. +- [ ] IaC deposu güncel; son L9 tatbikat tarihi yazılı. +- [ ] `bf-support-bundle` secret dışlama kuralı testli (K-14). + +## 13. Açık Sorular + +- [ ] Yedek saklama süresi ve şifreleme anahtarının kasası (sahibi: merkezi admin, 21). +- [ ] MEG yerel veri yedeği gerekli mi — 13-MEG kararı bekleniyor (sahibi: Faz 3). +- [ ] L9 tatbikat periyodu (öneri: 3 ay) onayı (sahibi: 25-ROADMAP). + +--- + +## Ek: Mermaid — kurtarma merdiveni + +```mermaid +flowchart TB + F["Arıza bildirimi
(teknisyen / monitoring)"] --> L1["L1 servis restart"] + L1 -->|düzelmedi| L2["L2 reboot"] + L2 -->|düzelmedi| L3["L3 son değişikliği geri al"] + L3 -->|düzelmedi| L4["L4 alternatif kanaldan onarım
(WG ↔ RustDesk/MeshCentral)"] + L4 -->|erişim yok| L8["L8 USB ile sıfırdan imaj"] + L4 -->|erişim var, config bozuk| L5["L5 config geri yükleme"] + L5 -->|düzelmedi| L6["L6 Docker/MEG yeniden kurulum"] + L6 -->|düzelmedi| L7["L7 OS onarımı"] + L7 -->|düzelmedi| L8 + L8 --> OK["READY"] + L9["L9 merkez VDS inşası
(ayrı tetikleyici)"] --> OKC["Merkez servisler ayakta"] +``` +
+ + +# 18 — Pilot ve 700 Cihaz Yayılımı (Pilot & Rollout) + +> Kısa özet: Yayılımın ilk adımı `bf-hardware-inventory.sh` donanım taramasıdır; imaj dondurulmadan önce saha heterojenliği ölçülür. Sonra LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD dalgaları, her dalgada giriş/çıkış kriteriyle ilerler. + +- Dosya: `docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-01` (donanım profili), `#K-09` (önce envanter, sonra imaj), `#K-11` (dalga zinciri), `#K-12` (cihaz kimliği) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +700 cihazı "bir gecede" değil, ölçülebilir dalgalarla üretime almak. Her dalga bir öncekinin kanıtı üzerine kurulur; bir dalga batarsa filo değil, o dalga etkilenir. + +## 2. Kapsam + +- Kapsam içi: donanım envanter taraması, dalga tanımı ve sayıları, her dalganın giriş/çıkış kriterleri, dalga başına izlenecek metrikler, durdurma (halt) kuralları. +- Kapsam dışı: update'lerin dalga zinciri (10-UPDATE — aynı dalga isimlerini kullanır ama tetikleyici farklıdır), tek cihaz kurulum adımları (20-FIELD-RUNBOOK). + +## 3. Kararlar + +```text +KARAR: İmaj dondurulmadan ÖNCE `bf-hardware-inventory.sh` ile saha donanım taraması yapılır; CPU/RAM/disk/NIC/GPU profilleri çıkarılmadan golden image dondurulmaz. +GEREKÇE: K-09'un en büyük riski heterojen donanımda sürücü eksiğidir; tarama olmadan üretilen tek imaj sahada rastgele kırılır. Profil listesi LAB test matrisini belirler. +ALTERNATİF: Taramasız "standart PC varsayımı" — ilk dalgada sürpriz donanımla çakışır; elendi. +RİSK: Tarama örneklemi yanlı olur (kolay ulaşılan bayiler); azaltma: bölge başına kota + en az %10 örneklem hedefi. +MALİYET: Ücretsiz (script + Ansible). +LİSANS: Yok. +``` + +```text +KARAR: Dalgalar: LAB(2) → P1/PILOT-1(5) → P2/PILOT-2(20) → W1/WAVE-1(50) → W2/WAVE-2(100) → PROD (kalan ~523). Hiçbir dalga, önceki dalganın çıkış kriterini sağlamadan başlamaz. +GEREKÇE: K-11'in onay zinciri; üstel büyüme (2→5→20→50→100→523) riski kademeli taşır, her adımda gözetim süresi vardır. +ALTERNATİF: Tek seferde toplu kurulum — geri dönüşü olmayan filo-çapı hata riski; elendi. +RİSK: Takvim baskısıyla kriter atlanır; azaltma: dalga geçişi merkezi admin onayı gerektirir (21), Semaphore'da kapı adımı. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Önce ölç (envanter), sonra dondur (imaj), sonra büyüt (dalgalar). Bu sıra, 700 cihazda "bilinmeyen donanım" ve "bilinmeyen ölçek" risklerini birbirinden ayırır: donanım riski LAB'da, ölçek riski dalgalarda eritilir. Dalga sayıları (2/5/20/50/100) hem K-11'deki update zinciriyle aynıdır hem de teknisyen kapasitesine uyar (erken dalgalar az cihazla çok öğrenme). + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Taramasız imaj | Hızlı başlangıç | Sahada sürücü sürprizi | Elendi | +| Tek dalga (big-bang) | Hızlı | Filo-çapı geri dönüşsüz hata | Elendi | +| Daha fazla küçük dalga (10+) | Çok güvenli | Takvim ve teknisyen yorgunluğu | Elendi (6 dalga yeterli) | + +## 6. Avantajlar + +- Her dalga, sonraki dalganın eğitim verisidir (süre, hata tipi, destek yükü ölçülür). +- Durdurma kuralları sayesinde kötü imaj 5 cihazdan öteye geçemez. +- Envanter taraması aynı zamanda 02-INVENTORY'nin veri kaynağıdır (iki iş bir taş). + +## 7. Dezavantajlar + +- Dalgalar takvimi uzatır; yönetim "neden hepsi birden değil" diye sorar (bu doküman cevaptır). +- Erken dalga bayileri pilot yükü taşır; iletişim ve destek planı gerekir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Envanter örneklemi yanlı | Orta | Yüksek | Bölge kotası + %10 hedefi | +| Kriter atlanarak dalga atlama | Orta | Yüksek | Merkezi admin onayı zorunlu (21) | +| Pilot bayilerde destek yığılması | Orta | Orta | Dalga başına teknisyen kapasite planı (25) | +| Donanım profili imajı kırar (yeni parti PC) | Orta | Orta | Her yeni donanım partisi LAB'a girer | + +## 9. Uygulama Planı + +### FAZ 0 — Donanım taraması (her şeyden önce) + +1. `bf-hardware-inventory.sh` mevcut/örnek cihazlarda çalıştırılır (CPU, RAM, disk, NIC, GPU, BIOS modu, mevcut OS). +2. Çıktılar merkezde toplanır, donanım profilleri çıkarılır (örn. Profil-A: i3/4GB/120GB, Profil-B: i5/8GB/256GB…). +3. Profil listesi 04-GOLDEN-IMAGE'a girer; LAB test matrisi = her profilden ≥1 cihaz. +4. Profil başına sürücü notu düşülür (Wi-Fi çipi, ekran kartı, yazıcı). + +```bash +# örnek: envanter taraması (sahada veya Ansible ile) +sudo ./bf-hardware-inventory.sh --out /tmp/inv-$(hostname).json +# örnek: merkezde toplama +ansible all -m fetch -a "src=/tmp/inv-{{ inventory_hostname }}.json dest=inventory/hw/ flat=yes" +``` + +### Dalga planı + +| Dalga | Cihaz | Amaç | Gözetim süresi | Çıkış kriteri (hepsi şart) | +|---|---|---|---|---| +| LAB | 2 | İmaj + 3 akış doğrulama (erişim/boot/update) | 1 hafta | 3 akış yeşil, support-bundle temiz, rollback denendi | +| P1 (PILOT-1) | 5 | Gerçek saha + WG kopma sayacı + teknisyen akışı | 2 hafta | Kopma sayacı toplandı, teknisyen USB→READY'yi desteksiz yaptı, kritik hata yok | +| P2 (PILOT-2) | 20 | Ölçek provası + digest pinleme değerlendirmesi + destek yükü ölçümü | 2–3 hafta | Cihaz-başı kurulum süresi hedefte, destek bileti/cihaz eşiği altında, monitoring 17 metrik dolu | +| W1 (WAVE-1) | 50 | Bölge-ölçeği yayılım, envanter disiplini | 3 hafta | Envanter %100 eşleşiyor, halt kuralı tetiklenmedi | +| W2 (WAVE-2) | 100 | Üretim-öncesi son prova, paralel ekip çalışması | 3–4 hafta | İki ekip paralel kurulum yapabiliyor, geri alma zinciri canlı denendi | +| PROD | ~523 | Kalan filo | planlı | W2 çıkış kriterleri + yönetim onayı | + +### Halt (durdurma) kuralları + +1. Dalga içinde aynı kök-nedenle ≥2 kritik arıza → dalga durur, 17-RECOVERY L3 işletilir. +2. Monitoring'de dalga cihazlarının >%10'u 24 saatte çevrimdışı → yayılım durur, ağ/VPN incelenir. +3. Teknisyen kurulum süresi hedefin 2× üstünde → eğitim/runbook (20) revize edilmeden devam edilmez. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Envanter scripti 3 farklı donanımda | Profil JSON'u eksiksiz | LAB + örnek saha | +| LAB 3-akış testi | Erişim + boot + update yeşil | LAB(2) | +| P1 kopma sayacı 7/24 | Keepalive ayarı için veri | P1(5) | +| Dalga geçiş kapısı (Semaphore) | Onaysız geçiş engellenir | Merkez test | + +## 11. Rollback + +1. Dalga içi arızada etkilenen cihazlar 17-RECOVERY merdiveniyle (L3/L8) önceki-iyi duruma alınır. +2. İmaj-köklü arızada dalga durdurulur, imaj revize edilir, dalga baştan başlar (ilerideki dalgalar etkilenmez). +3. PROD'da imaj sorunu çıkarsa 10-UPDATE rollback zinciri işletilir. + +## 12. Kontrol Listesi + +- [ ] Envanter taraması tamam, profil listesi 04'e işlendi. +- [ ] Her dalganın giriş/çıkış kriteri + gözetim süresi yazılı ve onaylı. +- [ ] Halt kuralları 21 (merkez) runbook'una işlendi. +- [ ] Dalga cihaz listeleri envanterde (`BF-`) ayrıldı. + +## 13. Açık Sorular + +- [ ] Örneklem hedefi (%10) ve bölge kotaları onayı (sahibi: merkezi admin). +- [ ] Pilot bayi seçimi + iletişim planı (sahibi: 25-ROADMAP). +- [ ] Cihaz-başı kurulum süresi hedefi (öneri P2'de ölçülüp sabitlenecek). + +--- + +## Ek: Mermaid — dalga akışı + +```mermaid +flowchart LR + INV["Donanım taraması
bf-hardware-inventory.sh"] --> PROF["Profil listesi"] + PROF --> IMG["Golden image
(04)"] + IMG --> LAB["LAB 2"] + LAB -->|çıkış kriteri| P1["P1 5"] + P1 -->|çıkış kriteri| P2["P2 20"] + P2 -->|çıkış kriteri| W1["W1 50"] + W1 -->|çıkış kriteri| W2["W2 100"] + W2 -->|çıkış + onay| PROD["PROD ~523"] + LAB -->|halt| FIX["Düzelt +
tekrar"] + P1 -->|halt| FIX + P2 -->|halt| FIX + W1 -->|halt| FIX + W2 -->|halt| FIX + FIX --> LAB +``` +
+ + +# 19 — Sorun Giderme (Troubleshooting) + +> Kısa özet: Sahada ve merkezde en sık görülen arızaların belirti → neden → komut → çözüm tablosu. 17-RECOVERY "hangi seviye" sorusunu, bu doküman "o seviyede hangi komut" sorusunu cevaplar. + +- Dosya: `docs/19-TROUBLESHOOTING.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-03` (xRDP/GUI), `#K-04` (erişim kanalları), `#K-05` (WireGuard), `#K-10` (Docker), `#K-14` (`bf-status`, support-bundle) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Tekrar eden arızalarda teşhisi hızlandırmak: belirtiyi bulan teknisyen/admin, tabloya bakıp ilk komutu 30 saniyede çalıştırır. Her girdide "ne zaman üst seviyeye çık" notu vardır. + +## 2. Kapsam + +- Kapsam içi: boot, ağ/VPN, SSH, RDP/GUI, RustDesk, MeshCentral, Docker/MEG, disk, monitoring belirti tabloları. +- Kapsam dışı: seviye kararları (17), adım-adım kurulum (20), merkez onay akışı (21). + +## 3. Kararlar + +```text +KARAR: Her arıza girdisi aynı formatta yazılır: Belirti → Olası nedenler (olasılık sırasıyla) → Tanı komutları → Çözüm → Yükseltme kriteri. +GEREKÇE: Standart format, acemi teknisyenin bile doğru sırayla (tanıdan önce çözüm yok) ilerlemesini sağlar; her çözüm denenebilir komutla verilir. +ALTERNATİF: Serbest-anlatım sorun listesi — komutlar dağınık kalır, otomasyona çevrilemez; elendi. +RİSK: Tablo eskirse yanlış yönlendirir; azaltma: her PİLOT dalgası sonrası tablo gözden geçirilir. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Sorun giderme, kurtarma merdiveninin (17) "içini" doldurur: 17 hangi LEVEL, 19 o LEVEL'de hangi komut. Olasılık sırası önemlidir — en yaygın neden önce yazılır, teknisyen zamanı boşa harcanmaz. Yükseltme kriteri her girdinin sigortasıdır (inatla aynı komutu tekrarlamak yasak). + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Harici bilgi tabanı (Wiki araması) | Aranabilir | Çevrimdışı sahada erişilemez, Git akışı yok | Elendi (22'de Docusaurus içinde bu dosya zaten aranabilir) | +| Her arıza için ayrı sayfa | Detaylı | 700 cihazda 100 sayfa yönetilemez | Elendi (gerekirse Ek'e taşınır) | + +## 6. Avantajlar + +- Tek dosya, çevrimdışı da okunur (USB'de veya basılı). +- Komutlar kopyala-yapıştırılabilir; açıklamalar Türkçe, komut adları İngilizce orijinal. + +## 7. Dezavantajlar + +- 26.04.1'e özgü çıktı farkları LAB'da güncellenmelidir (kesin servis adları, log yolları). +- MEG'e özgü arızalar 13-MEG-ACCEPTANCE'tan beslenmelidir; bu dosya iskelettir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Eski komut (servis adı değişti) | Orta | Orta | LAB'da her komut çalıştırılıp doğrulanır | +| Secret'lı komut geçmişe düşer | Düşük | Yüksek | Support-bundle kuralı (K-14); komutlarda secret yazılmaz | + +## 9. Uygulama Planı + +Önce genel tanı (her arızada ilk 60 saniye): + +```bash +bf-status +systemctl --failed +ip -brief addr; ip route show default +wg show wg0 2>&1 | head -20 +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.RestartPolicy}}' +df -h / /var/lib/docker | cat +``` + +### T-1 — Cihaz açılmıyor / ekranda hiçbir şey yok + +| # | Neden (olasılık sırası) | Tanı | Çözüm | +|---|---|---|---| +| 1 | Elektrik yok / BIOS güç ayarı | Priz, adaptör, güç LED'i | BIOS "Restore on AC Power Loss = Power On" (16) | +| 2 | Monitör/kablo | Başka kablo/ekran dene | Değiştir | +| 3 | Disk ölmüş | BIOS diski görmüyor | Donanım değişimi → L8 yeni diskte | + +Yükseltme: donanım sağlamsa ama OS açılmıyorsa T-2'ye geç. + +### T-2 — Boot terminale düşmüyor / servisler kalkmıyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Failed servis var | `systemctl --failed`, `journalctl -xb -p err` | İlgili servis girdisine git (T-3…T-6) | +| 2 | Disk dolu | `df -h /` | Docker log temizliği + rotasyon kontrolü (T-7) | +| 3 | Bozuk çekirdek/paket | `dpkg --audit`, `apt --fix-broken install` | 17-L7; olmazsa L8 | + +### T-3 — WireGuard tüneli yok (`wg show` boş / handshake eski) + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Servis inaktif | `systemctl status wg-quick@wg0` | `sudo systemctl enable --now wg-quick@wg0` | +| 2 | Anahtar/config hatası | `journalctl -u wg-quick@wg0 --no-pager`, `wg show wg0 latest-handshakes` | Config'i merkezden yeniden bas (17-L5); peer adı `bf-` mi kontrol et | +| 3 | Saha interneti yok / CGNAT | `ping -c3 8.8.8.8`, modem arayüzü | Yerel ağ düzeltilir; kopma sayacı not edilir (P1 görevi) | +| 4 | Merkez hub down | Başka cihazdan hub'a bak | 17-L9 kolu işletilir | + +Yükseltme: WG kalkmıyorsa RustDesk/MeshCentral ile devam (17-L4); onlar de yoksa saha ziyareti. + +### T-4 — SSH bağlanmıyor (WG ayaktayken) + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | sshd down / anahtar eşleşmiyor | `systemctl status ssh`, `ssh -vvv blueforce@bf-` | Anahtarı merkezden yeniden dağıt (K-13); password denemesi YOK | +| 2 | Yanlış IP/hostname | `ip -brief addr`, envanter karşılaştır | 02-NAMING'e göre düzelt | +| 3 | Güvenlik duvarı | `sudo ufw status`, `ss -tlnp` | 11-HARDENING standardına döndür | + +### T-5 — RDP açılmıyor / siyah ekran / hemen kopuyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | GUI katmanı kapalı | `systemctl status display-manager xrdp xrdp-sesman` | `bf-gui-on`, tekrar dene | +| 2 | Oturum çakışması (yerel + RDP aynı kullanıcı) | `who`, `loginctl list-sessions` | Ayrı kullanıcı/oturum; eski oturumu kapat | +| 3 | xRDP config bozulmuş | `journalctl -u xrdp --no-pager` | Bilinen-iyi config'i bas (17-L5) | + +### T-6 — RustDesk bağlanmıyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | İstemci servis/uygulama kapalı | RustDesk durumu, ID görünüyor mu | Servisi başlat; ID+röle adresi merkez değerlerle aynı mı | +| 2 | Key uyuşmazlığı | İstemci key vs hbbs key | Merkezi key'i yeniden bas (07-REMOTE-ACCESS) | +| 3 | hbbs/hbbr down | Merkezden konteyner/servis durumu | Merkez tarafını ayağa kaldır; health monitörü (Kuma) ne diyor | + +### T-7 — Docker/MEG kalkmıyor veya sürekli restart + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Restart loop (config/entrypoint) | `docker ps`, `docker logs --tail 100 ` | Config düzelt + `docker compose up -d` (17-L6) | +| 2 | Disk dolu (rotasyonsuz log) | `df -h /var/lib/docker`, `du -sh /var/lib/docker/containers/*` | `daemon.json` rotasyonu uygula; taşan logu buda | +| 3 | `latest` çekilmiş / sürüm kaymış | `docker images`, compose tag kontrolü | Sabit tag'e döndür (K-10); Watchtower varsa kaldır | +| 4 | Port çakışması / UFW bypass | `ss -tlnp`, `docker port ` | `127.0.0.1`-bind standardına döndür (K-10) | + +### T-8 — Disk doluyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Docker json-file logları | `du -sh /var/lib/docker/containers/*` | Rotasyon + budama, `docker system prune` (dikkatli) | +| 2 | journald şişmiş | `journalctl --disk-usage` | `SystemMaxUse` limiti + `journalctl --vacuum-size=` | +| 3 | Prometheus retention (merkez) | `df -h`, retention ayarı | 30–90 gün bandına çek (K-07) | + +### T-9 — Monitoring'de cihaz gri/kayıp + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Node Exporter down | `systemctl status node_exporter`, hedef scrape logu | Servisi başlat; textfile dizin izni | +| 2 | Uptime Push gelmiyor | Kuma "last seen" | Cihaz interneti + push job'ı (T-3'e dön) | +| 3 | Etiket yanlış (`BF-`) | Prometheus target etiketleri | Envanterle eşitle (02) | + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Her T girdisindeki ilk tanı komutu LAB'da çalışır | Komut + örnek çıktı bu dosyada | LAB(2) | +| WG-kill → T-3 → L4 onarım tatbikatı | < hedef sürede tünel döner | P1(5) | +| Disk-doldur simülasyonu → T-8 | Alarm + temizlik çalışır | LAB(2) | + +## 11. Rollback + +Yanlış çözüm uygulandıysa 17-L3 (son değişikliği geri alma) işletilir; tanı komutlarının kendisi read-only'dir, rollback gerektirmez. + +## 12. Kontrol Listesi + +- [ ] Her girdide Yükseltme kriteri var. +- [ ] Komutların tamamı LAB 26.04.1 imajında denendi. +- [ ] Hiçbir girdide secret/şifre yazılmıyor. +- [ ] MEG'e özgü satırlar 13 ile çapraz linkli. + +## 13. Açık Sorular + +- [ ] Kesin `bf-gui-*` birim adları + GNOME oturum satırı — LAB çıktısı (sahibi: Faz 3). +- [ ] MEG log hacmi ölçümü → T-8 eşikleri (sahibi: 12-DOCKER yazarı). +- [ ] Turkcell CGNAT tanı notları — P1 verisi (sahibi: 08-WIREGUARD yazarı). + +--- + +## Ek: Triyaj Akışı (hangi T girdisi?) + +```mermaid +flowchart TB + SORUN["Arıza bildirimi
bf-status + systemctl --failed"] --> ACILIR{"Cihaz açılıyor mu?"} + ACILIR -->|Hayır| T1["T-1: güç / BIOS / disk"] + ACILIR -->|Evet| BOOTOK{"Servisler kalkıyor mu?"} + BOOTOK -->|Hayır| T2["T-2: failed servis / disk dolu"] + BOOTOK -->|Evet| WG{"wg show handshake güncel mi?"} + WG -->|Hayır| T3["T-3: WireGuard + L4 yedek kanal"] + WG -->|Evet| SSH{"SSH oluyor mu?"} + SSH -->|Hayır| T4["T-4: sshd / anahtar / UFW"] + SSH -->|Evet| RDP{"RDP açılıyor mu?"} + RDP -->|Hayır| T5["T-5: bf-gui-on / xRDP"] + RDP -->|Evet| DERIN["T-6+: Docker / MEG / disk (T-7/T-8)"] +``` +
+ + +# 20 — Saha Teknisyeni Runbook + +> Kısa özet: Sahada cihaz başına giden teknisyenin el kitabı. Tek akış: USB'yi tak → kur → bayi numarasını gir → READY'yi gör → çık. Komut ezberi istenmez; her adımda "ne göreceksin, ne yazacaksın" vardır. + +- Dosya: `docs/20-FIELD-TECHNICIAN-RUNBOOK.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-09` (USB → script → bayi no → READY), `#K-12` (bayi no → `BF-`), `#K-14` (`bf-status`, support-bundle) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Teknisyenin sahada tek başına, telefonsuz bile cihazı READY hale getirmesi. Bu dosya "neden" anlatmaz, "ne yap" söyler. Neden merak eden 01'e bakar. + +## 2. Kapsam + +- Kapsam içi: USB ile sıfır kurulum, bayi no girişi, READY kontrolü, arızalı cihazda ilk 3 hareket, merkeze ne zaman telefon açılacağı. +- Kapsam dışı: merkez onayları (21), arıza teşhis tablosu detayı (19 — sadece yönlendirme), update dalgaları (10). + +## 3. Kararlar + +```text +KARAR: Teknisyen akışı 4 adımdır: USB → kur → bayi no → READY. Teknisyen hostname, IP, anahtar, config dosyası ile uğraşmaz; hepsini `blueforce-install.sh` yapar. +GEREKÇE: K-09'un hedef akışı; insan hatası en çok elle config'de olur. Teknisyenin tek manuel girdisi 8 haneli bayi numarasıdır (K-12). +ALTERNATİF: Teknisyene tam Linux eğitimi + manuel kurulum — 700 cihazda standardizasyon bozulur; elendi. +RİSK: Script USB'si eski sürüm kalır; azaltma: USB etiketinde sürüm + merkezden "şu sürümden eski USB'yi kullanma" duyurusu. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Teknisyenin işi "bilgisayarcılık" değil "bayi cihazını çalışır teslim etmek"tir. Karar, teknisyenin dikkatini tek kritik noktaya toplar: bayi numarasını doğru girmek. Geri kalan her şey script + merkez otomasyonudur. Bu yüzden bu dosya kısa cümlelerle, emir kipiyle yazılır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Detaylı Linux kılavuzu | Bilgili teknisyen | Uzun, hata açık, herkes okumaz | Elendi | +| Merkezden uzaktan kurulum (teknisyensiz) | Saha maliyeti yok | İlk kurulumda cihazda internet/VPN yok | Elendi (ilk kurulum sahada) | + +## 6. Avantajlar + +- Yeni teknisyen 1 saatlik eğitimle sahaya çıkar. +- Her cihaz aynı adımla kurulur; "ustalık farkı" kapanır. + +## 7. Dezavantajlar + +- Script'in yapmadığını teknisyen de yapamaz; olağandışı durumda merkeze bağımlıdır (bilerek böyle). +- USB medyası fiziksel lojistik ister (sürüm takibi). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Bayi no yanlış girilir | Orta | Yüksek | Script ekranda `BF-`'yu gösterir, teknisyen bayiye onaylatır | +| Eski USB ile kurulum | Orta | Orta | USB sürüm etiketi + kurucu açılışta sürümü söyler | +| READY görülmeden sahadan çıkılır | Düşük | Yüksek | Kontrol listesi imzalanmadan iş kapanmaz | + +## 9. Uygulama Planı + +### A. Sıfır kurulum (yeni cihaz) — 4 adım + +**ADIM 1 — USB'yi tak, bilgisayarı aç.** + +1. Kurulum USB'sini tak. +2. Bilgisayarı aç, USB'den başlat (açılışta genelde F12/F8/F2 — ekranda yazar). +3. Ubuntu kurulum ekranı gelene kadar bekle. + +> Göreceğin: Ubuntu kurulum ekranı. Bunu görmüyorsan: USB tam takılı mı, BIOS boot sırası USB mi? Olmazsa merkeze telefon aç. + +**ADIM 2 — Ubuntu'yu kur.** + +1. Ekrandaki kurulumu varsayılanlarla tamamla (dil: Türkçe, disk: tamamını kullan). +2. Kurulum bitince USB'yi çıkarma, bilgisayarı yeniden başlat. + +**ADIM 3 — Kurucu scripti çalıştır, bayi numarasını gir.** + +1. Terminali aç, şunu yaz: + +```bash +sudo ./blueforce-install.sh +``` + +2. Script senden **8 haneli bayi numarasını** ister. Faturadaki/sistemdeki numarayı yaz, Enter'a bas. Örnek: `12010193`. +3. Script ekranda şunu gösterecek: + +```text +Device ID: BF-12010193 +Hostname: bf-12010193 +``` + +4. Numarayı bayiye sesli onaylat: "Cihaz numaranız BF-12010193, doğru mu?" Yanlışsa scriptten çık, tekrar çalıştır. + +**ADIM 4 — READY'yi gör, çık.** + +1. Script bitene kadar bekle (kapatma, fişi çekme). +2. Sonunda şunu çalıştır: + +```bash +bf-status +``` + +3. Ekranda **READY** (veya tüm satırlar yeşil/OK) görmelisin. +4. Kontrol listesini doldur (§12), sahadan çık. + +### B. Arızalı cihaza gittin — ilk 3 hareket + +1. `bf-status` çalıştır. Kırmızı satır hangisi? Not al. +2. Bilgisayarı bir kez yeniden başlat. Düzelirse READY'yi gör, çık. +3. Düzelmezse support paketini al, merkeze gönder: + +```bash +bf-support-bundle --out /tmp/bf--bundle.tar.gz +``` + +Paketi merkeze ilet (telefonla nasıl göndereceğin söylenir), merkez sana söylemeden başka komut çalıştırma. + +### C. Merkeze telefon açma kuralları + +Hemen ara: READY alamıyorsan, aynı arıza 2 cihazda üst üste çıktıysa, donanım kırık/yanık kokusu varsa, bayi numarasından emin değilsen. +Arama: cihaz başında ol, `BF-`'yu ve `bf-status` çıktısını hazır tut. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Sıfır teknisyen 4 adımı desteksiz yapar | READY < hedef süre | LAB(2) | +| Yanlış bayi no girilir | Script uyarır, kuruluma devam etmez | LAB(2) | +| Elektrik kesintisi simülasyonu sonrası `bf-status` | READY döner | LAB(2) | + +## 11. Rollback + +Teknisyen rollback yapmaz. Kurulum yarıda kesildiyse: baştan başla (ADIM 1). İkinci denemede de olmazsa merkeze telefon aç — merkez 17-RECOVERY'den seviyeyi seçer. + +## 12. Kontrol Listesi + +- [ ] USB sürümü güncel (etiket + merkez duyurusu eşleşiyor). +- [ ] `BF-` bayiye onaylatıldı. +- [ ] `bf-status` → READY görüldü. +- [ ] Cihaz fişe + internete bağlı bırakıldı. +- [ ] İş formu dolduruldu (bayi no, tarih, teknisyen adı). + +## 13. Açık Sorular + +- [ ] Kurulum USB'sinin dağıtım/sürüm takip yöntemi (sahibi: merkezi admin, 21). +- [ ] İş formu kağıt mı mobil mi (sahibi: 25-ROADMAP). +- [ ] Hedef kurulum süresi (öneri: P2'de ölçülecek). + +--- + +## Ek: Mermaid — teknisyen akışı + +```mermaid +flowchart TB + USB["USB tak → aç"] --> KUR["Ubuntu kur"] + KUR --> SCR["sudo ./blueforce-install.sh"] + SCR --> NO["Bayi no gir
(8 hane)"] + NO --> ONAY["BF-no'yu bayiye onaylat"] + ONAY --> BEKLE["Script bitsin"] + BEKLE --> READY["bf-status → READY?"] + READY -->|evet| CIK["Formu doldur → çık"] + READY -->|hayır| TEL["Merkeze telefon aç"] +``` +
+ + +# 21 — Merkezi Yönetici Runbook + +> Kısa özet: Merkezdeki yöneticinin el kitabı: hangi işlemi kim onaylar, update'i kim başlatır, dalga kapıları nasıl açılır, teknisyen ne zaman sahaya sürülür. Yetki ve sıra bu dosyadadır. + +- Dosya: `docs/21-CENTRAL-ADMIN-RUNBOOK.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-06` (Ansible/Semaphore), `#K-11` (onay zinciri + dalgalar), `#K-13` (anahtar dağıtımı) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Merkezde "kim, neyi, hangi sırayla yapar" sorusunu kapatmak. Update'i kim başlatır, dalgayı kim onaylar, acil durumda kim karar verir — hepsi burada yazar, kriz anında aranmaz. + +## 2. Kapsam + +- Kapsam içi: rol ve onay yetkileri, update başlatma akışı, dalga kapı prosedürü, anahtar/erişim yönetimi, acil durum yetkisi, günlük/haftalık rutinler. +- Kapsam dışı: tek cihaz kurulum adımları (20), komut-seviyesi teşhis (19), kurtarma seviye tablosu (17 — buradan çağrılır). + +## 3. Kararlar + +```text +KARAR: Update'i yalnızca merkezi yönetici başlatır; teknisyen, bayi veya otomatik zamanlayıcı update tetikleyemez. Her dalga geçişi açık onay (Semaphore kapı adımı + kayıt) gerektirir. +GEREKÇE: K-11 "onaysız update yasak" ilkesinin operasyonel karşılığıdır; tek başlatma noktası = denetlenebilirlik. Otomatik tetikleyici olmaması, zamanlanmış sürprizleri engeller. +ALTERNATİF: Takvimli otomatik dalga başlatma — onay kaydı zayıflar, arızada "kim başlattı" belirsizleşir; elendi. +RİSK: Yönetici izindeyken update kilitlenir; azaltma: en az 2 onay yetkilisi tanımlanır (asıl + vekil). +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +```text +KARAR: Acil güvenlik update'inde dalga sırası atlanamaz ama gözetim süreleri kısaltılabilir; kısaltma kararı ve gerekçesi kayda geçirilir. +GEREKÇE: Sıra atlamak (LAB'ı geçmeden PROD'a çıkmak) filo-çapı riskidir; süre kısaltmak ise bilinçli hızlandırmadır. Kayıt, sonradan denetimi sağlar. +ALTERNATİF: Acilde direkt PROD — geri dönüşü olmayan hata riski; elendi. +RİSK: "Acil" etiketi suistimal edilir; azaltma: acil tanımı (CVSS eşiği / aktif sömürü) 10-UPDATE'ta yazılıdır. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +700 cihazda en tehlikeli cümle "ben başlattım sandım"dır. Başlatma yetkisini tek role toplamak, hem hatayı hem suçu önler: hata olursa kayıttan bulunur, suçlama olursa kayıt konuşur. Vekil kuralı, tek kişiye bağımlılığı kaldırır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Herkes update başlatabilir | Hızlı | Onaysız yayılım, denetimsizlik | Elendi | +| Tam otomatik pipeline | Eforsuz | Politika ihlali (K-11) | Elendi | +| Tek onay yetkilisi | Net | İzin/hastalıkta kilit | Elendi (asıl+vekil) | + +## 6. Avantajlar + +- Her update'in "kim başlattı, kim onayladı, ne zaman" kaydı vardır. +- Acil durumda bile sıra korunur; hız, sıranın içinde ayarlanır. + +## 7. Dezavantajlar + +- Onay adımı update'i yavaşlatır (bilerek; hız 25-ROADMAP'ta acil yoluyla dengelenir). +- Semaphore kapı adımı atlanırsa politika kağıtta kalır — teknik zorunluluk şarttır. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Kapı adımı bypass edilir (CLI'dan direkt run) | Orta | Yüksek | Semaphore dışı prod run'ı yasaktır; denetim logu izlenir | +| İki yetkili de ulaşılamaz | Düşük | Orta | Acil iletişim zinciri + önceden tanımlı duraklatma (devam etme, durdur) | +| Anahtar sızıntısı | Düşük | Yüksek | Per-admin anahtar iptali + rotasyon (K-13) | + +## 9. Uygulama Planı + +### Roller + +| Rol | Kim | Yetkisi | +|---|---|---| +| Onay yetkilisi (asıl + vekil) | İsimle tanımlanır | Dalga başlatma/onay, acil kısaltma kararı, L8/L9 kararı | +| Operatör | Merkez ekibi | Onaylanmış job'u çalıştırma, monitoring izleme, teknisyen yönlendirme | +| Teknisyen | Saha ekibi | 20'deki akış; update/dalga başlatamaz | +| İzleyici (read-only) | Yönetim | Panoları görür, işlem yapamaz | + +### Standart update akışı (yönetici gözüyle) + +1. Update talebi gelir (güvenlik bülteni / MEG sürümü / OS yaması) → etki + geri alma planı raporlanır. +2. Onay yetkilisi LAB'ı başlatır: Semaphore'da job + kayıt. +3. LAB çıkış kriteri tuttuysa P1 → P2 → W1 → W2 → PROD, her kapıda açık onay. +4. Herhangi bir dalgada halt kuralı (18) tetiklenirse: yayılım DURUR, 17-L3 işletilir, kök neden bulunmadan kapı açılmaz. +5. PROD bitiminde kapanış kaydı (başarı/arıza sayısı, süre). + +```bash +# örnek: onaylı dalga run (operatör, onay sonrası) +ansible-playbook playbooks/update.yml --limit wave_p1 +# örnek: kapı kontrolü — onaysız PROD engeli (ilke; teknik karşılığı Semaphore'da) +# PROD limiti yalnızca "prod-onay" etiketli job ile koşar +``` + +### Anahtar ve erişim yönetimi + +1. Yeni admin: anahtarı üretilir, `authorized_keys`'e Ansible ile dağıtılır (K-13); eski/ayrılan adminin anahtarı aynı yolla kaldırılır. +2. Paylaşılan tek private key YOKTUR; böyle bir dosya görülürse olay kaydı açılır. +3. Semaphore yalnızca WireGuard arkasından erişilir; gizliler yerleşik şifreli Key Store'dadır (K-06). + +### Günlük / haftalık rutinler + +- Günlük (operatör, 10 dk): Uptime Kuma UP/DOWN, Prometheus `up` == 0 listesi, gece reboot olanlar, yedek job durumu. +- Haftalık (yönetici, 30 dk): dalga takvimi, açık halt'lar, anahtar/envanter uyumsuzlukları, L9 tatbikat sayacı. + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Onaysız PROD run denemesi | Engellenir + log düşer | Merkez test | +| Semaphore kapalıyken Ansible CLI run | Onaylı iş CLI'dan koşar (K-06) | LAB(2) | +| Vekil ile dalga onayı | Kayıtta vekil adı görünür | Merkez test | + +## 11. Rollback + +1. Hatalı dalga: 17-L3 + 10-UPDATE rollback zinciri; kapı bir sonraki dalgaya kapatılır. +2. Hatalı onay: aynı yetkili (veya vekil) durdurma kararı verir; "devam" kararı verilemez, yalnızca "dur + geri al". + +## 12. Kontrol Listesi + +- [ ] Asıl + vekil isimle tanımlı ve iletişim bilgileri güncel. +- [ ] Semaphore'da PROD kapı adımı teknik olarak zorunlu. +- [ ] Her dalga onayı kayıtlı (kim, ne zaman, hangi sürüm). +- [ ] Ayrılan personelin anahtarı kaldırıldı. + +## 13. Açık Sorular + +- [ ] Acil tanımı eşikleri (CVSS/aktif sömürü) — 10-UPDATE ile ortak onay (sahibi: yönetici). +- [ ] Kapanış raporu formatı ve saklama süresi (sahibi: 25-ROADMAP). +- [ ] Read-only yönetim panosu kapsamı (sahibi: 14-MONITORING yazarı). + +--- + +## Ek: Update Onay Akışı (yönetici gözüyle) + +```mermaid +flowchart TB + TALEP["Update talebi
(güvenlik / MEG / OS)"] --> KAYIT["Değişiklik kaydı
kapsam + geri alma planı"] + KAYIT --> ONAY{"Onay yetkilisi
(asıl veya vekil)"} + ONAY -->|Ret| KAPANIR["Kapanış kaydı (ret)"] + ONAY -->|Onay| LAB["LAB(2) → P1(5) → P2(20)"] + LAB --> KAPI{"Sağlık kapısı temiz mi?"} + KAPI -->|Halt| DUR["Yayılım DURUR → 17-L3 rollback"] + KAPI -->|Temiz| WAVE["W1(50) → W2(100) → PROD(~523)"] + WAVE --> RAPOR["Kapanış kaydı
(başarı/arıza/süre)"] +``` +
+ + +# 22 — Dokümantasyon Platformu (Documentation Platform) + +> Kısa özet: Teknik dokümanların yazıldığı, sürümlendiği ve yayınlandığı platform kararı: Docusaurus önerilir. `docs/*.md` tek kaynaktır; site derlemesi CI'da yapılır; AI ajanları Markdown'u doğrudan günceller. + +- Dosya: `docs/22-DOCUMENTATION-PLATFORM.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-08` (Docusaurus, MkDocs yedekte) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +27 dokümanın (00…25 + şablon) hem ham Markdown olarak Git'te hem de aranabilir site olarak yayınlanmasını sağlamak; "doküman nerede, güncel mi, kim değiştirdi" sorularını kapatmak. + +## 2. Kapsam + +- Kapsam içi: platform seçimi, `docs/` ↔ site ilişkisi, yazım-derleme-yayın akışı, sürümleme ve dil politikası. +- Kapsam dışı: doküman içerikleri (00…21, 23…25), CI komut detayı (25-ROADMAP'a havale). + +## 3. Kararlar + +```text +KARAR: Dokümantasyon platformu Docusaurus'tur; kaynak `docs-site/` iskeleti + `docs/*.md` tek-kaynak; site derlemesi CI'da yapılır. +GEREKÇE: "AI-güncelleme" kriterini yalnızca Git-yerleşik Markdown çözer: ajan `.md`'yi doğrudan yazar, değişiklik PR'dan geçer, geçmiş Git'tedir. Docusaurus yerleşik sürümleme + yerleşik i18n + MDX (Mermaid dahil) verir; derleme maliyeti alternatifleriyle eşdeğerdir. +ALTERNATİF: MkDocs + Material — eşdeğer sadelikte güçlü ikinci; Docusaurus hattı sorun çıkarırsa geçiş maliyeti düşük (kaynak yine düz Markdown) → yedekte tutulur. Wiki.js (DB+servis yükü) ve TriliumNext (tek kullanıcı) elendi. +RİSK: Node derleme hattı bozulabilir; azaltma: kilitli bağımlılıklar + CI derleme testi; derleme bozulsa bile `docs/*.md` ham haliyle okunur. +MALİYET: Ücretsiz (self-hosted/statik yayın). +LİSANS: Docusaurus MIT — https://github.com/facebook/docusaurus + https://docusaurus.io/docs/markdown-features/diagrams (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Yayınlanan sitenin kaynağı ile Git'teki `docs/*.md` arasında elle kopyalama yapılmaz; senkronizasyon tek yönlüdür (Git → site). +GEREKÇE: Çift-kaynak, güncel-olmayan site üretir; tek kaynak + CI derlemesi "sitedeki her satırın Git karşılığı var" garantisi verir. +ALTERNATİF: Wiki'de düzenle, Git'e aktar — yön tersine döner, PR disiplini ölür; elendi. +RİSK: CI bozulursa site eskir; azaltma: CI rozeti + derleme testi her PR'da koşar. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Üç filtre (24 §4) burada da uygulandı: (1) ücretsiz — Docusaurus MIT, barındırma statik; (2) sadelik — DB'li wiki (Wiki.js) bir servis daha demektir, reddedildi; tek-kullanıcı bilgi tabanı (TriliumNext) ekip akışını taşımaz; (3) AI-uyumluluk — ajan düz Markdown'u yazar ve review'a sunar; bu, DB-içi editörde mümkün değildir. MkDocs'a karşı Docusaurus'u ayıran, sonradan eklenti aramadan gelen sürümleme + i18n'dir (700 cihazlık filonun dokümanı yıllarca yaşar, sürüm gerekir). + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| MkDocs + Material | Sade, hızlı, güçlü ikinci | Sürümleme/i18n eklentiyle | Yedek | +| Wiki.js | Güçlü editör, yetkilendirme | DB+servis yükü, Git akışı dolaylı | Elendi | +| TriliumNext | Hafif, kişisel | Ekip review akışı yok | Elendi | +| Yalnızca GitHub/GitLab render | Sıfır kurulum | Sürümleme/arama/zengin tema yok | Elendi (ham okuma zaten var) | + +## 6. Avantajlar + +- Derleme bozulsa bile dokümanlar kaybolmaz (`docs/*.md` her zaman okunur). +- Mermaid diyagramları (01'deki 3 akış) sitede ve PR önizlemede render olur. +- Sürümleme sayesinde "PİLOT'taki prosedür neydi" sorusu cevaplanabilir. + +## 7. Dezavantajlar + +- Node derleme hattı bakım ister (kilit dosyası + periyodik güncelleme). +- Editör eşiği wiki'ye göre yüksektir (PR açmak gerekir) — teknik ekip için kabul edilebilir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Derleme hattı bozulur, site eskir | Orta | Düşük (kaynak okunur) | CI derleme testi + kilitli bağımlılık | +| Doküman-Git senkronu bozulur (elle site editi) | Düşük | Orta | Siteye yazma yetkisi yalnızca CI'da | +| İçerik eskir (prosedür değişti, doküman kalmış) | Orta | Yüksek | Her PR'da "ilgili doküman güncellendi mi" kontrolü | + +## 9. Uygulama Planı + +1. `docs-site/` iskeleti kurulur (Docusaurus init), `docs/*.md` siteye kaynak olarak bağlanır. +2. CI job'ı: her PR'da derleme testi (`build`); `main`'e merge'de statik yayın. +3. Yazım kuralı: yeni/degisen prosedür → önce `.md`, sonra PR; site editi yasak. +4. Sürümleme: ilk PROD dalgasında `v1` kesilir; sonraki imaj değişiklikleri yeni sürümle etiketlenir. + +```bash +# örnek: yerelde önizleme (komut adları İngilizce orijinal, açıklama Türkçe) +cd docs-site +npm install +npm run build +npm run serve +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| CI derleme testi (bozuk link/Mermaid) | Hatalı PR kırmızıya düşer | CI | +| `docs/*.md` ham okunabilirlik | Git arayüzünde tablolar/diyagramlar anlaşılır | Git web | +| Sürüm kesme (`v1`) | Eski prosedüre sürüm menüsünden erişilir | Yayınlanan site | + +## 11. Rollback + +1. Bozuk site yayını: önceki CI çıktısına (statik) dönülür; kaynak `.md` etkilenmez. +2. Platform değişikliği (Docusaurus → MkDocs): kaynak `.md` taşındığı için içerik kaybı olmaz; yalnızca `docs-site/` yeniden yazılır. + +## 12. Kontrol Listesi + +- [ ] `docs-site/` derleniyor, 3 Mermaid (01) render oluyor. +- [ ] CI'da derleme testi her PR'da koşuyor. +- [ ] Siteye elle yazma kapalı (yalnızca CI yayınlıyor). +- [ ] Şablon (`_TEMPLATE.md`) ile yeni doküman açma akışı denendi. + +## 13. Açık Sorular + +- [ ] Statik sitenin barındırılacağı yer (merkez VDS / iç ağ) (sahibi: 25-ROADMAP). +- [ ] İlk sürüm (`v1`) kesme tarihi — PROD dalgasına bağlı (sahibi: merkezi admin, 21). +- [ ] Türkçe dışına çeviri ihtiyacı (i18n açılacak mı) (sahibi: yönetim). + +--- + +## Ek: Mermaid — doküman akışı + +```mermaid +flowchart LR + W["Yazar/ajan
docs/*.md düzenler"] --> PR["PR + review"] + PR --> CI["CI: Docusaurus build testi"] + CI -->|yeşil| MERGE["main'e merge"] + MERGE --> PUB["Statik site yayını"] + PUB --> READ["Okuyucu
(teknisyen/admin)"] +``` +
+ + +# 23 — Güvenlik ve Lisans Denetimi (Security & License Audit) + +> Kısa özet: Mimariye giren her ücretli-aday aracın lisans hükmü + ücretsiz karşılığı tek tabloda; AnyDesk'in neden mimariden çıkarıldığı resmi URL'leriyle. Yeni araç öneren önce bu dosyaya bakar. + +- Dosya: `docs/23-SECURITY-AND-LICENSE-AUDIT.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-04` (AnyDesk HAYIR + RustDesk/MeshCentral), `#K-06` (Semaphore Community), `#K-07` (Netdata elendi), `#K-10` (Watchtower) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +"Şu aracı da kursak mı?" sorusunun lisans cevabını tek yerde vermek ve 700 cihazda lisanssız ticari kullanımı baştan engellemek. Her satırda resmi kaynak URL'si vardır; kulaktan-dolma lisans bilgisi yasaktır. + +## 2. Kapsam + +- Kapsam içi: ücretli-aday araçların hükmü, seçilen ücretsiz karşılıklar, AnyDesk HAYIR hükmü, periyodik denetim rutini. +- Kapsam dışı: teknik kurulum (ilgili dokümanlar), firewall/SSH sertleştirme detayı (11-HARDENING). + +## 3. Kararlar + +```text +KARAR: AnyDesk ücretsiz sürümü Blueforce filosunda KULLANILAMAZ; AnyDesk mimariden çıkarılmıştır. +GEREKÇE: AnyDesk ücretsiz sürümü yalnızca kişisel kullanımı kapsar; 700 kurumsal saha cihazı ticari kullanımdır ve lisanssız kullanım sözleşme ihlali olur. Kurumsal kullanım en az ücretli plan gerektirir; ayrıca kapalı kaynak + self-hosted yalnızca en üst planda sunulur. +ALTERNATİF: Müşteri sahasında zaten lisanslı AnyDesk varsa opsiyonel installer modülü olarak kalabilir (merkezi mimarinin parçası değil) — bunun dışında alternatif yoktur; birincil/ikincil erişim RustDesk OSS + MeshCentral'dır (K-04). +RİSK: Sahada "alışkanlıkla" AnyDesk Free kurulur; azaltma: golden image'da AnyDesk yoktur + periyodik denetimde kurulu yazılım taraması yapılır. +MALİYET: Kullanılmadığı için lisans bedeli yoktur (kullanılsaydı: ücretli plan zorunlu). +LİSANS: AnyDesk lisans şartı — https://support.anydesk.com/docs/anydesk-licenses.md (güncelleme 2025-10-30; doğrulanma: 2026-09-15) + plan listesi https://anydesk.com/en/pricing (doğrulanma: 2026-09-15). +``` + +```text +KARAR: Zorunlu mimarideki tüm bileşenler FREE katmandadır; ücretli-aday her araç için ücretsiz karşılık aşağıdaki tabloda sabitlenmiştir. Hiçbir playbook, compose veya imaj ücretli özelliğe bağlanamaz. +GEREKÇE: 700 cihazda "küçük" lisans bedeli bile çarpanla büyür; ücretsiz-tabana bağlılık hem maliyeti hem denetim yükünü sıfırlar. Karşılık tablosu, gelecekteki "Pro'ya geçelim" baskısına hazır cevap verir. +ALTERNATİF: İhtiyaç oldukça ücretli plana geçme — maliyet takibi + sözleşme yükü + kilitlenme; elendi. +RİSK: Ücretsiz sürümün kapsamı değişir (upstream fiyat politikası); azaltma: yıllık lisans gözden geçirme (§9 rutin). +MALİYET: Ücretsiz (VDS kira bedeli hariç — altyapı maliyeti, lisans değil). +LİSANS: Bileşen lisansları 24-DECISION-LOG Ek A'da (doğrulama: 2026-09-15). +``` + +## 4. Neden Bu Karar? + +Lisans denetimi "hukuk işi" değil, mimari iştir: ücretli bir bileşen mimariye girerse 700 cihazın tamamı o sözleşmeye kilitlenir. Bu dosya, her ücretli adayı daha teklif aşamasında ücretsiz karşılığıyla eşleştirir; AnyDesk maddesi bunun en sert örneğidir (kişisel-kullanım tuzağı). Güvenlik boyutu da aynı tablodadır: kapalı kaynak + dış kimlik (Tailscale/ZeroTier tipi) hem lisans hem veri-egemenliği riski taşır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| İhtiyaç oldukça lisans satın alma | Esnek | 700 çarpanı, sözleşme takibi, kilitlenme | Elendi | +| Karma (bazı cihazlar ücretli) | Hedefli harcama | İki sınıf filo, denetim karmaşası | Elendi | +| Yıllık lisanssız "görmezden gelme" | Kısa vadeli kolay | Sözleşme ihlali + denetimde ceza | Yasak | + +## 6. Avantajlar + +- Denetimde (müşteri/üst yönetim) her satırın resmi URL'si hazırdır. +- Yeni araç teklifi 5 dakikada elenebilir veya kabul edilebilir (tabloya uyar mı?). + +## 7. Dezavantajlar + +- Upstream fiyat/lisans değişiklikleri takip ister (yıllık rutin). +- Bazı ücretsiz sürümler özelliksizdir (örn. Semaphore Community'de OIDC/2FA/Vault yok) — bu eksikler operasyonel disiplinle kapatılır (K-06). + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Sahaya lisanssız ticari yazılım kurulur | Orta | Yüksek | İmajda yok + periyodik kurulu-yazılım taraması | +| Upstream free kapsamı daralır | Düşük | Orta | Yıllık gözden geçirme + yedek alternatifler (§9 tablo) | +| AGPL yükümlülüğü ihlali (değişiklik dağıtırsak) | Düşük | Orta | Değişikliksiz self-host politikası (RustDesk/Grafana notu) | + +## 9. Uygulama Planı + +### Ücretli-aday → ücretsiz-karşılık tablosu (FREE sürümler baz) + +| Ücretli aday | Neden girmedi | Ücretsiz karşılık (mimaride) | Lisans / kaynak | +|---|---|---|---| +| AnyDesk (ücretli plan) | Free yalnızca kişisel kullanım; 700 cihaz ticari → ihlal | RustDesk OSS hbbs/hbbr + MeshCentral | AGPL-3.0 / Apache-2.0 — https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ ; https://github.com/Ylianst/MeshCentral ; hüküm https://support.anydesk.com/docs/anydesk-licenses.md | +| RustDesk Pro | Gerekmez — OSS sınırsız istemci + self-host verir | RustDesk OSS 1.4.9 / sunucu 1.1.16 | AGPL-3.0 — https://rustdesk.com/pricing/ (doğrulanma: 2026-09-15) | +| Semaphore Pro/Enterprise | 700 cihaz > Pro 500 node sınırı; Community yeterli | Semaphore UI Community | MIT, Community $0 — https://semaphoreui.com/pricing + https://github.com/semaphoreui/semaphore | +| Netdata Cloud (ücretli) | Free 5 node kotası 700'de ücrete düşer | Prometheus + Node Exporter + Grafana OSS + Uptime Kuma | Apache-2.0 / AGPL-3.0 / MIT (24 Ek A 23–25) | +| Tailscale / ZeroTier (ücretli kademeler) | Freemium + merkezi kimlik | WireGuard (self-hosted hub-spoke) | OSS — https://www.wireguard.com/quickstart/ | +| Ubuntu Pro / ESM | Mimari ESM'e bel bağlamaz | Ubuntu LTS ücretsiz + onaylı update dalgaları | https://releases.ubuntu.com/ | +| Docker Business / Scout | Mimaride yok | Docker CE + sabit tag disiplini | https://docs.docker.com/ | +| Wiki.js Cloud / Confluence | DB yükü + ücret | Docusaurus (MIT) | https://github.com/facebook/docusaurus | +| AWX (operatör maliyeti) / Salt / Rudder Enterprise | K8s/agent/relay yükü | Ansible CLI + Semaphore Community | GPL-3.0 / MIT — https://github.com/ansible/ansible | + +```bash +# örnek: sahada lisanssız/izinsiz yazılım taraması (merkezden) +ansible all -m shell -a "dpkg -l | grep -i -E 'anydesk|teamviewer' || echo TEMIZ" +# örnek: Watchtower kalıntısı taraması (politika ihlali, K-10) +ansible all -m shell -a "docker ps --format '{{.Names}} {{.Image}}' | grep -i watchtower || echo TEMIZ" +``` + +### Periyodik rutin + +1. Üç ayda bir: yukarıdaki taramalar + `latest` etiketi denetimi + `unattended-upgrades` kapalılık kontrolü (K-11). +2. Yılda bir: tablo satırlarındaki resmi URL'ler yeniden çekilir, fiyat/lisans değişikliği varsa bu dosya PR ile güncellenir. +3. Yeni araç teklifi: tabloya satır eklenmeden kuruluma girmez (21'deki onay yetkilisi denetler). + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| İmajda AnyDesk/TeamViewer paketi yok | `dpkg -l` temiz | LAB(2) imaj denetimi | +| İmajda Watchtower yok, `latest` yok | `docker images` sabit tag | LAB(2) | +| `unattended-upgrades` kapalı | Timer'lar maskeli, değerler `0` | LAB(2) | + +## 11. Rollback + +Lisans değişikliği (upstream free kapsamı daralırsa): yedek alternatife (tablodaki "Yedek" satırları, örn. MkDocs) geçilir; değişiklik 24-DECISION-LOG PR'ı ile yapılır, bu dosya aynı PR'da güncellenir. + +## 12. Kontrol Listesi + +- [ ] AnyDesk HAYIR hükmü resmi URL'li (§3 birinci blok). +- [ ] Her ücretli-adayın ücretsiz karşılığı tabloda + URL'li. +- [ ] Üç aylık tarama job'ları Semaphore'da tanımlı. +- [ ] Yıllık URL tazeleme takvime işlendi. + +## 13. Açık Sorular + +- [ ] Üç aylık tarama sonuçlarının raporlanacağı merci (sahibi: merkezi admin, 21). +- [ ] AGPL değişiklik-dağıtım politikasının hukukça gözden geçirilmesi gerekli mi (sahibi: yönetim). +- [ ] Müşteri-lisanslı AnyDesk modülünün şartları (sahibi: 07-REMOTE-ACCESS yazarı). + +--- + +## Ek: Lisans Karar Akışı (yeni araç teklifi) + +```mermaid +flowchart TB + TEKLIF["Yeni araç teklifi"] --> UCRET{"Ücretli katman / lisans gerektirir mi?"} + UCRET -->|Hayır (OSS, kotasız)| KABUL["Mimariye alınabilir
(karşılık tablosuna işlenir)"] + UCRET -->|Evet| KARSILIK{"Ücretsiz karşılığı tabloda var mı?"} + KARSILIK -->|Var| RED["Teklif elenir
karşılık kullanılır"] + KARSILIK -->|Yok| PR["24-DECISION-LOG PR'ı açılır
Ek A'ya URL + sürüm + tarih"] +``` +
+ + +# 24 — Karar Günlüğü (Decision Log) + +> Kısa özet: Blueforce 700 cihaz Linux filosunun tüm nihai mimari kararları tek tabloda; her karar KARAR/GEREKÇE/ALTERNATİF/RİSK/MALİYET/LİSANS bloğu + resmi kaynak URL ile. Diğer 25 dokümanın başvurduğu kilit dosyadır. +> +> - Dosya: `docs/24-DECISION-LOG.md` +> - İlgili kararlar: bu dosyanın kendisi kilit kaynaktır; `01-ARCHITECTURE.md` bu kararları diyagramlaştırır. +> - Durum: [x] Onaylı (Faz 2 kilidi; değişiklik bu dosyadan PR ile yapılır) +> - Kural: tüm kararlarda FREE sürümler baz alınır; Pro/Enterprise zorunlu mimariye girmez. +> - Doğrulama günü: 2026-09-15 (tüm URL'ler bu tarihte HTTP 200 ile çekildi; ayrıntı `research/` dosyalarında). + +--- + +## 1. Amaç + +27 izlenebilirlik sorusunun (bkz. §13 ve Ek B) her birinin hangi karara dayandığını tek yerden göstermek; kararsız veya varsayıma dayalı seçim bırakmamak. + +## 2. Kapsam + +- Kapsam içi: OS/GUI/boot, 4 erişim kanalı, WireGuard, fleet, monitoring, docs, golden image, Docker, cihaz kimliği, update dalgaları — aşağıdaki K-01…K-14 kararları. +- Kapsam dışı: script kodları, VDS kurulum komutları, gerçek cihaz test sonuçları (Faz 3+ işi; ilgili dokümanlara havale edilir). + +## 3. Kararlar + +### K-01 — Baz işletim sistemi ve sürüm + +```text +KARAR: Saha standardı Ubuntu Server 26.04.1+ LTS'tir (GUI'siz minimal kurulum; GNOME sonradan eklenir). +GEREKÇE: 26.04.1 LTS resmi indirme sayfasında yayında (kod adı Resolute Raccoon); Server ISO'su varsayılan olarak GUI kurmaz, minimal zemin + ihtiyaca göre GUI standart yoldur. Noktasürüm (.1+) baz alınır çünkü ilk .0 dalgasındaki kurulumcu hataları genelde .1'de toplanır (LAB imaj testiyle kesinleşir). +ALTERNATİF: Ubuntu 24.04 LTS — 26.04 doğrulanamasaydı yedekti; doğrulandığı için yedekte bekler. Ubuntu Desktop 26.04 (6 GB RAM şartı) saha bütçesini zorladığı için elendi. +RİSK: 26.04.x noktasürümlerinde donanım sürücü farkı; azaltma: golden image LAB(2)'de her saha donanım profilinde test edilir. +MALİYET: Ücretsiz. +LİSANS: Ubuntu LTS ücretsiz (açık kaynak bileşenler; ESM kapsamına bel bağlanmaz) — https://releases.ubuntu.com/ (26.04.1, doğrulanma: 2026-09-15) + https://ubuntu.com/download/desktop (sistem gereksinimi, doğrulanma: 2026-09-15). +``` + +### K-02 — Masaüstü ortamı (GNOME) + +```text +KARAR: Saha masaüstü ortamı GNOME'dur (Ubuntu Desktop standardı); XFCE elendi. +GEREKÇE: Kullanıcı kararı + Ubuntu Desktop standardı: tanıdık modern masaüstü, teknisyen akışıyla birebir uyum. +ALTERNATİF: XFCE — hafif ve düşük kaynak tüketimine rağmen kullanıcı kararıyla elendi; yedekte tutulmaz. +RİSK: xRDP+GNOME kombinasyonu ek ayar isteyebilir (uyarı: GNOME oturumu RDP'de ek yapılandırma gerektirebilir); azaltma: 13-MEG-LINUX-ACCEPTANCE testi GNOME üzerinde koşar, `bf-gui-*` LAB'da 26.04.1 imajında doğrulanmadan dondurulmaz. +MALİYET: Ücretsiz. +LİSANS: GNOME (GPL/LGPL bileşenler), ücretsiz — https://www.gnome.org/ (doğrulanma: 2026-09-15). +``` + +### K-03 — Boot davranışı ve GUI aç/kapa + +```text +KARAR: Cihazlar terminale (multi-user) boot eder; grafik katman `bf-gui-on` / `bf-gui-off` ile systemd üzerinden açılıp kapatılır. +GEREKÇE: xRDP mimarisi oturum yöneticisi (sesman) + systemd servis birimleri (`xrdp`, `xrdp-sesman`) üzerine kuruludur; GUI, display-manager + xRDP servisleri üzerinden yönetilebilir bir katman olarak modellenir. Varsayılan kapalı GUI = düşük RAM, düşük saldırı yüzeyi, öngörülebilir boot. +ALTERNATİF: Her zaman grafik boot — kaynak israfı ve 700 cihazda gereksiz hata yüzeyi nedeniyle elendi. +RİSK: Kesin birim adları ve GNOME oturum başlatma satırı 26.04.1'e göre değişebilir; azaltma: LAB'da 26.04.1 imajında doğrulanmadan `bf-gui-*` dondurulmaz. +MALİYET: Ücretsiz. +LİSANS: xRDP Apache-2.0, ücretsiz — https://github.com/neutrinolabs/xrdp (v0.10.6.1, 2026-07-07; doğrulanma: 2026-09-15). +``` + +### K-04 — Uzaktan erişim kanalları (4 kanal) + +```text +KARAR: Dört erişim kanalı: (1) SSH-over-WireGuard (yönetim/otomasyon), (2) xRDP + GNOME (grafik bakım), (3) RustDesk OSS self-hosted hbbs/hbbr (birincil grafik uzak erişim, WireGuard-bağımsız), (4) MeshCentral self-hosted (ikincil yönetim: terminal/dosya/envanter, WireGuard-bağımsız). AnyDesk mimariden ÇIKARILDI. +GEREKÇE: RustDesk OSS sınırsız istemci, kendi ID+röle altyapısı, E2E şifreli oturum, dosya transferi ve katılımsız erişimi ücretsiz verir; istemcide VDS genel adresi kullanıldığında WireGuard çökse bile kanal ayakta kalır. MeshCentral Apache-2.0 ile ikinci bağımsız düzlemi sağlar. AnyDesk ücretsiz sürümü yalnızca kişisel kullanımı kapsar; 700 kurumsal cihaz ticari kullanımdır ve lisanssız kullanım ihlal olur (en az Advanced planı gerekirdi). +ALTERNATİF: AnyDesk (lisans maliyeti + kapalı kaynak + self-hosted yalnızca en üst planda → elendi; yalnızca müşteri sahasında zaten lisanslıysa opsiyonel installer modülü). Tailscale/ZeroTier (freemium, merkezi kimlik → elendi). +RİSK: İki VDS-bağımlı kanalın tek ortak noktası VDS + genel internet; azaltma: hbbs/hbbr `--net=host` + health monitörü (Uptime Kuma) + yedek VDS planı 17-RECOVERY'de. +MALİYET: Ücretsiz (kendi VDS'i üzerinde; VDS kira bedeli hariç — altyapı maliyeti, lisans değil). +LİSANS: RustDesk istemci+sunucu AGPL-3.0 (1.4.9 / 1.1.16) — https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ + https://rustdesk.com/docs/en/self-host/client-deployment/ (doğrulanma: 2026-09-15); MeshCentral Apache-2.0 (1.2.5) — https://github.com/Ylianst/MeshCentral (doğrulanma: 2026-09-15); AnyDesk hükmü — https://support.anydesk.com/docs/anydesk-licenses.md (doğrulanma: 2026-09-15) + https://anydesk.com/en/pricing. +``` + +### K-05 — WireGuard modeli + +```text +KARAR: Hub-spoke WireGuard: merkez sunucu hub, her saha cihazı spoke (peer adı = `bf-<8hane>`); istemcide `PersistentKeepalive = 25` + `wg-quick@wg0` systemd enable; RDP/SSH dış dünyaya kapalı, yalnızca WireGuard arkasında. +GEREKÇE: Resmi QuickStart NAT arkasındaki eş için 25 sn keepalive'i "geniş güvenlik-duvarı yelpazesiyle çalışan makul aralık" olarak verir; `wg-quick@.service` Ubuntu paketinden gelir, enable = açılışta otomatik VPN. Elektrik kesintisi sonrası cihaz açılınca VPN kendiliğinden gelir. +ALTERNATİF: Keepalive kapalı / agresif 10 sn — kapalı NAT eşlemesini düşürür, agresif değer saha verisi olmadan yazılmaz; PİLOT-1 kopma sayacına göre ayarlanır. +RİSK: Turkcell CGNAT davranışı resmi kaynaktan doğrulanamaz (operatör bilgisi); azaltma: PİLOT-1'de 7/24 kopma sayacı, aralık saha verisiyle ayarlanır. +MALİYET: Ücretsiz. +LİSANS: WireGuard OSS (Ubuntu paketi `wireguard-tools 1.0.20250521-1ubuntu1`) — https://www.wireguard.com/quickstart/ + https://packages.ubuntu.com/resolute/wireguard-tools (doğrulanma: 2026-09-15). +``` + +### K-06 — Filo yönetimi + +```text +KARAR: Birincil filo otomasyonu Ansible CLI (SSH, agent'sız); operatör arayüzü Semaphore UI Community (self-hosted, MIT). Hiçbir playbook Pro/Enterprise özelliğine bağlanmaz. +GEREKÇE: En düşük hareketli parça (SSH + YAML + tek Go binary); 700 cihazda ek agent/master/K8s maliyeti yok. Community ücretsizliği resmi fiyat sayfasıyla kanıtlı ("$0, free forever"); Pro en fazla 500 managed node desteklediği için 700 cihazda ücretli yol zaten kapalı. +ALTERNATİF: AWX (K8s yükü + release'ler refactoring nedeniyle duraklatıldı → elendi); Salt (minion agent + master PKI → elendi); Rudder Community (relay/Enterprise modülleri + öğrenme eğrisi → elendi); MeshCentral (erişim aracı, filo otomasyonu değil → bu rolde elendi). +RİSK: Semaphore tek nokta arızası → Ansible CLI bağımsız çalışır (UI çökse filo durmaz). Community'de OIDC/2FA/Vault yok → Semaphore yalnızca WireGuard arkasında, erişim VPN + SSH anahtarı ile; gizliler yerleşik şifreli Key Store'da. +MALİYET: Ücretsiz (kendi VDS'i üzerinde). +LİSANS: Ansible GPL-3.0 — https://github.com/ansible/ansible; Semaphore Community MIT — https://github.com/semaphoreui/semaphore + https://semaphoreui.com/pricing (doğrulanma: 2026-09-15). +``` + +### K-07 — Monitoring + +```text +KARAR: Birincil Prometheus + Node Exporter + Grafana OSS (17 metriğin tamamı); tamamlayıcı Uptime Kuma (saha-teknisyeni dostu UP/DOWN panosu + Push "last seen" + bildirimler). Netdata elendi. +GEREKÇE: 17 metriğin tamamı yalnızca Prometheus yığınında ücretsiz ve kotasız toplanır (WireGuard handshake, Docker, MEG sağlığı textfile collector ile). Netdata Cloud Free 5 node kotası 700 cihazda ücrete düşer. Uptime Kuma metrik vermez ama en basit çevrimdışı alarmını verir (20 sn aralık, 90+ bildirim servisi, tek konteyner). +ALTERNATİF: Yalnızca Uptime Kuma (metrik körlüğü → elendi); Netdata self-hosted Parent (merkezi tasarım yükü + kota riski → elendi). +RİSK: 700 node'da saklama/kardinalite; azaltma: scrape 60 sn, retention 30–90 gün, etiket şeması `BF-` standardında, federasyon/fazlı rollout. +MALİYET: Ücretsiz. +LİSANS: Prometheus/Node Exporter Apache-2.0 — https://github.com/prometheus/prometheus + https://prometheus.io/docs/guides/node-exporter/; Grafana OSS AGPL-3.0 (değişikliksiz self-host) — https://grafana.com/oss/; Uptime Kuma MIT — https://github.com/louislam/uptime-kuma (doğrulanma: 2026-09-15). +``` + +### K-08 — Doküman platformu + +```text +KARAR: Docusaurus (`docs-site/` iskeleti; `docs/*.md` tek kaynak, site derlemesi CI'da). +GEREKÇE: "AI-güncelleme" kriterini yalnızca Git-yerleşik Markdown çözer (ajan `.md`'yi doğrudan yazar, review PR'dan geçer). Wiki.js'nin DB+servis yükü sadelik kuralını bozar; TriliumNext tek-kullanıcı bilgi tabanıdır. MkDocs'a karşı Docusaurus: yerleşik sürümleme + yerleşik i18n + MDX; derleme maliyeti eşdeğerdir. +ALTERNATİF: MkDocs + Material (eşdeğer sadelikte güçlü ikinci; Docusaurus derleme hattı sorun çıkarırsa geçiş maliyeti düşük çünkü kaynak yine düz Markdown — yedekte tutulur). Wiki.js (DB yükü → elendi). TriliumNext (ekip akışı yok → elendi). +RİSK: Node derleme hattı bozulabilir; azaltma: kilitli bağımlılıklar + CI derleme testi; derleme bozulsa bile `docs/*.md` ham haliyle okunur. +MALİYET: Ücretsiz. +LİSANS: Docusaurus MIT — https://github.com/facebook/docusaurus + https://docusaurus.io/docs/markdown-features/diagrams (doğrulanma: 2026-09-15). +``` + +### K-09 — Golden image yöntemi + +```text +KARAR: Golden image = Ubuntu Server 26.04.1+ minimal + `unattended-upgrades` kapalı + `blueforce-install.sh` tek kurucu (bayi no → READY); imaj, preseed/autoinstall dosyasıyla üretilir, cihaz-spesifik kimlik (`bf-`) imajda DEĞİL ilk açılışta verilir. +GEREKÇE: Donanım heterojenliği bilinmediği için bit-kopya klon (Clonezilla) tek imajda kırılgandır; autoinstall + idempotent kurulum scripti her donanım profilinde aynı zemini kurar, kimlik ilk boot'ta enjekte edilir. Hedef akış: USB → Ubuntu → tek script → bayi no → READY. +ALTERNATİF: Clonezilla bit-kopya (hızlı ama donanım-fragil → yedek yöntem olarak tutulur); Packer/custom ISO (ek derleme hattı yükü → elendi); cloud-init (autoinstall'a göre saha-USB akışına daha az uygun → elendi). +RİSK: İlk imaj heterojen donanımda sürücü eksiltebilir; azaltma: önce `bf-hardware-inventory.sh` taraması (18-ROLLOUT), LAB'da her profilde test. +MALİYET: Ücretsiz. +LİSANS: Ubuntu autoinstall/subiquity (ücretsiz, Ubuntu lisans seti içinde) — https://releases.ubuntu.com/26.04/ (doğrulanma: 2026-09-15). NOT: Faz 1'de resmi autoinstall sözdizimi ayrıca doğrulanmadı; kesin direktifler 04-GOLDEN-IMAGE'da LAB çıktısıyla yazılır. +``` + +### K-10 — Docker restart politikası ve imaj disiplini + +```text +KARAR: Tüm saha konteynerlerinde restart politikası `unless-stopped`; `latest` etiketi yasak (sabit tag zorunlu); Watchtower yok; log rotasyonu (`max-size`/`max-file`) daemon.json'da zorunlu; yayımlanan portlar `127.0.0.1`'e bağlanır (UFW bypass'a karşı). +GEREKÇE: `unless-stopped`, `always`'in aksine teknisyenin bakım için durdurduğu konteyneri reboot sonrası sürpriz kalkıştan korur; elektrik kesintisi kurtarması aynen çalışır (kesinti "manuel stop" sayılmaz). Hareketli `latest` etiketi 700 cihazda determinizmi bozar ("onaysız update yasak" politikası). Watchtower hem politikayı ihlal eder (otomatik updater) hem upstream'de arşivlenmiştir. Docker varsayılan `json-file` logu rotasyon yapmaz → dolan disk = kilitlenen cihaz. Docker yayımlanan portlarda UFW'yi baypas eder (resmi belgeli) → localhost-bind + WireGuard üzerinden erişim. +ALTERNATİF: `always` (öngörülemez bakım davranışı → elendi); `iptables:false` (resmi doküman "çoğu kullanıcı için uygun değil" diye uyarır → varsayılan çözüm değil); digest pinleme (`@sha256`) PİLOT-2'de değerlendirilecek ek sıkılık adımı. +RİSK: `max-size/max-file` değerleri MEG log hacmine göre ayarlanmazsa ya disk dolar ya log kaybolur; azaltma: PİLOT'ta hacim ölçülür, 12-DOCKER'da sabitlenir. +MALİYET: Ücretsiz (Docker CE). +LİSANS: Docker CE ücretsiz (Business/Scout mimaride yok) — https://docs.docker.com/engine/containers/start-containers-automatically/ + https://docs.docker.com/engine/logging/configure/ + https://docs.docker.com/engine/network/packet-filtering-firewalls/ (doğrulanma: 2026-09-15); Watchtower arşiv durumu — https://github.com/containrrr/watchtower (doğrulanma: 2026-09-15). +``` + +### K-11 — Otomatik güncelleme yasağı + +```text +KARAR: Golden image'da `unattended-upgrades` kapatılır (`/etc/apt/apt.conf.d/20auto-upgrades` değerleri `0` + `apt-daily*.timer` maskeleme); tüm güncellemeler LAB→PİLOT→WAVE→PROD onay zincirinden geçer (dalgalar: LAB(2)/P1(5)/P2(20)/W1(50)/W2(100)/PROD). +GEREKÇE: `unattended-upgrades` Ubuntu Server'da varsayılan kurulu ve etkindir, günde bir kez çalışır; kapatılmazsa 700 cihaz ilk açılışta güncellemeye kalkar ("onaysız update yasak" ilkesi delinir). Kaçan timer makine açılışında hemen tetiklenir (`Persistent=true` davranışı) — saha PC'leri sık kapanan makinelerdir. +ALTERNATİF: Otomatik security update'e izin verme — 700 cihazda eşzamanlı indirme + onaysız değişim riski nedeniyle elendi; security update'ler öncelik işaretli onaylı dalga ile çıkar. +RİSK: Kapatma adımı imajda atlanırsa filo kendiliğinden güncellenir; azaltma: `blueforce-install.sh` final-check + monitoring'de `Unattended-Upgrade` durumu metriği. +MALİYET: Ücretsiz. +LİSANS: Yok (işletim sistemi yapılandırması) — https://ubuntu.com/server/docs/how-to/software/automatic-updates/ + https://manpages.ubuntu.com/manpages/resolute/en/man8/unattended-upgrade.8.html (doğrulanma: 2026-09-15). +``` + +### K-12 — Cihaz kimliği standardı + +```text +KARAR: 8 haneli bayi no → Device ID `BF-` (büyük), hostname `bf-` (küçük); WireGuard peer adı, RustDesk etiketi, Ansible inventory adı, monitoring etiketi hepsi aynı ID'yi kullanır. Örnek: `BF-12010193` / `bf-12010193`. +GEREKÇE: Tek kimlik = 9 sistemde (DNS/hostname, WireGuard, RustDesk, MeshCentral, Ansible, Prometheus, log, envanter, runbook) join anahtarı; büyük/küçük ayrımı insan-makine karışıklığını önler (insan `BF-`, makine `bf-`). +ALTERNATİF: Rastgele UUID / MAC-tabanlı ad — saha teknisyeni için okunamaz, bayi eşleşmesi manuel olur → elendi. +RİSK: Bayi no değişirse kimlik çakışması; azaltma: 02-NAMING'de yeniden-no prosedürü + envanter benzersizlik kontrolü. +MALİYET: Ücretsiz. +LİSANS: Yok (isimlendirme standardı). +``` + +### K-13 — SSH kimlik doğrulama mimarisi + +```text +KARAR: `blueforce` bakım kullanıcısı + anahtar zorunlu (password SSH kapalı, root SSH kapalı); 700 cihaza tek private key YAYILMAZ — admin başına anahtar + cihazlarda `authorized_keys` merkezi dağıtımı (Ansible), uzun vadede SSH CA hedefi. +GEREKÇE: Tek private key'in sızıntı blast-radius'u 700 cihazdır; per-admin anahtar + merkezi dağıtım sızıntıyı tek anahtarın iptaline indirger. Servisler ayrı system user ile çalışır, sudo kontrollüdür. +ALTERNATİF: Tek paylaşılan private key (basit ama 700 cihazlık blast-radius → elendi); password auth (kaba-kuvvet yüzeyi → elendi). +RİSK: Anahtar dağıtım gecikmesi yeni cihazda erişimsizlik; azaltma: golden image'da bootstrap anahtarı + ilk Ansible run'ında rotasyon. +MALİYET: Ücretsiz. +LİSANS: OpenSSH (BSD-lisanslı), ücretsiz. +``` + +### K-14 — Log ve sağlık disiplini + +```text +KARAR: Birincil metrik/monitoring K-07; log tarafında journald + Docker `json-file` rotasyonlu + textfile `.prom` (WireGuard handshake, Docker, MEG) + `bf-status` / `bf-diagnostics` / `bf-support-bundle` (secret sızdırmaz) sözleşmeleri. +GEREKÇE: 700 dokunulmaz cihazda logsuz metrik kör, metriksiz log sağırdır; textfile collector özel metriklerin (WireGuard/MEG) standart Prometheus yoludur. Support-bundle secret dışlama kuralıyla güvenli uzaktan tanı sağlar. +ALTERNATİF: Harici log platformu (Loki/ELK) ilk fazda — ek servis yükü, sadelik kuralını bozar → Faz-sonrası hedef olarak 15-LOGGING'e not edilir. +RİSK: Log hacmi retention'ı şişirir; azaltma: PİLOT'ta hacim ölçümü + limitler 15-LOGGING'de. +MALİYET: Ücretsiz. +LİSANS: systemd-journald / Node Exporter textfile (Apache-2.0) — https://github.com/prometheus/node_exporter (doğrulanma: 2026-09-15). +``` + +--- + +## 4. Neden Bu Karar? + +Tüm kararlarda aynı üç filtre uygulandı: (1) %100 ücretsiz/self-hosted — resmi URL + sürüm + tarih olmadan lisans iddiası yazılmadı; (2) sadelik — Kubernetes, microservice, gereksiz agent/DB/cloud önerisi reddedildi (AWX'in K8s zorunluluğu, Salt master PKI, Wiki.js DB yükü, harici log platformu); (3) onaysız update yasağı — unattended-upgrades, `latest`, Watchtower aynı ilkeden elendi. 700 ölçeğinde her "küçük varsayılan" (günlük otomatik update, rotasyonsuz log, UFW bypass) filo-çapı arızaya dönüşür; kararlar bu çarpanı küçültür. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Ubuntu 24.04 LTS | Olgun donanım desteği | 26.04 doğrulandı, daha kısa destek penceresi | Yedek | +| XFCE | Hafif, düşük kaynak | Kullanıcı kararıyla elendi (saha standardı GNOME) | Elendi | +| AnyDesk | Tanınmış, kolay | Ticari lisans zorunlu (700 cihaz), kapalı kaynak | Elendi | +| Tailscale/ZeroTier | Kolay mesh | Freemium, merkezi kimlik | Elendi | +| AWX | Güçlü UI | K8s zorunlu, release'ler duraklatıldı | Elendi | +| Salt / Rudder | Ölçeklenir | Agent+master/relay yükü, öğrenme eğrisi | Elendi | +| Netdata Cloud | Zengin agent | Free 5 node kotası, 700 cihaz ücretli | Elendi | +| Yalnızca Uptime Kuma | Çok kolay | Metrik körlüğü | Elendi | +| Wiki.js | Güçlü editör | DB+servis yükü, Git akışı dolaylı | Elendi | +| MkDocs + Material | Eşdeğer sade | Sürümleme/i18n eklentiyle | Yedek (güçlü ikinci) | +| TriliumNext | Hafif | Tek kullanıcı, ekip akışı yok | Elendi | +| Clonezilla bit-kopya | Hızlı klon | Donanım-fragil | Yedek yöntem | +| Docker `always` | Her koşulda ayakta | Bakım-stop'u reboot'ta deler | Elendi | +| Watchtower | Otomatik imaj | Politika ihlali + arşivlenmiş upstream | Elendi | +| Tek paylaşılan SSH key | Basit | 700 cihazlık blast-radius | Elendi | + +## 6. Avantajlar + +- 14 kararın tamamı ücretsiz katmanda kanıtlı; zorunlu mimaride lisans bedeli yok. +- Her kararın resmi kaynak URL'si + sürümü + doğrulama tarihi var; varsayıma dayalı karar yok (belirsizler LAB/PİLOT'a havale edildi). +- 4 erişim kanalından ikisi WireGuard-bağımsız; tek tünel arızası filoyu kör etmez. + +## 7. Dezavantajlar + +- K-09 (golden image) Faz 1'de tam resmi-doğrulamalı değil; autoinstall sözdizimi LAB çıktısına bağımlı. +- Semaphore Community'de OIDC/2FA/Vault yokluğu operasyonel disiplinle (VPN-arkası + Key Store) kapatılıyor; bu disiplin bozulursa güvenlik açığı doğar. +- Prometheus 700 node işletme bilgisi ister; retention/kardinalite LAB'da ölçülmeden donanım siparişi verilmemeli. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| `unattended-upgrades` imajda açık unutulur | Orta | Yüksek (filo kendiliğinden güncellenir) | install.sh final-check + monitoring metriği (K-11) | +| Docker UFW bypass ile MEG portu dışa açılır | Orta | Yüksek | localhost-bind standardı + 11-HARDENING denetimi (K-10) | +| Semaphore çöker, filo yönetimsiz kalır | Düşük | Orta | Ansible CLI bağımsız çalışır (K-06) | +| 26.04.x sürücü farkı heterojen donanımda | Orta | Orta | LAB'da profil başına imaj testi (K-01/K-09) | +| Tek VDS iki bağımsız kanalı da barındırırsa ortak arıza | Düşük | Yüksek | hbbs/hbbr + MeshCentral ayrı VDS veya yedek planı (K-04) | +| 700 node Prometheus kardinalite patlaması | Orta | Orta | 60 sn scrape, 30–90 gün retention, `BF-` etiket şeması (K-07) | + +## 9. Uygulama Planı + +1. Golden image'a K-01/K-02/K-03/K-10/K-11 uygulanır (04-GOLDEN-IMAGE). +2. WireGuard hub + ilk peer'lar K-05'e göre kurulur (08-WIREGUARD). +3. hbbs/hbbr + MeshCentral VDS'leri K-04'e göre ayağa kaldırılır (07-REMOTE-ACCESS). +4. Ansible + Semaphore K-06'ya göre kurulur; ilk playbook LAB(2)'de koşar (09-FLEET). +5. Prometheus + Uptime Kuma K-07'ye göre kurulur; 17 metrik LAB'da doğrulanır (14-MONITORING). +6. Update zinciri K-11 dalgalarıyla işletilir (10-UPDATE). + +```bash +# örnek: cihaz kimliği standardı (K-12) +hostnamectl set-hostname bf-12010193 +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| `20auto-upgrades` değerleri `0`, timer'lar maskeli | Otomatik apt çalışmaz | LAB(2) imaj denetimi | +| `bf-gui-on/off` sonrası RDP oturumu | GNOME oturumu açılır/kapanır | LAB(2) | +| WireGuard down iken RustDesk oturumu | Bağlantı kurulur (bağımsız kanal) | P1(5) | +| `docker inspect` restart politikası | `unless-stopped` tüm saha konteynerlerinde | LAB(2) | +| Semaphore kapalı iken Ansible CLI run | Playbook başarıyla koşar | LAB(2) | +| Scrape + 17 metrik + Uptime Push | Grafana + Kuma panoları dolu | P1(5) | + +## 11. Rollback + +1. Yanlış karar tespitinde bu dosya PR ile düzeltilir; bağımlı dokümanlar (01, 03, 07, 08, 09, 12, 14) aynı PR'da güncellenir. +2. İmaj-kritik karar (K-01/K-11) geri alınırsa golden image yeniden üretilir, sahaya sürülmüş dalga 10-UPDATE rollback zinciriyle geri alınır. +3. Son çare: karar öncesi imaj sürümüne dönüş (17-RECOVERY LEVEL 9). + +## 12. Kontrol Listesi + +- [ ] 14 kararın her birinde KARAR/GEREKÇE/ALTERNATİF/RİSK/MALİYET/LİSANS + kaynak URL var. +- [ ] Ücretli hiçbir araç zorunlu mimaride değil; AnyDesk hükmü resmi URL'li. +- [ ] `unattended-upgrades`, `latest`, Watchtower yasakları üç dosyada da tutarlı (03/10/12). +- [ ] Ek B'deki 27 sorunun her biri ≥1 karara bağlı (boş hücre yok). + +## 13. Açık Sorular + +- [ ] GNOME oturum başlatma satırı + `bf-gui-*` kesin birim listesi — LAB 26.04.1 imajında (sahibi: Faz 3, 03/04 yazarları). +- [ ] MEG log hacmine göre `max-size/max-file` kesin değerleri — PİLOT ölçümü (sahibi: 12-DOCKER yazarı). +- [ ] Turkcell NAT kopma sayacı + keepalive ayarı — P1 7/24 sayaç (sahibi: 08-WIREGUARD yazarı). +- [ ] Digest pinleme (`@sha256`) kararı — PİLOT-2 değerlendirmesi (sahibi: 12-DOCKER yazarı). +- [ ] Prometheus retention/kardinalite hesabı (700 × 60 sn) — LAB ölçümü (sahibi: 14-MONITORING yazarı). +- [ ] Semaphore Global Runner sayısı + eşzamanlılık — 700 node yük testi (sahibi: 09-FLEET yazarı). + +--- + +## Ek A: Kaynak Tablosu (URL + sürüm + tarih) + +| # | Kaynak | Sürüm/tarih | +|---|---|---| +| 1 | https://releases.ubuntu.com/ | 26.04.1 LTS Resolute Raccoon, 2026-09-15 | +| 2 | https://releases.ubuntu.com/26.04/ | Server GUI kurmaz notu, 2026-09-15 | +| 3 | https://ubuntu.com/download/desktop | 2 GHz / 6 GB / 25 GB, 2026-09-15 | +| 4 | https://ubuntu.com/server/docs/how-to/software/automatic-updates/ | güncel Server docs, 2026-09-15 | +| 5 | https://manpages.ubuntu.com/manpages/resolute/en/man8/unattended-upgrade.8.html | 2.12ubuntu9, 2026-09-15 | +| 6 | https://www.gnome.org/ | Desktop standardı beyanı, 2026-09-15 | +| 7 | https://github.com/neutrinolabs/xrdp | v0.10.6.1 (2026-07-07), 2026-09-15 | +| 8 | https://www.wireguard.com/quickstart/ | keepalive 25 sn, 2026-09-15 | +| 9 | https://packages.ubuntu.com/resolute/wireguard-tools | 1.0.20250521-1ubuntu1, 2026-09-15 | +| 10 | https://docs.docker.com/engine/network/packet-filtering-firewalls/ | UFW bypass, 2026-09-15 | +| 11 | https://docs.docker.com/engine/logging/configure/ | log rotasyon, 2026-09-15 | +| 12 | https://docs.docker.com/engine/containers/start-containers-automatically/ | unless-stopped, 2026-09-15 | +| 13 | https://github.com/containrrr/watchtower | arşivli, 2026-09-15 | +| 14 | https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ | mimari hbbs/hbbr, 2026-09-15 | +| 15 | https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/docker/ | compose + portlar, 2026-09-15 | +| 16 | https://rustdesk.com/docs/en/self-host/client-deployment/ | OSS toplu dağıtım, 2026-09-15 | +| 17 | https://rustdesk.com/pricing/ | Individual ~11,88 / Basic ~23,88 USD/ay, 2026-09-15 | +| 18 | https://support.anydesk.com/docs/anydesk-licenses.md | ticari lisans şartı (güncelleme 2025-10-30), 2026-09-15 | +| 19 | https://anydesk.com/en/pricing | Solo/Standard/Advanced/Ultimate, 2026-09-15 | +| 20 | https://github.com/Ylianst/MeshCentral | 1.2.5 (2026-08-12), Apache-2.0, 2026-09-15 | +| 21 | https://github.com/ansible/ansible | GPL-3.0, 2026-09-15 | +| 22 | https://github.com/semaphoreui/semaphore + https://semaphoreui.com/pricing | MIT, Community $0, 2026-09-15 | +| 23 | https://github.com/prometheus/prometheus + https://prometheus.io/docs/guides/node-exporter/ | Apache-2.0, 2026-09-15 | +| 24 | https://grafana.com/oss/ | AGPL-3.0 OSS, 2026-09-15 | +| 25 | https://github.com/louislam/uptime-kuma | MIT, 2026-09-15 | +| 26 | https://github.com/facebook/docusaurus + https://docusaurus.io/docs/markdown-features/diagrams | MIT, 2026-09-15 | +| 27 | https://github.com/prometheus/node_exporter | textfile collector, 2026-09-15 | + +--- + +## Ek B: 27 Soru → Karar İzlenebilirliği + +> NOT: `SUMMARY.md` Faz 4'te üretilecek; aşağıdaki S-01…S-27 listesi şartname + plandaki 27 kritik sorunun Faz 2 karşılığıdır. SUMMARY yazıldığında her cevap buradaki karara linklenecek. + +| # | Soru | Karar | Doküman | +|---|---|---|---| +| S-01 | Hangi Ubuntu sürümü? | K-01 (Server 26.04.1+ LTS) | 03-UBUNTU-BASELINE | +| S-02 | Server mı Desktop mı? | K-01 (Server, GUI'siz kurulum) | 03 | +| S-03 | GNOME mu XFCE mi? | K-02 (GNOME; XFCE elendi) | 03, 07 | +| S-04 | Boot terminale mi grafiğe mi? | K-03 (terminal + bf-gui-on/off) | 03, 16 | +| S-05 | SSH nasıl (kullanıcı, auth, root)? | K-13 (key zorunlu, root kapalı) | 06 | +| S-06 | 700 cihazda tek key riski nasıl çözülür? | K-13 (per-admin key + CA hedefi) | 06 | +| S-07 | RDP hangi sunucu/masaüstü ile? | K-03 (xRDP + GNOME) | 07 | +| S-08 | RustDesk OSS neyi kapsar, Pro gerekir mi? | K-04 (OSS yeterli, Pro yok) | 07 | +| S-09 | WireGuard çökerse uzak erişim sürer mi? | K-04 (RustDesk/MeshCentral bağımsız) | 07, 08 | +| S-10 | 4. erişim kanalı hangisi? | K-04 (MeshCentral) | 07 | +| S-11 | AnyDesk ücretsiz kullanılabilir mi? | K-04 (HAYIR — mimariden çıkarıldı) | 07, 23 | +| S-12 | WireGuard modeli + keepalive + otomatik reconnect? | K-05 (hub-spoke, 25 sn, wg-quick enable) | 08 | +| S-13 | RDP/SSH dış dünyaya açık mı? | K-05 (hayır — WireGuard arkası) | 08, 11 | +| S-14 | Filo yönetimi hangi araç? | K-06 (Ansible + Semaphore Community) | 09 | +| S-15 | Semaphore Pro gerekir mi? | K-06 (hayır — kanıtlı) | 09, 23 | +| S-16 | Monitoring hangi yığın, 17 metrik nasıl? | K-07 (Prometheus + Kuma) | 14 | +| S-17 | Çevrimdışı/last-seen nasıl izlenir? | K-07 (`up` + Push monitör) | 14 | +| S-18 | Onaysız update politikası nedir? | K-11 (yasak + dalgalı onay zinciri) | 10 | +| S-19 | `unattended-upgrades` ne olacak? | K-11 (imajda kapalı) | 03, 10 | +| S-20 | Update dalgaları neler? | K-11 (LAB(2)/P1(5)/P2(20)/W1(50)/W2(100)/PROD) | 10, 18 | +| S-21 | Docker restart politikası ne? | K-10 (`unless-stopped`) | 12 | +| S-22 | `latest` / Watchtower serbest mi? | K-10 (ikisi de yasak) | 12 | +| S-23 | Docker + UFW çakışması nasıl çözülür? | K-10 (localhost-bind) | 11, 12 | +| S-24 | Log disiplini nedir? | K-10 + K-14 (rotasyon + textfile + bundle) | 12, 15 | +| S-25 | Golden image yöntemi ne? | K-09 (autoinstall + install.sh) | 04, 05 | +| S-26 | Cihaz kimliği standardı ne? | K-12 (`BF-` / `bf-`) | 02 | +| S-27 | Doküman platformu hangisi? | K-08 (Docusaurus) | 22 | + +--- + +## Ek: Karar Haritası (hangi soru hangi karara?) + +```mermaid +flowchart LR + SORU["27 soru (Ek B)
S-01…S-27"] --> ZEMIN["Zemin: K-01/K-02/K-03
K-09/K-10/K-11/K-12"] + SORU --> AG["Ağ + erişim: K-04/K-05/K-13"] + SORU --> OPS["Operasyon: K-06/K-07/K-14"] + SORU --> DOC["Docs: K-08"] + ZEMIN --> D1["03/04/05/10/12/02"] + AG --> D2["07/08/06/11"] + OPS --> D3["09/14/15"] + DOC --> D4["22"] +``` +
+ + +# 25 — Uygulama Yol Haritası (Implementation Roadmap) + +> Kısa özet: Blueforce 700 cihaz projesinin FAZ'lı yol haritası: FAZ 0 hazırlık → FAZ 1 merkez → FAZ 2 imaj → FAZ 3 LAB/P1 → FAZ 4 dalgalar → FAZ 5 PROD + işletme. Katılımsız provisioning (unattended) ayrı FAZ'dır; temel akış çalışmadan otomasyona geçilmez. + +- Dosya: `docs/25-IMPLEMENTATION-ROADMAP.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-09` (imaj + unattended sırası), `#K-11` (dalgalar), tüm K-01…K-14 (fazlara dağılım) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Projeyi "ne, hangi sırada, hangi kapıdan geçerek" sorusuyla yönetmek. Her FAZ'ın girişi, çıkışı ve sahibi bellidir; bir FAZ kapanmadan sonrakine iş taşınmaz. + +## 2. Kapsam + +- Kapsam içi: FAZ tanımı, sıra ve bağımlılıklar, her FAZ'ın çıkış kriteri, unattended provisioning FAZ'ının yeri, doküman-faz eşleşmesi. +- Kapsam dışı: dalga cihaz sayıları detayı (18), update onay akışı (10), komut-seviyesi kurulum (03/04/05). + +## 3. Kararlar + +```text +KARAR: Yol haritası 7 FAZ'dır: F0 Hazırlık+Envanter → F1 Merkez VDS → F2 Golden image (katılımlı) → F3 LAB+P1 → F4 Dalgalar (P2/W1/W2) → F5 PROD+İşletme → F6 Katılımsız provisioning (unattended, ayrı FAZ). +GEREKÇE: Sıra bağımlılıkları zorlar: envanter olmadan imaj (K-09), imaj olmadan LAB, LAB olmadan dalga (K-11) olmaz. Unattended provisioning ayrı FAZ'dır çünkü katılımlı akış (USB→script→bayi no) sahada kanıtlanmadan PXE/preseed otomasyonu ek yük ve ek arıza yüzeyi demektir. +ALTERNATİF: Unattended'i en başa koyma — temel akıştaki hata otomasyonla çarpılır; elendi. Tek-faz "hepsini yap" planı — kapısız ilerleme, filo-çapı risk; elendi. +RİSK: FAZ atlama baskısı (takvim); azaltma: her FAZ çıkışı 21'deki onay yetkilisinin imzasına bağlıdır. +MALİYET: Ücretsiz (işgücü planlaması; VDS kira hariç). +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Klasik hata sırası tersine çevirmektir: önce otomasyon, sonra temel. Bu harita bilerek "sıkıcı" sırayı seçer — önce elle çalışan, sonra kendiliğinden çalışan. F6'nın ayrı FAZ olması, unattended'in iptal edilebilir (proje yine de biter) ama atlanamaz (sırası gelmeden yapılmaz) olduğunu söyler. Her FAZ bir karar grubunu hayata geçirir; harita ile 24-DECISION-LOG arasındaki bağ tabloyla sabittir. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Unattended ilk FAZ | Erken otomasyon | Kanıtlanmamış akışın otomasyonu | Elendi (F6'ya taşındı) | +| Az FAZ (3 büyük) | Kısa plan | Kapılar seyrek, risk büyük | Elendi | +| Çok FAZ (12+) | Detaylı | Yönetim yükü, faz yorgunluğu | Elendi | + +## 6. Avantajlar + +- Her FAZ bağımsız kapanır; proje yarıda kalsa bile kazanım (örn. F1 merkezi) kalır. +- F6 opsiyoneldir: katılımlı akışla PROD bitebilir, unattended sonradan eklenir. + +## 7. Dezavantajlar + +- FAZ disiplini yavaş görünür; paydaşa "neden F6 en sonda" açıklaması gerekir (gerekçe §4'tür). +- FAZ'lar arası bekleme (gözetim süreleri) takvimi uzatır. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| FAZ çıkışı atlanır, sonrakine geçilir | Orta | Yüksek | Onay yetkilisi imzası zorunlu (21) | +| Merkez VDS donanımı yetersiz çıkar (F1) | Orta | Orta | LAB yük testi; servisleri ikinci VDS'e bölme planı (01) | +| Donanım profili imajı kırar (F2→F3) | Orta | Orta | F0 envanteri + F3 profil matrisi | + +## 9. Uygulama Planı + +| FAZ | Ad | Kararlar | Çıkış kriteri | Dokümanlar | +|---|---|---|---|---| +| F0 | Hazırlık + donanım envanteri | K-12 (kimlik), K-09 (tarama-önce) | Profil listesi + envanter şeması onaylı | 02, 18 (FAZ 0), 00 | +| F1 | Merkez VDS kurulumu | K-04, K-05, K-06, K-07, K-08 | WG hub + hbbs/hbbr + MeshCentral + Semaphore + Prometheus/Kuma ayakta; L9 tatbikatı yapıldı | 06, 07, 08, 09, 14, 17-L9 | +| F2 | Golden image (katılımlı) | K-01, K-02, K-03, K-09, K-10, K-11 | `blueforce-install.sh` USB→READY akışı LAB donanımında çalışıyor; `unattended-upgrades` kapalı; tag/`unless-stopped` denetli | 03, 04, 05, 12 | +| F3 | LAB(2) + P1(5) | K-11 (dalga), K-14 (bundle), K-05 (kopma sayacı) | 3 akış yeşil; teknisyen desteksiz kurulum yaptı; kopma sayacı toplandı | 18, 19, 20, 10 | +| F4 | Dalgalar P2/W1/W2 | K-11, K-06, K-07 | Her dalga çıkış kriteri + halt kaydı; destek yükü hedefte | 18, 21, 14 | +| F5 | PROD (~523) + işletmeye alma | Tümü | Filo READY; rutinler (günlük/haftalık) devrede; `v1` doküman kesildi | 21, 22, 23 | +| F6 | Katılımsız provisioning (unattended) | K-09 (hedef) | Teknisyen girdisiz (veya minimum-girdili) kurulum P2 ölçeğinde kanıtlı; katılımlı akış yedekte duruyor | 04 (F6 eki) | + +F6 notu: F6, F5'ten sonra başlar; F5'in kapanmasına engel değildir. F6 kapsamı (PXE/preseed derinliği, ağ gereksinimleri) F3 verisine göre F4 içinde netleştirilir. F6 başarısız olursa proje F5 haliyle tamam sayılır. + +```bash +# örnek: FAZ kapısı kontrolü (merkez) +# F3 çıkışı için LAB doğrulama seti +ansible-playbook playbooks/verify-lab.yml --limit lab +bf-support-bundle --out /tmp/f3-gate-bundle.tar.gz +``` + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| F1 L9 tatbikatı (boş VDS'e kur) | Tüm merkez servisleri ayağa kalkar | İzole VDS | +| F2 imaj denetim seti (K-10/K-11 kilitleri) | `latest` yok, timer'lar maskeli, politika `unless-stopped` | LAB(2) | +| F3 3-akış + rollback | Erişim/boot/update + geri alma yeşil | LAB(2)/P1(5) | +| F6 unattended prova | Minimum-girdi ile READY | P2 ölçeği | + +## 11. Rollback + +1. FAZ içi hata: ilgili dalga/iş 17-RECOVERY veya 10-UPDATE zinciriyle geri alınır; FAZ tekrarlanır. +2. FAZ kararı yanlışsa (örn. imaj yöntemi): 24-DECISION-LOG PR'ı + bağımlı FAZ'ların tekrarı. +3. F6 geri alınırsa katılımlı akışa dönülür (yedek her zaman sıcak tutulur). + +## 12. Kontrol Listesi + +- [ ] Her FAZ'ın çıkış kriteri + sahibi yazılı ve onaylı. +- [ ] F6'nın F5 sonrası ayrı FAZ olduğu paydaşça kabul edildi. +- [ ] Doküman-faz eşleşmesi (tablodaki son sütun) güncel. +- [ ] Onay yetkilisi (asıl+vekil) tanımlı (21). + +## 13. Açık Sorular + +- [ ] FAZ tarihleri ve teknisyen kapasite planı (sahibi: yönetim + 18). +- [ ] F1 VDS donanım siparişi için LAB yük ölçümü (sahibi: 14-MONITORING yazarı). +- [ ] F6 kapsamı (PXE şart mı, USB-preseed yeter mi) — F3 verisi sonrası (sahibi: 04 yazarı). + +--- + +## Ek: Mermaid — FAZ akışı + +```mermaid +flowchart LR + F0["F0 Hazırlık
+ envanter"] --> F1["F1 Merkez VDS"] + F1 --> F2["F2 Golden image
(katılımlı)"] + F2 --> F3["F3 LAB 2 + P1 5"] + F3 --> F4["F4 P2/W1/W2"] + F4 --> F5["F5 PROD + işletme"] + F5 --> F6["F6 Unattended
(ayrı FAZ)"] +``` +
+ + + + + + + + + + + + + + +# Faz 1A Araştırma — OS / GUI / RDP + WireGuard + Docker + +> Durum: KAYNAK DOĞRULAMA NOTU (karar taslağı değil). Her bulgu `iddia + resmi URL + sürüm/tarih + Blueforce etkisi` formatındadır. +> Doğrulama yöntemi: resmi sayfalar 2026-09-15 tarihinde doğrudan çekilerek (HTTP 200) okundu. Web arama servisi (Nous gateway) kapalıydı; bu dosyadaki hiçbir bulgu arama özetine dayanmaz, hepsi doğrudan sayfa içeriğidir. +> Kural: tüm bileşenlerde FREE sürüm baz alınır (Ubuntu LTS, WireGuard, Docker CE, XFCE, xRDP). Ücretli varyantlar (Ubuntu Pro, Docker Business vb.) zorunlu mimariye konulmaz; free limitleri ilgili maddede kısaca not edilir. + +--- + +## A1. Ubuntu 26.04 LTS var mı? Saha için uygun mu? + +- **BULGU A1.1 — Ubuntu 26.04.1 LTS mevcut ve resmi indirme sayfasında yayında.** + - İddia: `releases.ubuntu.com` ana sayfası "Ubuntu 26.04.1 LTS (Resolute Raccoon)" başlığını, `26.04/` ve `26.04.1/` dizinlerini listeler. + - Resmi URL: https://releases.ubuntu.com/ (HTTP 200, çekilme: 2026-09-15) + - Sürüm/tarih: 26.04.1 LTS, kod adı Resolute Raccoon + - Blueforce etkisi: Şartnamedeki "Ubuntu LTS (26.04 doğrulanacak)" maddesi DOĞRULANDI — 26.04 gerçek, güncel LTS serisidir. Ancak `.1` sürümünün yayında olması, golden image için **26.04.1+ noktasürümü** baz almayı önerir (ilk .0 dalgasındaki kurulumcu hataları genelde .1'de toplanır — bu kısım genel mühendislik kanaati, LAB'da imaj testiyle kesinleşir). + +- **BULGU A1.2 — 26.04 Server ISO'su varsayılan olarak GUI kurmaz.** + - İddia: `26.04/` dizin sayfası Server imajını "It will not install a graphical user interface" cümlesiyle tanımlar; Desktop ve live-server ISO'ları ayrıdır. + - Resmi URL: https://releases.ubuntu.com/26.04/ (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: "Server + sonradan XFCE" saha mimarisi temiz bir zemine oturur — minimal kurulum + ihtiyaca göre GUI, desteklenen standart yoldur. + +- **BULGU A1.3 — Ubuntu Desktop 26.04 sistem gereksinimi saha PC'leri için ağırdır.** + - İddia: Resmi indirme sayfası Desktop için "2 GHz dual-core, 6 GB RAM, 25 GB disk" ister. + - Resmi URL: https://ubuntu.com/download/desktop (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: 700 saha PC'sinde tam GNOME/Ubuntu Desktop standardı RAM/disk bütçesini zorlar. Bu, XFCE tercihinin en somut dayanağıdır (bkz. A2). + +- **BULGU A1.4 — `unattended-upgrades` Ubuntu Server'da VARSAYILAN OLARAK KURULU ve ETKİNDİR; günde bir kez çalışır.** + - İddia: Resmi Server dokümantasyonu: "This is done via the `unattended-upgrades` package, which is installed by default" + "Right after installation, automatic installation of security updates will be enabled" + "By default, `unattended-upgrades` runs once per day". Kapatma yolu: `/etc/apt/apt.conf.d/20auto-upgrades` içinde `Update-Package-Lists` / `Unattended-Upgrade` değerlerini `0` yapmak; tetikleyici systemd timer'ları `apt-daily.timer` / `apt-daily-upgrade.timer`. + - Resmi URL: https://ubuntu.com/server/docs/how-to/software/automatic-updates/ (HTTP 200, render; kaynak: https://raw.githubusercontent.com/canonical/ubuntu-server-documentation/main/docs/how-to/software/automatic-updates.md, HTTP 200, çekilme: 2026-09-15) + - Sürüm/tarih: güncel Server dokümantasyonu (resolute dahil); man sayfası resolute'ta `unattended-upgrades 2.12ubuntu9` paketini doğrular — https://manpages.ubuntu.com/manpages/resolute/en/man8/unattended-upgrade.8.html (HTTP 200, 2026-09-15); noble paketi https://packages.ubuntu.com/noble/unattended-upgrades (HTTP 200) + - Blueforce etkisi: KRİTİK — "onaysız update yasak" politikası için golden image'da `20auto-upgrades`'ın `0`'lanması ve timer'ların maskelenmesi ŞART; aksi halde 700 cihaz ilk açılışta güncellemeye kalkar (doküman ayrıca makine kapalıyken kaçan timer'ın açılışta hemen tetiklendiğini — `Persistent=true` davranışı — yazar; saha PC'leri sık kapanan makineler olduğu için bu not runbook'a girer). Free notu: ESM kapsamı Pro'ya girer — mimaride ESM'e bel bağlanmaz, standart `security` cebi yeterlidir (ücretsiz). + +--- + +## A2. Masaüstü: XFCE vs GNOME + xRDP (700 ölçek) + +- **BULGU A2.1 — Xfce resmi olarak "hafif, hızlı, düşük sistem kaynağı" hedefler.** + - İddia: Proje ana sayfası: "Xfce is a lightweight desktop environment… It aims to be fast and low on system resources." + - Resmi URL: https://www.xfce.org/about (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: GNOME (6 GB RAM şartı, A1.3) karşısında XFCE'nin saha standardı olması yönünde karar verilir. RDP üzerinden 700 cihaza bakımda düşük RAM/CPU ayak izi = daha az donma, daha ucuz saha donanımı. + +- **BULGU A2.2 — xRDP aktif, güncel, ücretsiz (Apache-2.0) RDP sunucusudur; Temmuz 2026 sürümü var.** + - İddia: GitHub `neutrinolabs/xrdp`: açıklama "xrdp: an open source RDP server", son push 2026-09-09, son sürüm v0.10.6.1 (2026-07-07), ~6728 yıldız, varsayılan dal `devel`. + - Resmi URL'ler: https://github.com/neutrinolabs/xrdp + https://api.github.com/repos/neutrinolabs/xrdp (+ `/releases/latest`) (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: xRDP "ölü proje" riski YOK — 4. erişim yöntemi adayı olarak mimaride kalır. Lisans ücretsiz, 700 ölçekte lisans maliyeti doğmaz. + +- **BULGU A2.3 — xRDP mimarisi oturum yöneticisi (sesman) + servis birimleri üzerine kuruludur; GUI aç/kapa buradan türetilir.** + - İddia: Upstream README kaynak ağacı `sesman` (session manager), `xrdp-sesman.service.in` ve `xrdp.service.in` systemd birimlerini içerir (devel dalı `instfiles/` listesi ve README'den doğrulandı). + - Resmi URL: https://github.com/neutrinolabs/xrdp (README + `instfiles/`, `sesman/` dizinleri; GitHub API HTTP 200, 2026-09-15) + - Blueforce etkisi: `bf-gui-on/off` tasarımının dayanağı: GUI, display-manager servisi + xRDP servisleri (`xrdp`, `xrdp-sesman`) üzerinden systemd ile açılıp kapatılabilir bir katman olarak modellenir (kesin birim adları ve XFCE oturum başlatma satırı LAB'da 26.04.1 imajında doğrulanacak — bu dosyada komut uydurulmaz). + +- **DOĞRULANAMAYAN / LAB'A BIRAKILAN:** xRDP GitHub wiki'si JS ile yükleniyor, statik çekmede oturum-masaüstü uyumluluk tablosu okunamadı (https://github.com/neutrinolabs/xrdp/wiki, 2026-09-15). GNOME-üzerinde-xRDP'nin bilinen ek ayar ihtiyacı bu kaynaktan teyit EDİLEMEDİ → "XFCE birincil, GNOME denenmeyecek" kararı A1.3+A2.1'e dayanır; GNOME+xRDP kombinasyonu LAB gündemine "test edilmeyecek alternatif" olarak değil, "XFCE yetersiz kalırsa" yedeği olarak yazılır. + +--- + +## A3. WireGuard otomatik reconnect (Turkcell NAT arkası saha) + +- **BULGU A3.1 — NAT arkasındaki eşin erişilebilir kalmasının resmi mekanizması PersistentKeepalive'dır; önerilen değer 25 sn.** + - İddia: Resmi QuickStart "NAT and Firewall Traversal Persistence" bölümü: NAT/güvenlik duvarı arkasındaki eşin bağlantı eşlemesini canlı tutmak için periyodik keepalive göndermesi gerekir; "A sensible interval that works with a wide variety of firewalls is 25 seconds"; `0` = kapalı (varsayılan); ayar `PersistentKeepalive =` alanı veya `persistent-keepalive` komut satırı ile yapılır; çoğu kullanıcının ihtiyacı yoktur, protokolü biraz daha geveze yapar. + - Resmi URL: https://www.wireguard.com/quickstart/ (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: Saha PC'leri (mobil NAT arkası) için istemci konfigürasyonunda `PersistentKeepalive = 25` STANDART olur. Sunucu tarafında keepalive gerekmez (simetrik değil — istemci canlı tutar). + +- **BULGU A3.2 — `wg-quick@.service` systemd birimi Ubuntu paketinin içinden gelir; enable = açılışta otomatik VPN.** + - İddia: `wireguard-tools` (noble/amd64) dosya listesi `/lib/systemd/system/wg-quick@.service` ve `wg-quick.target` içerir → `systemctl enable wg-quick@wg0` kalıcı açılır; resolute (26.04) paket sürümü `1.0.20250521-1ubuntu1`. + - Resmi URL'ler: https://packages.ubuntu.com/noble/amd64/wireguard-tools/filelist ve https://packages.ubuntu.com/resolute/wireguard-tools (HTTP 200, 2026-09-15) + - Blueforce etkisi: "Otomatik reconnect" iki katmandan kurulur: (1) WireGuard keepalive (A3.1) + (2) systemd `wg-quick@wg0` enable + restart. Elektrik kesintisi sonrası cihaz açılınca VPN kendiliğinden gelir — 16-POWER-LOSS dokümanının ağı ayağı hazır. + +- **DOĞRULANAMAYAN / SAHADA ÖLÇÜLECEK:** "Turkcell CGNAT" özelliği resmi dokümandan doğrulanamaz (operatör bilgisi). Tasarım, operatörden bağımsız NAT-traversal'a (A3.1) dayanır; PİLOT-1'de her cihazda `PersistentKeepalive=25` ile 7/24 kopma sayacı tutulur, keepalive aralığı saha verisine göre ayarlanır. Tahminle 10 sn gibi agresif değer yazılmaz. + +--- + +## A4. Docker (CE, ücretsiz): UFW bypass + log + restart + latest + Watchtower + +- **BULGU A4.1 — Docker, yayımlanan portlarda UFW'yi BAYPAS eder; bu resmi olarak belgelenmiştir.** + - İddia: Resmi doküman "Docker and ufw": Docker ve UFW uyumsuz çalışır; konteyner portu yayımlanınca trafik `nat` tablosunda yönlendirilir, UFW'nin kullandığı INPUT/OUTPUT zincirlerine uğramadan geçer — "effectively ignoring your firewall configuration". + - Resmi URL: https://docs.docker.com/engine/network/packet-filtering-firewalls/ (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: KRİTİK — MEG ve yardımcı servisler `ports:` ile yayımlanırsa UFW kuralları delinir. Güvenli çözüm seçenekleri (dokümanın aynı sayfasından): (a) port yayımlamayı 127.0.0.1'e bağlamak, (b) `iptables:false` (doküman "çoğu kullanıcı için uygun değil, konteyner ağını bozabilir" diye uyarır — bu yüzden varsayılan çözüm DEĞİL), (c) Docker'ın `DOCKER-USER`/nftables belgeli zincirleri. Saha standardı: (a) + WireGuard üzerinden erişim; detay `12-DOCKER-OPERATIONS` ve `11-SECURITY-HARDENING`'de. + +- **BULGU A4.2 — Varsayılan `json-file` log sürücüsü ROTASYON YAPMAZ; disk dolmasına yol açar. Çözüm `max-size`/`max-file` daemon ayarıdır.** + - İddia: Resmi doküman: "By default, no log-rotation is performed… which can lead to disk space exhaustion"; örnek daemon ayarı `{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }`; `max-file` gibi sayısal değerler string yazılır; ayar değişikliği sonrası Docker restart gerekir ve sadece yeni konteynerlere uygulanır. + - Resmi URL: https://docs.docker.com/engine/logging/configure/ (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: Golden image'daki `/etc/docker/daemon.json`'da log rotasyonu ZORUNLU standart olur (saha PC'leri aylarca dokunulmaz; dolan disk = kilitlenen cihaz). Değerler (10m/3) başlangıç önerisi; MEG log hacmi PİLOT'ta ölçülüp ayarlanır. + +- **BULGU A4.3 — Saha standardı restart politikası `unless-stopped`'tur.** + - İddia: Resmi doküman: `unless-stopped` "`always`'e benzer, ancak konteyner durdurulmuşsa (manuel ya da başka şekilde) Docker daemon yeniden başlasa bile yeniden başlatılmaz". Politika ancak konteyner en az bir kez başarıyla başladıktan sonra devreye girer. + - Resmi URL: https://docs.docker.com/engine/containers/start-containers-automatically/ (HTTP 200, çekilme: 2026-09-15) + - Blueforce etkisi: `always` yerine `unless-stopped` seçilir — saha teknisyeni bakım için durdurduğu konteynerin reboot sonrası sürpriz şekilde kalkmaması gerekir (öngörülebilirlik > her koşulda ayakta tutma). Elektrik kesintisi kurtarması `unless-stopped` ile aynen çalışır (kesinti "manuel stop" sayılmaz). + +- **BULGU A4.4 — `latest` etiketi yasaklanır (determinizm gerekçesi).** + - İddia: Docker Hub etiket-semantiği sayfası taşınmış, resmi cümle bu turda yakalanamadı (https://docs.docker.com/docker-hub/repos/manage/hub-images/ HTTP 200 ama `latest` geçmiyor, 2026-09-15). Bu yüzden yasak, resmi alıntıya değil Blueforce "onaysız update yasak" politikasına dayandırılır: hareketli etiket, hangi imajın koştuğunu belirsizleştirir; 700 cihazda aynı imajı garanti etmenin yolu sabit sürüm etiketi (+ mümkünse digest) ve merkezi rollout'tur. + - Blueforce etkisi: Compose dosyalarında `image: …:latest` YASAK; sabit tag zorunlu. Digest pinleme (`@sha256:…`) PİLOT-2'de değerlendirilecek ek sıkılık adımı olarak açık soruya yazılır (komut/sözdizimi uydurulmadı, doküman yazımında Compose spec'ten ayrıca doğrulanacak). + +- **BULGU A4.5 — Watchtower kullanılmaz; iki bağımsız gerekçe.** + - İddia 1 (politika): Watchtower "otomatik imaj güncelleyici"dir — Blueforce "onaysız update yasak" kuralını doğrudan ihlal eder (LAB→PİLOT→WAVE rollout zincirini bypass eder). + - İddia 2 (upstream): `containrrr/watchtower` deposu ARŞİVLENMİŞTİR (`archived: true`, son push 2025-12-17, ~24.6k yıldız, 219 açık issue). + - Resmi URL: https://github.com/containrrr/watchtower + https://api.github.com/repos/containrrr/watchtower (HTTP 200, 2026-09-15) + - Blueforce etkisi: Watchtower mimariye GİRMEZ; güncellemeler Ansible/Semaphore zincirinden, onaylı ve dalgalı yapılır. Alternatif "unattended imaj çekme" fikri de aynı gerekçeyle reddedilir. + - Free notu: Docker CE ücretsizdir; Docker Business/Scout gibi ücretli özellikler mimaride yoktur ve gerekmez. + +--- + +## Karar etkisi özeti (Faz 2'ye girdi) + +| # | Karar yönü | Dayanak bulgu | +|---|-----------|---------------| +| 1 | Baz OS: Ubuntu Server 26.04.1+ LTS (GUI'siz kurulum, XFCE sonradan) | A1.1, A1.2 | +| 2 | Masaüstü: XFCE birincil; tam GNOME saha standardı değil | A1.3, A2.1 | +| 3 | Uzaktan bakım katmanı: xRDP (aktif upstream, ücretsiz) + systemd ile GUI aç/kapa | A2.2, A2.3 | +| 4 | `unattended-upgrades` imajda kapatılır (timer dahil), update zinciri LAB→…→PROD | A1.4 | +| 5 | WireGuard istemci: `PersistentKeepalive=25` + `wg-quick@wg0` enable | A3.1, A3.2 | +| 6 | Docker: UFW-bypass'a karşı localhost-bind standardı; log rotasyonu zorunlu; `unless-stopped`; `latest` yasak; Watchtower yok | A4.1–A4.5 | + +Açık sorular (tahmin yazılmaz, LAB/PİLOT'ta ölçülür): XFCE oturum başlatma satırı ve `bf-gui-on/off` kesin birim listesi (26.04.1 imajında); MEG log hacmine göre `max-size/max-file` ayarı; Turkcell NAT kopma sayacı ve keepalive ayarı; digest pinleme kararı. + + + +# B — Uzaktan Erişim Araştırması (Faz 1B) + +Tarih: 2026-09-15. Kapsam: 700 cihazlık filo için bağımsız uzaktan erişim kanalı. +Sözlük: hbbs = RustDesk ID/randevu (rendezvous) + sinyalizasyon sunucusu; +hbbr = röle (relay) sunucusu; VDS = sanal özel sunucu. + +> Not: Web arama altyapısı (Nous gateway) kapalı olduğundan bulgular +> curl ile çekilen resmi dokümantasyon sayfaları + GitHub API üzerinden +> doğrulandı. Cloudflare korumalı `anydesk.com` pazarlama sayfalarına curl +> ile erişilemedi; AnyDesk fiyatlandırması gerçek tarayıcı ile, lisans +> şartları ise Cloudflare korumasız resmi bilgi bankası +> (`support.anydesk.com`) üzerinden doğrulandı. + +## 1. RustDesk OSS self-hosted (hbbs + hbbr) + +- Mimari: istemciler önce hbbs'e kaydolur (ID, heartbeat, NAT tipi testi); + doğrudan P2P kurulamazsa trafik hbbr üzerinden rölelenir. İkisi de tek + `rustdesk/rustdesk-server` imajından `hbbs` / `hbbr` komutuyla çalışır. + Kaynak: https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ + (erişim 2026-09-15; sürüm bilgisi aşağıda). +- VDS kurulumu (Docker Compose, önerilen `network_mode: "host"`): + `rustdesk/rustdesk-server:latest` imajı, `./data:/root` kalıcı hacmi, + `unless-stopped` yeniden başlatma. Kaynak: + https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/docker/ + (erişim 2026-09-15). +- Güvenlik duvarı portları: hbbs 21115/TCP (NAT testi), 21116/TCP+UDP + (ID kaydı/heartbeat + TCP hole punching), 21118/TCP (web istemcisi, + opsiyonel); hbbr 21117/TCP (röle), 21119/TCP (web istemcisi, opsiyonel). + 21114/TCP yalnızca Pro web konsolunundur. 21118/21119 kullanılmıyorsa + kapalı tutulmalıdır (IP sahteciliği uyarısı dokümanda). Kaynak: aynı + Docker sayfası (erişim 2026-09-15). +- Pro gerektirmeyen özellikler (OSS'ta mevcut): sınırsız istemci, kendi + ID + röle altyapın, uçtan uca şifreli P2P/röleli oturum, dosya transferi, + katılımsız erişim (kalıcı parola), betikli toplu dağıtım (PowerShell / + batch / MSI / macOS bash / Linux bash). Toplu dağıtımda istemciye + self-hosted sunucu adresi + yapılandırma dizesi (config string) gömülür. + Kaynak: https://rustdesk.com/docs/en/self-host/client-deployment/ + (erişim 2026-09-15). +- Yalnızca Pro'da olanlar (OSS'ta YOK): web konsolu, API, OIDC/SSO, LDAP, + 2FA, erişim kontrolü, rol tabanlı yönetim, denetim logları, strateji/ + adres defteri, özel istemci üretici. Kaynak: + https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ + + https://rustdesk.com/pricing/ (erişim 2026-09-15; Individual ~11,88 + USD/ay, Basic ~23,88 USD/ay, yıllık faturalı). +- Sürümler (GitHub API, 2026-09-15): istemci `rustdesk/rustdesk` 1.4.9 + (2026-07-06); sunucu `rustdesk/rustdesk-server` 1.1.16 (2026-07-20). + Lisans: AGPL-3.0 (her iki repo). Yıldız: istemci ~123,6b, sunucu ~10,4b. + +### WireGuard çökerse bağımsız kanal çalışır mı? — EVET + +RustDesk istemcisi, yapılandırılan ID sunucusuna (VDS genel IP/DNS, +21115-21117) doğrudan internet üzerinden ulaşır; trafik WireGuard +arayüzünden geçme zorunluluğu yoktur (istemcide tünel içi bir adres değil, +VDS'nin gerçek adresi tanımlanır). hbbs/hbbr, istemcilerin gerçek gelen +IP'sini görmelidir (`--net=host` önerisinin gerekçesi de budur). Dolayısıyla +WireGuard servisi veya ana tünel çökse bile RustDesk kanalı ayakta kalır; +tek ortak bağımlılık VDS'nin kendisi ve genel internet erişimidir. Bu, +tasarım hedefiyle de tutarlıdır: "kendi randevu/röle sunucunu kur, verinin +kontrolü sende" (https://github.com/rustdesk/rustdesk README, +erişim 2026-09-15). Öneri: Blueforce mimarisinde RustDesk kanalını +WireGuard'a bağımlı yapmayacak şekilde, istemcide VDS genel adresi kullan; +`ALWAYS_USE_RELAY=Y` yalnız NAT sorunlu sahalarda değerlendir (gecikme/ +bant genişliği maliyetiyle). + +## 2. AnyDesk ticari lisans — 700 kurumsal cihazda ücretsiz kullanım yasal mı? + +**HAYIR.** Ücretsiz sürüm yalnızca kişisel kullanımı kapsar; ticari +kullanımda giden bağlantıyı başlatan istemcilerin lisanslı olması şarttır. +Resmi bilgi bankası: "Only outgoing clients that initiate connections to +other AnyDesk clients for commercial use need to have an AnyDesk license." +Kaynak: https://support.anydesk.com/docs/anydesk-licenses.md +(güncellenme 2025-10-30, erişim 2026-09-15). 700 cihazlık kurumsal filo +açıkça ticari kullanımdır; ücretsiz sürüm lisans ihlali olur. + +- Güncel fiyatlar (https://anydesk.com/en/pricing, tarayıcıyla erişim + 2026-09-15): Solo (100 yönetilen cihaz) → Standard ~34,32 USD/ay + (500 yönetilen cihaz, 20 kullanıcı) → Advanced ~72,72 USD/ay + (1000 yönetilen cihaz, 100 kullanıcı) → Ultimate (2000 cihazdan başlar, + ölçeklenebilir, teklifli). 700 cihaz Standard'ı aşar (500) → en az + Advanced gerekir; hepsi yıllık faturalı, KDV hariç. +- **Mimari karar: AnyDesk birincil/ücretsiz kanal olarak mimariden + ÇIKARILDI.** Gerekçe: lisans maliyeti + kapalı kaynak + self-hosted + seçeneğin yalnızca en üst (Ultimate/On-Premises) planda olması. +- Opsiyonel not (mimari dışı): müşteri sahasında zaten lisanslı AnyDesk + varsa, teknisyen erişimi için "opsiyonel installer modülü" olarak + paketlenebilir; ancak Blueforce imajında varsayılan kurulu gelmez, + envanter/lisans takibi müşteriye aittir. + +## 3. Dördüncü bağımsız ücretsiz erişim adayı: MeshCentral (önerilen) + +Tailscale/ZeroTier gibi freemium, merkezi kimlik isteyen çözümler elendi; +gerçek OSS + self-hosted olan MeshCentral seçildi. + +- Nedir: NodeJS tabanlı, web üzerinden bilgisayar yönetimi; aracı (agent) + kurulu cihazlar sunucuda görünür; web tabanlı uzak masaüstü + terminal + + dosya yönetimi dahili. Kaynak: https://meshcentral.com (erişim + 2026-09-15) + https://github.com/Ylianst/MeshCentral readme.md + (erişim 2026-09-15). +- Lisans: Apache-2.0 (package.json `license` alanı + LICENSE dosyası, + GitHub API ile doğrulandı 2026-09-15). Ticari kullanım dahil ücretsiz, + self-hosted'da kullanıcı/cihaz kotası yok. +- Sürüm: 1.2.5 (yayınlanma 2026-08-12, GitHub API 2026-09-15). Aktif bakımda + (son push 2026-09-14). Yıldız ~7,2b. +- Platformlar: sunucu ve aracı Windows/Linux/macOS/FreeBSD (resmi site). + Kurulum: `meshcentral` npm paketi üzerinden (`npm install -g meshcentral` + standardı), dokümantasyon: https://ylianst.github.io/MeshCentral/ + (README'de bağlantılı, erişim 2026-09-15). +- Blueforce rolü: RustDesk birincil grafik kanal; MeshCentral ikincil + yönetim düzlemi (envanter + terminal + dosya + web konsolu) olarak aynı + veya ayrı VDS'de koşar. O da kendi portları üzerinden internete çıkar; + WireGuard'dan bağımsız ikinci bir "ölüm-dışı" kanal sağlar. Not: ilk + kurulumda TLS (Let's Encrypt / ters vekil) ve 2FA açılmalıdır (resmi + dokümanda anlatılır). + +## 4. Karar özeti + +| Kanal | Rol | Lisans | WireGuard bağımsız mı? | +|---|---|---|---| +| RustDesk OSS (hbbs/hbbr, VDS) | Birincil grafik uzak erişim | AGPL-3.0, ücretsiz | EVET | +| MeshCentral (VDS) | İkincil yönetim (terminal/dosya/envanter) | Apache-2.0, ücretsiz | EVET | +| WireGuard | Ana güvenli ağ | OSS | — (kendisi) | +| AnyDesk | Mimariden çıkarıldı; yalnızca müşteri lisanslıysa opsiyonel installer modülü | Ticari lisans gerekli | — | + +Doğrulanamayan / sonraki faza kalan: RustDesk `id_ed25519.pub` anahtar +dağıtım otomasyonu detay adımları (Faz 2 kurulum betiklerinde ele alınacak); +MeshCentral gerçek VDS kurulum testi yapılmadı (Faz 2). + + + +# Faz 1C Araştırma — Fleet Yönetimi + Monitoring + Docs Platformu + +- Tarih: 2026-09-15 (doğrulama günü güncel resmi kaynaklar) +- Kapsam: 700 saha cihazı, %100 ücretsiz/self-hosted, sadelik öncelikli +- Kural: Her bulgunun resmi URL'si vardır. Sürüm/plan bilgisi değişebilir; karar öncesi URL'den teyit edilir. + +--- + +## 1. Fleet Yönetimi Karşılaştırması + +### 1.1. Adaylar ve lisans (resmi kaynak) + +| Araç | Lisans (OSS çekirdek) | Kaynak | +|---|---|---| +| Ansible (CLI / core) | GPL-3.0 | https://github.com/ansible/ansible | +| Semaphore UI Community | MIT, "$0, free forever, open source" | https://github.com/semaphoreui/semaphore — https://semaphoreui.com/pricing | +| AWX | Apache-2.0 | https://github.com/ansible/awx (README rozeti → https://github.com/ansible/awx/blob/devel/LICENSE.md) | +| Salt (Salt Project) | Apache-2.0 | https://github.com/saltstack/salt — https://docs.saltproject.io/ | +| Rudder (Core) | GPL-3.0, Core açık kaynak; Enterprise abonelik node başına/yıl ücretli | https://github.com/Normation/rudder — https://www.rudder.io/en/pricing/ | +| MeshCentral | Apache-2.0 | https://github.com/Ylianst/MeshCentral — https://meshcentral.com/ | + +### 1.2. Semaphore UI Community — ücretsiz/OSS özellikler ve Pro bağımlılığı yoktur kanıtı + +Resmi plan karşılaştırmasına göre (https://semaphoreui.com/pricing): + +**Community ($0) içinde VAR (ücretsiz, MIT, self-hosted):** +Ansible/Terraform/OpenTofu/Shell/Python/PowerShell çalıştırma; статиk/dosya/dinamik envanter yönetimi; +yeniden kullanılabilir Task Template; Cron zamanlama; Variable Groups; merkezi Key Store (şifreli, runtime enjeksiyon); +Global Runner ile dağıtık çalıştırma; proje-seviyesi erişim kontrolü + RBAC; Task/Activity/Server log geçmişi; +REST API; Webhook tetikleyiciler; Git depoları (GitHub/GitLab/Bitbucket); bildirimler (e-posta, Slack, Teams vb.); +AI iş akışları için MCP sunucusu; docs + topluluk desteği. + +**Yalnızca Pro/Enterprise'da VAR (Community'de YOK, ama 09-FLEET kapsamı için gerekli DEĞİL):** +izole Project Runner'lar; task özetleri; HashiCorp Vault/AWS/Azure/Devolutions gizli yönetimi; +2FA (TOTP); LDAP/AD; OIDC SSO; IdP grup eşleme; özel roller; dosya-tabanlı log dışa aktarma; +HA kurulum; air-gapped lisans; SLA destek. + +**Pro bağımlılığı yok — üç kanıt:** +1. Community "free forever and open source ... start automating without a paid subscription" — https://semaphoreui.com/pricing +2. Community kodu MIT lisanslı public repo — https://github.com/semaphoreui/semaphore +3. Kritik eşik: **Pro en fazla 500 managed node** destekler ("Up to 500 managed nodes ... For larger environments, +choose Enterprise"). 700 cihaz zaten Pro kapsamını aşıyor; ücretli yola girilse bile Pro yetmez, Enterprise gerekir. +Community'de lisansla dayatılan node kotası yoktur (self-hosted, kota lisans anahtarına bağlıdır; Community anahtarsız çalışır). +Sonuç: filo yönetimi Community + Ansible CLI ile kurulur; hiçbir iş akışı Pro/Enterprise özelliğine bağlanmaz +(Vault yerine Key Store, SSO yerine WireGuard arkası + SSH anahtarı, izole runner yerine Global Runner). + +**Community free limitleri (bilinerek kabul edilenler, Pro'ya taşınmadan çözümüyle):** +- OIDC/SSO, LDAP/AD, 2FA yok → çözüm: Semaphore yalnızca WireGuard arkasında, erişim VPN + SSH anahtarı ile. +- HashiCorp Vault/AWS/Azure gizli yönetimi yok → çözüm: yerleşik şifreli Key Store. +- İzole Project Runner yok → çözüm: Global Runner + proje-seviyesi erişim kontrolü. +- Dosya-tabanlı log dışa aktarma ve task özeti yok → çözüm: Task/Activity geçmişi UI + API'den çekilir, merkezi loga taşınır. +- HA yok → çözüm: Ansible CLI bağımsız çalışır; Semaphore çökse filo durmaz (günlük yedek + hızlı yeniden kurulum). + +### 1.3. Karşılaştırma — 700 cihaz sadeliği + +| Kriter | Ansible CLI | Semaphore Community | AWX | Salt | Rudder Community | MeshCentral | +|---|---|---|---|---|---|---| +| 700 cihaza ek yük | Yok (agent'sız, SSH) | Tek Go binary + DB, hafif | **Ağır: Kubernetes + AWX Operator zorunlu** | Minion agent + master PKI | Server + relay + agent (ağır) | Server + agent (uzaktan erişim ağırlıklı) | +| Öğrenme/işletme sadeliği | YAML playbook, en basit | Playbook'u UI'dan çalıştırma, basit | K8s bilgisi ister, karmaşık | State/pillar/grain modeli, orta-zor | Politika/direktif modeli, orta-zor | Filo otomasyonu değil, erişim aracı | +| Ücretsiz kapsam riski | Yok (GPL-3.0) | Yok (yukarıdaki kanıt) | **Sürüm riski: son release 2 Tem 2024, "releases paused during refactoring"** — https://github.com/ansible/awx | SaltStack Config (Broadcom) ücretli; OSS taraf güvenli ama master ölçekleme ister | Enterprise modülleri (relay, patch kampanyası, CVE yönetimi) ücretli — https://www.rudder.io/en/pricing/ | Yok (Apache-2.0), ama konfigürasyon yönetimi değil | +| 700 cihaz kanıtı | Binlerce node pratiği yaygın | Global Runner + dinamik envanter | Ölçeklenir ama K8s maliyetiyle | Ölçeklenir (master yatırımı) | Ölçeklenir (relay yatırımı) | 700 cihaza filo-komut modeli zayıf | + +- AWX kurulumu: https://docs.ansible.com/projects/awx/en/latest/ (Operator tabanlı) ve refactoring duyuruları: + https://www.ansible.com/blog/upcoming-changes-to-the-awx-project/ +- Salt docs: https://docs.saltproject.io/ — minion/master mimarisi 700 cihazda ek PKI ve master kapasite planı ister. +- MeshCentral kapsamı "remote monitoring and management" (erişim + envanter), Ansible-filo karşılığı değildir: + https://github.com/Ylianst/MeshCentral + +### 1.4. KARAR + +- **KARAR:** Birincil filo otomasyonu **Ansible CLI (SSH, agent'sız)**; operatör arayüzü **Semaphore UI Community + (self-hosted, MIT)**. Hiçbir playbook/iş akışı Pro/Enterprise özelliği kullanmaz. +- **GEREKÇE:** En düşük hareketli parça (SSH + YAML + tek Go binary); 700 cihazda ek agent/master/K8s/DB maliyeti yok; + Community ücretsizliği resmi fiyat sayfasıyla kanıtlı; 500-node Pro kotası 700 cihazda ücretli yolu zaten kapatıyor. +- **ALTERNATİF:** AWX (K8s yükü + release'ler duraklatıldı → elendi); Salt (agent + master PKI yükü → elendi); + Rudder Community (politika modeli güçlü ama relay/Enterprise modülleri ve öğrenme eğrisi → elendi); + MeshCentral (4. erişim kanalı adayı olarak değerlendirilir, filo otomasyonu olarak değil). +- **RİSK:** Semaphore tek nokta arızası → Ansible CLI her zaman bağımsız çalışır (UI çökse filo durmaz). + Community'de OIDC/2FA yok → Semaphore yalnızca WireGuard arkasında yayınlanır, admin erişimi VPN + SSH anahtarı ile. +- **MALİYET:** $0 (kendi VDS'i üzerinde). +- **LİSANS:** Ansible GPL-3.0, Semaphore MIT — ticari kullanımda lisans bedeli yok. + +--- + +## 2. Monitoring Karşılaştırması + +### 2.1. Adaylar ve lisans + +| Çözüm | Lisans | Kaynak | +|---|---|---| +| Prometheus + Node Exporter | Apache-2.0 / Apache-2.0 | https://github.com/prometheus/prometheus — https://github.com/prometheus/node_exporter — https://prometheus.io/docs/guides/node-exporter/ | +| Grafana OSS | AGPL-3.0 (self-hosted ücretsiz) | https://github.com/grafana/grafana — https://grafana.com/oss/ | +| Netdata (Agent OSS + Cloud) | Agent GPL-3.0; **Cloud Free yalnızca 5 node'a kadar, sonrası node başına ~$4.50/ay'dan başlar** | https://github.com/netdata/netdata — https://www.netdata.cloud/pricing/ | +| Uptime Kuma | MIT | https://github.com/louislam/uptime-kuma | + +### 2.2. Karşılaştırma — 700 cihaz + +| Kriter | Prometheus + Node Exporter + Grafana OSS | Netdata | Uptime Kuma | +|---|---|---|---| +| Merkezi metrik (700 node) | Sınırsız, ücretsiz (kendi Prometheus'u) | Merkezi görünüm Cloud'da; **ücretsiz kota 5 node** → 700 cihaz ücretli | Metrik yok; sadece uptime/nabız | +| Cihaz başı yük | node_exporter tek binary (~hafif, :9100) | Agent zengin ama 700'de Parent/Cloud planı gerekir | Yok (ajan gerektirmez; push/ping) | +| Kurulum sadeliği | Orta (prometheus.yml + systemd) | Kolay (agent), ama 700'de merkezi tasarım gerekir | Çok kolay (tek Docker konteyneri) | +| Alarm | Alertmanager / Grafana alerting (ücretsiz) | Cloud bildirimleri kotaya bağlı | 90+ bildirim servisi, ücretsiz, 20 sn aralık — https://github.com/louislam/uptime-kuma | +| Last-seen/çevrimdışı takibi | `up` metriği + `time() - timestamp` | Agent kalp atışı (kotalı merkez) | Push monitörü + ping grafik + sertifika bilgisi | + +### 2.3. 17 minimum metrik — nerede, nasıl toplanır + +Önerilen mimaride (Prometheus birincil + Uptime Kuma tamamlayıcı) her metriğin kaynağı: + +| # | Metrik | Prometheus + Node Exporter | Uptime Kuma | Netdata Community | +|---|---|---|---|---| +| 1 | online/offline | `up{job="node"}` (scrape başarısı) | HTTP/TCP/Ping monitörü UP/DOWN | agent kalp atışı | +| 2 | last seen (son görülme) | `time() - timestamp(up)` veya Pushgateway/nodeli `node_time_seconds` farkı | Push monitör zaman damgası | — (kotalı) | +| 3 | CPU % | `node_cpu_seconds_total` → rate | — | agent | +| 4 | RAM % | `node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes` | — | agent | +| 5 | Disk % (her mount) | `node_filesystem_avail_bytes / node_filesystem_size_bytes` | — | agent | +| 6 | Disk inode % | `node_filesystem_files_free / node_filesystem_files` | — | agent | +| 7 | Disk IO (read/write) | `node_disk_read_bytes_total`, `node_disk_written_bytes_total` | — | agent | +| 8 | Ağ up/down (B/sn) | `node_network_receive_bytes_total`, `node_network_transmit_bytes_total` | ping gecikme grafiği (kısmi) | agent | +| 9 | Load average (1/5/15) | `node_load1/5/15` | — | agent | +| 10 | Uptime / boot zamanı | `node_boot_time_seconds`, `node_time_seconds - node_boot_time_seconds` | — | agent | +| 11 | Swap kullanımı | `node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes` | — | agent | +| 12 | systemd failed üniteler | `node_systemd_unit_state{state="failed"}` (systemd collector) | — | agent | +| 13 | Sıcaklık (CPU/disk) | `node_hwmon_temp_celsius` / `node_thermal_zone_temp` (textfile ile de beslenebilir) | — | agent/eBPF | +| 14 | WireGuard handshake yaşı | textfile collector (`wg show all latest-handshakes` → `.prom`) | Ping monitörü (dolaylı: VPN çökerse DOWN) | — | +| 15 | Docker konteyner durumu (MEG dahil) | cAdvisor veya textfile (`docker ps` özeti → `.prom`) | Docker konteyner monitörü (var/yok) | agent | +| 16 | MEG süreç sağlığı | textfile (`pgrep`/health endpoint → `.prom`) + blackbox HTTP probu | HTTP(s) Keyword monitörü (MEG arayüzü kelime kontrolü) | — | +| 17 | Ping gecikmesi / paket kaybı | blackbox_exporter `probe_duration_seconds`, `probe_success` | Ping grafiği (yerleşik) | — | + +- Node Exporter collector'ları ve `:9100/metrics` davranışı: https://prometheus.io/docs/guides/node-exporter/ +- textfile collector (özel metrikler: WireGuard, Docker, MEG için standart yol): Node Exporter `--collector.textfile.directory` + (https://github.com/prometheus/node_exporter). +- Uptime Kuma yetenekleri (Docker konteyner, Push, HTTP Keyword, ping grafiği, 20 sn aralık, 90+ bildirim): + https://github.com/louislam/uptime-kuma +- Netdata ücretsiz kotası nedeniyle 3–17'nin merkezi takibi 700 cihazda ücrete takılır: + https://www.netdata.cloud/pricing/ + +**Free sürüm limit notları (monitoring):** +- Prometheus + Node Exporter: limitsiz ve kotasız (Apache-2.0); limit, donanım kapasitesidir (retention/kardinalite LAB'da ölçülür). +- Grafana OSS (AGPL-3.0, self-hosted ücretsiz): dashboard, alerting, LDAP/OAuth dahil; Enterprise'a özgü + (SAML, audit log, datasource izinleri, scaling desteği) özellikler kapsam dışı bırakıldı — 14-MONITORING yalnızca OSS + özelliği kullanır (https://grafana.com/oss/). +- Netdata: Agent OSS (GPL-3.0) tek başına kotasızdır, ancak 700 cihazın **merkezi** görünümü Cloud üzerinden + kotaya takılır (Free: 5 node; sonrası node başına ücret). Self-hosted Parent ile merkezileştirme mümkün olsa da + ek tasarım/işletme yükü getirir → elenme gerekçesi budur. +- Uptime Kuma (MIT): tüm özellikler ücretsiz, yerleşik limit yok; tek sınır, metrik toplamamasıdır (uptime nabzı verir). + +### 2.4. KARAR + +- **KARAR:** Birincil **Prometheus + Node Exporter + Grafana OSS** (tüm 17 metrik); tamamlayıcı **Uptime Kuma** + (saha-teknisyeni dostu UP/DOWN panosu + Push "last seen" + bildirimler). Netdata elendi. +- **GEREKÇE:** 17 metriğin tamamı yalnızca Prometheus yığınında ücretsiz ve kotasız toplanır (14/15/16 textfile ile); + Netdata Cloud Free 5 node kotası 700 cihazda ücrete düşer; Uptime Kuma metrik vermez ama en basit + çevrimdışı alarmını ve durum sayfasını verir — ikisi birbirini tamamlar, karmaşıklık artmaz (Uptime Kuma tek konteyner). +- **ALTERNATİF:** Yalnızca Uptime Kuma (metrik körlüğü → elendi); Netdata self-hosted Parent (merkezi tasarım + kota riski → elendi). +- **RİSK:** 700 node'lu Prometheus'ta saklama/kardinalite → kayıt aralığı 60 sn, retention 30–90 gün, etiket şeması + `BF-` standardında sabitlenir; federasyon/fazlı rollout ile büyütülür. +- **MALİYET:** $0 (kendi VDS'i üzerinde). +- **LİSANS:** Prometheus/Node Exporter Apache-2.0; Grafana OSS AGPL-3.0 (değişiklik yapılmadan self-host kullanılır); + Uptime Kuma MIT. + +--- + +## 3. Docs Platformu Karşılaştırması + +### 3.1. Adaylar ve lisans + +| Platform | Lisans | Kaynak | +|---|---|---| +| Docusaurus | MIT | https://github.com/facebook/docusaurus — https://docusaurus.io/ | +| MkDocs (+ Material) | BSD-2-Clause (MkDocs); Material for MkDocs MIT/Insiders ayrı | https://github.com/mkdocs/mkdocs — https://www.mkdocs.org/ — https://squidfunk.github.io/mkdocs-material/ | +| Wiki.js | AGPL-3.0 | https://github.com/requarks/wiki — https://js.wiki/ — https://docs.requarks.io/ | +| TriliumNext | AGPL-3.0, kişisel bilgi tabanı | https://github.com/TriliumNext/Trilium | + +### 3.2. Kriter matrisi (şartname: ücretsiz/OSS, self-hosted, Git, Markdown, AI-güncelleme, search, sidebar, kod, Mermaid, koyu tema, intranet) + +| Kriter | Docusaurus | MkDocs (+Material) | Wiki.js | TriliumNext | +|---|---|---|---|---| +| Ücretsiz/OSS | Evet (MIT) | Evet (BSD) | Evet (AGPL-3.0) | Evet (AGPL-3.0) | +| Self-hosted intranet | Evet (statik dosya, Nginx) | Evet (statik dosya, Nginx) | Evet (Node.js + PostgreSQL/SQLite) | Kısmi (tek kullanıcılı sunucu modu; ekip intraneti için uygun değil) | +| Git (sürümleme/diff/PR) | **Yerleşik: docs zaten Git'te** | **Yerleşik: docs zaten Git'te** | Harici: Git storage hedefi senkronu (ek yapılandırma) — https://docs.requarks.io/storage/git | Yerleşik değil (kendi sync formatı) | +| Markdown | Evet (MDX dahil) | Evet | Evet (+WYSIWYG/HTML editör) — https://js.wiki/ | Evet (zengin not, wiki değil) | +| AI ile güncelleme | **En kolay: düz `.md` dosyalar, ajan doğrudan düzenler** | **En kolay: düz `.md` dosyalar** | API/DB üzerinden (dolaylı) | API üzerinden (dolaylı) | +| Arama | Yerleşik (lokal search eklentisi, offline çalışır) | Yerleşik (Material search, offline) | Yerleşik (DB tabanlı) | Yerleşik (kişisel) | +| Sidebar/sürüm/i18n | Yerleşik (sidebar, versioning, i18n) | Eklenti/tema ile | Yerleşik navigasyon | Ağaç not yapısı | +| Kod blokları | Prism, sekmeli kod, başlıklı blok | Highlight + ek eklentiler | Evet | Evet | +| Mermaid | Resmi destek — https://docusaurus.io/docs/markdown-features/diagrams | Material ile destek — https://squidfunk.github.io/mkdocs-material/reference/diagrams/ | Modül ile (ek yapılandırma) | Sınırlı/eklenti | +| Koyu tema | Yerleşik (kullanıcı değiştirebilir) | Yerleşik (Material toggle) | Yerleşik (light/dark) — https://js.wiki/ | Tema var | +| Operasyonel yük | Derleme hattı (Node), DB yok | Derleme hattı (Python), DB yok | **Çalışan servis + DB (yedekleme/izleme ister)** | Düşük ama ekip akışına uygun değil | + +- Wiki.js özellik/editör/karanlık mod/DB seçenekleri: https://js.wiki/ +- Docusaurus diyagram/search/versioning/i18n: https://docusaurus.io/docs/markdown-features/diagrams +- MkDocs statik üretim: https://www.mkdocs.org/ + +**Free sürüm limit notları (docs):** Dört adayın da önerilen sürümü tam ücretsizdir — gizli kota yok: +Docusaurus MIT (tüm özellikler açık), MkDocs BSD + Material Community (ücretli "Insider" eklentileri kapsam dışı, +gerekli değil), Wiki.js AGPL-3.0 (tüm modüller dahil, Enterprise sürümü yok), TriliumNext AGPL-3.0. +Elendikleri için değil, free-limitleri olmadığı için not düşüldü; seçim ölçütü Git-yerleşiklik + AI-güncelleme + sıfır servis yüküdür. + +### 3.3. KARAR + +- **KARAR:** **Docusaurus** (repo'da `docs-site/` iskeleti; `docs/*.md` tek kaynak, site derlemesi CI'da). +- **GEREKÇE:** Şartnamedeki "AI-güncelleme" kriterini yalnızca Git-yerleşik Markdown çözer (ajan `.md`'yi doğrudan yazar, + review PR'dan geçer); Wiki.js'nin DB+servis yükü "basitlik" kuralını bozar ve Git akışını dolaylı hale getirir; + TriliumNext tek-kullanıcı bilgi tabanıdır, 26 dosyalık ekip dokümantasyonu için yayın akışı yoktur; + MkDocs'a karşı Docusaurus'u seçme nedeni: yerleşik sürümleme (LAB→PROD evrelerinde doküman sürümü), + yerleşik i18n (TR öncelikli, EN yolu açık), MDX ile runbook içi parametreli bileşenler ve React ekosisteminde + hazır intranet temaları — derleme maliyeti MkDocs ile eşdeğerdir. +- **ALTERNATİF:** MkDocs + Material (eşdeğer sadelikte güçlü ikinci; Docusaurus derleme hattı sorun çıkarırsa geçiş + maliyeti düşük çünkü kaynak yine düz Markdown). Wiki.js (DB yükü → elendi). TriliumNext (ekip akışı yok → elendi). +- **RİSK:** Node derleme hattı → `docs-site/` kilitli bağımlılıklar + CI'da derleme testi; derleme bozulsa bile + `docs/*.md` ham haliyle okunur (tek kaynak kaybı yok). +- **MALİYET:** $0. +- **LİSANS:** Docusaurus MIT; üretilen içerik bize ait. + +--- + +## 4. Özet — üç tek-öneri + +1. **Fleet:** Ansible CLI + Semaphore UI Community (Pro bağımlılığı yok — kanıt §1.2). +2. **Monitoring:** Prometheus + Node Exporter + Grafana OSS (17 metrik) + Uptime Kuma (UP/DOWN + bildirim). +3. **Docs:** Docusaurus (`docs/*.md` tek kaynak). + +## 5. Açık sorular (karar günlüğüne taşınacak) + +- Semaphore Community'de lisansla dayatılan node kotası yoktur varsayımı `pricing` + repo davranışıyla doğrulandı; + 700 node'lu gerçek yük testi LAB evresinde yapılır (Global Runner sayısı, eşzamanlılık). +- Prometheus retention/kardinalite hesabı (700 × 60 sn scrape) LAB'da ölçülür, 14-MONITORING'e yazılır. +- Uptime Kuma Push monitörleri için cihaz başına heartbeat betiği sözleşmesi 14-MONITORING'e yazılır. + + + + + + + + + + + + + + + + + + + + + + + + + + + +# Blueforce Linux Filo — 700 Saha Cihazı + +> %100 ücretsiz / self-hosted Linux filo mimarisi: başka bir DevOps mühendisinin sıfırdan kurabileceği 26 Türkçe dokümanın ana reposu. + +## Amaç + +AdBlue saha otomasyonundaki ~700 Windows mini PC'yi, lisans maliyeti olmayan, merkezi yönetilebilir, kesintisiz çalışan bir Linux filosuna taşımak. + +Bu repo üç şeyi tek yerde toplar: + +1. **Kararlar** — her teknik seçim için gerekçe, alternatif, risk, maliyet ve lisans notu. +2. **Kurulum ve işletme dokümanları** — `docs/` altında 26 Türkçe Markdown dosyası. +3. **Uygulanabilir iskelet** — `scripts/`, `ansible/`, `config/`, `monitoring/`, `rustdesk/` klasörleri (kod ve config Faz 3'te dolar). + +Öncelik sırası: **kesintisiz çalışma > veri kaybı yok > uzaktan erişim > bakım kolaylığı.** + +## 700 Cihaz Özeti + +| Konu | Özet | +|---|---| +| Cihaz sayısı | ~700 saha PC'si (bayi/istasyon yanında) | +| Cihaz kimliği | 8 haneli bayi no → Device ID `BF-`, hostname `bf-` (ör. `BF-12010193` / `bf-12010193`). Tüm sistemlerde aynı ID kullanılır. | +| Hedef OS | Ubuntu LTS (sürüm Faz 1'de resmi kaynaktan doğrulanacak) | +| Ağ | Turkcell modem arkası, CGNAT/NAT varsayımı; merkezle bağlantı WireGuard tüneli üzerinden | +| Uzaktan erişim | RustDesk OSS self-hosted (hbbs/hbbr) ana yol; SSH + WireGuard yedek yol; RDP/GUI ihtiyaca göre | +| Filo yönetimi | Ansible + Semaphore UI Community (aday; Faz 2'de karar) | +| İzleme | Prometheus/Node Exporter/Grafana OSS veya Uptime Kuma (Faz 2'de karşılaştırılacak) | +| Güncelleme politikası | **Onaysız update yasak** (apt, release upgrade, Docker image, MEG dahil). Rollout: LAB(2) → PILOT-1(5) → PILOT-2(20) → WAVE-1(50) → WAVE-2(100) → PRODUCTION | +| Doküman dili | Türkçe; komut, dosya, servis ve terim adları İngilizce kalır | + +## Docs Haritası (26 dosya) + +Tüm dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonuna uyar. + +| No | Dosya | Konu | +|---|---|---| +| 00 | `00-MASTER-PLAN.md` | Genel plan, fazlar, kapsam | +| 01 | `01-ARCHITECTURE.md` | Uçtan uca mimari | +| 02 | `02-DEVICE-NAMING-AND-INVENTORY.md` | `BF-` / `bf-` isimlendirme ve envanter | +| 03 | `03-UBUNTU-BASELINE.md` | Ubuntu taban kurulum | +| 04 | `04-GOLDEN-IMAGE-AND-PROVISIONING.md` | Golden image ve ilk kurulum | +| 05 | `05-ONE-CLICK-INSTALLER.md` | Tek-tık kurucu | +| 06 | `06-USERS-SSH-AND-PERMISSIONS.md` | Kullanıcılar, SSH, yetkiler | +| 07 | `07-REMOTE-ACCESS.md` | RustDesk + SSH + RDP erişim yolları | +| 08 | `08-WIREGUARD-AND-NETWORK.md` | WireGuard ve ağ | +| 09 | `09-FLEET-MANAGEMENT.md` | Ansible/Semaphore filo yönetimi | +| 10 | `10-UPDATE-AND-ROLLBACK-POLICY.md` | Güncelleme ve geri alma politikası | +| 11 | `11-SECURITY-HARDENING.md` | Güvenlik sertleştirme | +| 12 | `12-DOCKER-OPERATIONS.md` | Docker işletimi | +| 13 | `13-MEG-LINUX-ACCEPTANCE.md` | MEG Linux kabul kriterleri | +| 14 | `14-MONITORING-AND-HEALTH.md` | İzleme ve sağlık | +| 15 | `15-LOGGING.md` | Log toplama ve saklama | +| 16 | `16-POWER-LOSS-AND-AUTO-RECOVERY.md` | Elektrik kesintisi ve otomatik kurtarma | +| 17 | `17-BACKUP-RECOVERY-AND-REINSTALL.md` | Yedekleme, kurtarma, yeniden kurulum | +| 18 | `18-PILOT-AND-700-DEVICE-ROLLOUT.md` | Pilot ve 700 cihaz rollout | +| 19 | `19-TROUBLESHOOTING.md` | Sorun giderme | +| 20 | `20-FIELD-TECHNICIAN-RUNBOOK.md` | Saha teknisyeni runbook | +| 21 | `21-CENTRAL-ADMIN-RUNBOOK.md` | Merkezi yönetici runbook | +| 22 | `22-DOCUMENTATION-PLATFORM.md` | Doküman platformu (Docusaurus adayı) | +| 23 | `23-SECURITY-AND-LICENSE-AUDIT.md` | Güvenlik ve lisans denetimi | +| 24 | `24-DECISION-LOG.md` | Karar günlüğü (kaynak URL + sürüm + tarih ile) | +| 25 | `25-IMPLEMENTATION-ROADMAP.md` | Uygulama yol haritası | + +Şablon: `docs/_TEMPLATE.md`. Karar günlüğü: `docs/24-DECISION-LOG.md`. + +## Maliyet Kuralı + +- Hedef **%100 ücretsiz / self-hosted**'dir. +- Ücretli veya lisans kısıtlı her araç dokümanda açıkça işaretlenir ve **ücretsiz alternatifi** ile birlikte verilir. +- Varsayıma dayalı lisans kararı verilmez; resmi kaynak (resmi docs, GitHub, fiyat sayfası) URL + sürüm + tarih ile kaydedilir. +- Örnek: AnyDesk kararı resmi kaynaktan doğrulanmadan yazılmaz. + +## İsimlendirme Örneği + +Bayi no `12010193` için: + +- Device ID: `BF-12010193` +- Hostname: `bf-12010193` +- WireGuard peer adı, RustDesk ID etiketi, Ansible inventory adı, monitoring etiketi: hepsi aynı ID'yi kullanır. + +## Klasör Yapısı + +``` +. +├── README.md +├── SUMMARY.md # Faz 4'te üretilecek +├── docs/ # 26 doküman (00–25) + _TEMPLATE.md +├── scripts/ +│ ├── install/ # ilk kurulum scriptleri (Faz 3'te sözleşme/imza) +│ ├── maintenance/ # bakım scriptleri +│ ├── diagnostics/ # tanı scriptleri +│ └── recovery/ # kurtarma scriptleri +├── ansible/ +│ ├── inventory/ +│ ├── playbooks/ +│ └── roles/ +├── config/ +│ ├── ssh/ +│ ├── wireguard/ +│ ├── firewall/ +│ ├── docker/ +│ └── systemd/ +├── monitoring/ # izleme config ve panoları +├── rustdesk/ # hbbs/hbbr self-hosted config +├── docs-site/ # doküman sitesi iskeleti (Docusaurus adayı) +├── tests/ # doğrulama testleri +└── research/ # Faz 1 kaynak doğrulama notları +``` + +## Durum + +- [x] Faz 0: repo iskeleti + şablon +- [ ] Faz 1: kaynak doğrulama (resmi docs/GitHub, 10 kritik konu) +- [ ] Faz 2: mimari kararlar +- [ ] Faz 3: 26 dokümanın parti parti yazımı +- [ ] Faz 4: QA, tutarlılık denetimi, SUMMARY.md + + + +# Blueforce Linux — Yönetici Özeti (SUMMARY) + +> 700 saha cihazlık filonun kesin kararları, en büyük 10 operasyonel riski ve ücretsiz-bileşen listesi. +> Ayrıntı `docs/` dosyalarındadır; her cevap ilgili dokümana linklenir. Kararların kilit kaynağı +> [`docs/24-DECISION-LOG.md`](docs/24-DECISION-LOG.md) (K-01…K-14), izlenebilirlik tablosu Ek B'dedir (S-01…S-27). +> Mimari tek bakış: [`docs/01-ARCHITECTURE.md`](docs/01-ARCHITECTURE.md). Commit yok, FREE sürümler baz. + +--- + +## 1. Şartnamedeki 27 Sorunun Kesin Cevabı + +1. **Hangi Ubuntu? Server mı Desktop mı?** — Ubuntu **Server 26.04.1+ LTS**, GUI'siz minimal kurulum. + Desktop ISO'sunun 6 GB RAM şartı saha bütçesini zorlar; GUI sonradan eklenir. → [`03`](docs/03-UBUNTU-BASELINE.md) (K-01) +2. **GNOME mu XFCE mi?** — Cevap **GNOME** (Ubuntu Desktop standardı, kullanıcı kararı); XFCE elendi. + → [`03`](docs/03-UBUNTU-BASELINE.md) (K-02) +3. **Normal boot GUI mi terminal mi?** — Cihazlar **terminale** (`multi-user.target`) boot eder; + kapalı GUI = düşük RAM, düşük saldırı yüzeyi, öngörülebilir boot. → [`03`](docs/03-UBUNTU-BASELINE.md), [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) (K-03) +4. **GUI nasıl açılıp kapatılacak?** — `sudo bf-gui-on` / `sudo bf-gui-off` ile systemd üzerinden + (display-manager + `xrdp`/`xrdp-sesman`); varsayılan kapalıdır. → [`03`](docs/03-UBUNTU-BASELINE.md), [`07`](docs/07-REMOTE-ACCESS.md) (K-03) +5. **Cihaz isimlendirme standardı?** — 8 haneli bayi no → insan arayüzünde `BF-` (büyük), + makine adında `bf-` (küçük); 9 sistemde (hostname, WireGuard peer, RustDesk, MeshCentral, + Ansible, Prometheus, log, envanter, runbook) join anahtarıdır. Örn. `BF-12010193` / `bf-12010193`. → [`02`](docs/02-DEVICE-NAMING-AND-INVENTORY.md) (K-12) +6. **İlk PC hazırlığı nasıl?** — İmaj dondurulmadan **önce** `bf-hardware-inventory.sh` ile donanım + taraması (CPU/RAM/disk/NIC/GPU profilleri); profilsiz imaj sahada rastgele kırılır. → [`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md), [`02`](docs/02-DEVICE-NAMING-AND-INVENTORY.md) (K-09) +7. **Yeni PC kurulumu kaç adım?** — Teknisyen için **4 adım**: USB tak → Ubuntu kur → + `blueforce-install.sh` + 8 haneli bayi no → `bf-status` ile READY gör, çık (<15 dk aktif iş). → [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md), [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md) (K-09) +8. **Installer nasıl çalışacak?** — Tek giriş `blueforce-install.sh --dealer-id <8hane>`; **18 idempotent + modül** (precheck → dealer-id → paketler → GNOME → xRDP → SSH → WireGuard → Docker → MEG → RustDesk → + MeshCentral → monitoring → firewall → envanter → logging → boot → bootstrap → final-check), + çıkış kodu 0 = READY, format kapısı + onay ekranlı. → [`05`](docs/05-ONE-CLICK-INSTALLER.md) (K-09) +9. **WireGuard mimarisi?** — **Hub-spoke**: merkez hub (`10.8.0.1`), her cihaz spoke (peer adı `bf-`, + sabit `/32`); istemcide `PersistentKeepalive = 25` + `wg-quick@wg0` enable; SSH/RDP yalnızca tünel + içinden, dış dünyaya kapalı. → [`08`](docs/08-WIREGUARD-AND-NETWORK.md) (K-05) +10. **SSH güvenliği nasıl?** — `blueforce` bakım kullanıcısı + **anahtar zorunlu**, password auth kapalı, + root SSH kapalı; 700 cihaza tek private key **yayılmaz** — admin başına anahtar, Ansible ile merkezi + `authorized_keys` dağıtımı, uzun vadede SSH CA hedefi. → [`06`](docs/06-USERS-SSH-AND-PERMISSIONS.md) (K-13) +11. **RDP nasıl?** — **xRDP + GNOME**, yalnızca WireGuard üzerinden; internete açık RDP portu yoktur. + Grafik katman kapalıyken RDP açılmaz (`bf-gui-on` gerekir). → [`07`](docs/07-REMOTE-ACCESS.md) (K-03/K-05) +12. **RustDesk self-host neyi kapsar?** — **RustDesk OSS** istemci + `hbbs`/`hbbr` kendi VDS'imizde; + sınırsız istemci, E2E şifreli oturum, dosya transferi, katılımsız erişim — hepsi ücretsiz. İstemci VDS + genel adresine doğrudan çıktığı için **WireGuard çökse bile** kanal ayaktadır. → [`07`](docs/07-REMOTE-ACCESS.md) (K-04) +13. **AnyDesk ücretsiz kullanılabilir mi?** — **HAYIR.** Free sürüm yalnızca kişisel kullanımı kapsar; + 700 kurumsal cihaz ticari kullanımdır, lisanssız kullanım sözleşme ihlalidir. Mimari dışıdır; yalnızca + müşteride zaten ticari lisans varsa opsiyonel `--with-anydesk` modülü. → [`07`](docs/07-REMOTE-ACCESS.md), [`23`](docs/23-SECURITY-AND-LICENSE-AUDIT.md) (K-04) +14. **4. erişim kanalı ne?** — **MeshCentral** (self-hosted, Apache-2.0): ikincil yönetim düzlemi + (terminal/dosya/envanter), RustDesk gibi WireGuard-bağımsız. 4 kanal = SSH-over-WG, xRDP, RustDesk, MeshCentral. → [`07`](docs/07-REMOTE-ACCESS.md) (K-04) +15. **Fleet yönetimi ne?** — Birincil **Ansible CLI** (SSH, agent'sız); **15 standart işlem** + (`bf-ping`, `bf-status`, `bf-gui-on/off`, `reboot`…) playbook + Semaphore task şablonu olarak tanımlı; + serbest komut yalnızca acil prosedürle; hedefleme tek → grup → pilot → tüm-filo. → [`09`](docs/09-FLEET-MANAGEMENT.md) (K-06) +16. **Semaphore kullanmalı mıyız?** — Evet, **Semaphore UI Community** (self-hosted, MIT, $0) operatör + arayüzü olarak; Semaphore çökse Ansible CLI bağımsız çalışır. **Pro gerekmez** (zaten 500 node + sınırıyla 700 cihaza yetmez). Community'de OIDC/2FA/Vault yoktur → yalnızca WireGuard arkasından erişim. → [`09`](docs/09-FLEET-MANAGEMENT.md) (K-06) +17. **Monitoring ne?** — Birincil **Prometheus + Node Exporter + Grafana OSS** (17 metriğin tamamı, + özel metrikler textfile `.prom` ile); tamamlayıcı **Uptime Kuma** (teknisyen dostu UP/DOWN + Push + "last seen" + bildirim). Netdata Cloud Free 5 node kotasıyla elendi. → [`14`](docs/14-MONITORING-AND-HEALTH.md) (K-07) +18. **Update onayı nasıl, kim başlatır, başarısız olursa ne olur?** — **Onaysız update yasaktır.** + Başlatma yetkisi yalnızca **merkezi yöneticide** (asıl + vekil); her dalga Semaphore onay kapısından + geçer: LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD(~523). Başarısız dalga bir sonrakine geçmez, + yayılım durur, etkilenmiş dalga rollback zinciriyle geri alınır; acil yamalar da aynı zincirden + (kısaltılmış bekleme, kapılar kalkmaz) geçer. → [`10`](docs/10-UPDATE-AND-ROLLBACK-POLICY.md), [`21`](docs/21-CENTRAL-ADMIN-RUNBOOK.md) (K-11) +19. **`unattended-upgrades` ne olacak?** — Golden image'da **kapalı** (`20auto-upgrades` değerleri `0` + + `apt-daily*.timer` maskeleme); kapatılmazsa Ubuntu varsayılanı 700 cihazı ilk açılışta güncellemeye + kalkar. Kilit, kurucu final-check + monitoring metriğiyle denetlenir. → [`03`](docs/03-UBUNTU-BASELINE.md), [`10`](docs/10-UPDATE-AND-ROLLBACK-POLICY.md) (K-11) +20. **Docker politikası ne?** — Restart `unless-stopped`; **`latest` yasak** (sabit tag, digest P2'de + değerlendirilir); **Watchtower yasak**; log rotasyonu `daemon.json`'da zorunlu; yayımlanan portlar + UFW bypass'a karşı **`127.0.0.1`**'e bağlanır. → [`12`](docs/12-DOCKER-OPERATIONS.md), [`11`](docs/11-SECURITY-HARDENING.md) (K-10) +21. **Log disiplini + disk dolması engeli?** — journald kalıcı + tavanlı (`SystemMaxUse`/`MaxFileSec`), + Docker `json-file` rotasyonlu, 7 kaynak standardı + `bf-status`/`bf-diagnostics`/`bf-support-bundle` + (secret sızdırmaz); merkezi Loki/ELK ilk fazda yok (Faz-sonrası hedef). Rotasyonsuz log = kilitlenen + cihaz kuralıyla disk taşması mimari düzeyde engellenir. → [`15`](docs/15-LOGGING.md), [`12`](docs/12-DOCKER-OPERATIONS.md) (K-14/K-10) +22. **Elektrik kesintisi ve dönüşü?** — BIOS `Restore on AC Power Loss = Power On` ile cihaz kendiliğinden + açılır; systemd sıralı boot zinciri (network → `wg-quick` → docker → MEG → agent'lar) + `unless-stopped` + ile her katman kendiliğinden toparlanır, ek kurtarma servisi yoktur. → [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) (K-03/K-10) +23. **İnternet paketi bitimi ve dönüşü?** — Kota bitince tünel sessizce ölür (arıza değil, "çevrimdışı" + görünür; Kuma "last seen" alarmı + bayiye bildirim); kota dönüşünde `wg-quick` + keepalive **kendiliğinden** + yeniden handshake eder, ek işlem gerekmez. Kota-dostu keepalive (25 sn, PİLOT verisiyle ayarlanır). → [`08`](docs/08-WIREGUARD-AND-NETWORK.md) (K-05) +24. **PC bozulunca teknisyen ne yapar?** — **9 seviyeli kurtarma merdiveni** (L1 servis restart → … → + L8 USB ile sıfırdan imaj → L9 merkez inşası); cihaz "değiştirilebilir" sayılır, kimlik + config merkezden + yeniden basılır, cihaz-başı veri yedeği yoktur. Çoğu arıza 4 kanaldan biriyle uzaktan çözülür, sahaya + gidilmez. → [`17`](docs/17-BACKUP-RECOVERY-AND-REINSTALL.md), [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md) +25. **Golden image yöntemi ne?** — Kimliksiz zemin (Server 26.04.1+ minimal + update kapalı + kurucu) USB/autoinstall + ile kurulur; cihaz-spesifik kimlik (`bf-`) imajda **değil ilk açılışta** enjekte edilir. Clonezilla + bit-kopya yalnızca yedek yöntemdir (donanım-fragil). → [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md), [`05`](docs/05-ONE-CLICK-INSTALLER.md) (K-09) +26. **700 cihaz rollout nasıl?** — Üstel dalgalar: LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD(~523); + hiçbir dalga önceki çıkış kriterini sağlamadan başlamaz; halt kuralları (≥2 kritik arıza / >%10 çevrimdışı / + 2× kurulum süresi) yayılımı durdurur. → [`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md) (K-11) +27. **Docs platformu ne?** — **Docusaurus**; kaynak `docs/*.md` tek-kaynaktır, site CI'da derlenir, siteye + elle yazma yasaktır. Yedek: MkDocs + Material. → [`22`](docs/22-DOCUMENTATION-PLATFORM.md) (K-08) + +--- + +## 2. 700 İstasyonda 5 Yıllık En Büyük 10 Operasyonel Risk + +| # | Risk | Önlem | +|---|---|---| +| 1 | **Onaysız update kaçağı** — tek imajda `unattended-upgrades` açık unutulur, 700 cihaz kendiliğinden güncellenir. | Kurucu `18-final-check` kapısı + monitoring'de `unattended_upgrade_active` metriği (1 = ihlal alarmı); K-11 kilidi üç dosyada (03/10/12) denetlenir. | +| 2 | **Tek VDS ortak arıza noktası** — hub + hbbs/hbbr + MeshCentral aynı makinede; VDS giderse 2 bağımsız kanal da gider. | Yedek VDS planı + üç ayda bir "boş VDS'e sıfırdan kur" L9 tatbikatı; IaC Git'te, veriler günlük dosya yedeğinde ([`17`](docs/17-BACKUP-RECOVERY-AND-REINSTALL.md)). | +| 3 | **Heterojen/yeni-parti donanım imajı kırar** — 5 yılda donanım partileri değişir, sürücü eksiği sahada patlar. | Her yeni parti LAB profil matrisine girer; imaj, envanter taraması olmadan dondurulmaz ([`18`](docs/18-PILOT-AND-700-DEVICE-ROLLOUT.md), [`04`](docs/04-GOLDEN-IMAGE-AND-PROVISIONING.md)). | +| 4 | **Semaphore kapı bypass'ı** — acil bahanesiyle CLI'dan onaysız PROD run, dalga disiplini kağıtta kalır. | Semaphore dışı PROD run'ı yasak + denetim logu izlenir; onaysız run testi merkez test planında ([`21`](docs/21-CENTRAL-ADMIN-RUNBOOK.md)). | +| 5 | **Prometheus kardinalite/retention şişmesi** — 700 node × 5 yıl metrik ve log hacmi merkez diskini ve sorguları yorar. | 60 sn scrape, 30–90 gün retention, `BF-` etiket disiplini; PİLOT hacim ölçümüyle donanım siparişi, federasyon hedefi ([`14`](docs/14-MONITORING-AND-HEALTH.md)). | +| 6 | **Teknisyen hatası ölçeklenir** — yanlış bayi no (kimlik çakışması), READY görülmeden çıkış, eski USB ile kurulum. | Kurucuda format kapısı + sesli onay ekranı; kontrol listesi imzalanmadan iş kapanmaz; USB sürüm etiketi ([`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md), [`02`](docs/02-DEVICE-NAMING-AND-INVENTORY.md)). | +| 7 | **Turkcell hat/kota davranış değişimi** — CGNAT eşleme süresi veya kota politikası değişir, tüneller toplu düşer. | P1 7/24 kopma sayacıyla keepalive saha verisine bağlanır; Kuma "last seen" + paket-bitim bildirim akışı ([`08`](docs/08-WIREGUARD-AND-NETWORK.md)). | +| 8 | **Upstream lisans/kapsam daralması** — RustDesk/Grafana/Semaphore free kapsamı 5 yıl içinde değişebilir. | Yıllık lisans gözden geçirme + her ücretli-adayın ücretsiz karşılığı tabloda hazır; değişiklik 24-PR ile işletilir ([`23`](docs/23-SECURITY-AND-LICENSE-AUDIT.md)). | +| 9 | **SSH anahtar sızıntısı** — tek private key 700 cihazı açar; ayrılan personelin anahtarı unutulur. | Per-admin anahtar + Ansible merkezi dağıtım/iptal, paylaşılan key politikada yasak + denetim scripti; SSH CA Faz-sonrası hedef ([`06`](docs/06-USERS-SSH-AND-PERMISSIONS.md)). | +| 10 | **IaC drift + denenmemiş kurtarma** — Git'teki playbook gerçek merkezden sapar, L9 günü çalışmaz. | Üç aylık L9 tatbikatı + aylık yedek-doğrulama job'ı (açılabilirlik + hash); sonuç monitoring'e işlenir ([`17`](docs/17-BACKUP-RECOVERY-AND-REINSTALL.md)). | + +--- + +## 3. Gerçekten Tamamen Ücretsiz Olanlar (FREE katman, lisans bedeli $0) + +> Kapsam: yazılım lisansları. Merkezi VDS kirası altyapı maliyetidir, lisans değildir. + +| Bileşen | Sürüm/katman | Lisans | Kaynak | +|---|---|---|---| +| Ubuntu Server LTS | 26.04.1+ (ESM'e bel bağlanmaz) | Açık kaynak | [`03`](docs/03-UBUNTU-BASELINE.md) | +| GNOME | saha standardı (Ubuntu Desktop) | GPL/LGPL bileşenler | [`03`](docs/03-UBUNTU-BASELINE.md) | +| xRDP | v0.10.6.1 | Apache-2.0 | [`03`](docs/03-UBUNTU-BASELINE.md) | +| WireGuard + wg-quick | wireguard-tools 1.0.20250521-1ubuntu1 | OSS | [`08`](docs/08-WIREGUARD-AND-NETWORK.md) | +| OpenSSH / UFW | sistem paketleri | BSD / GPL bileşenler | [`06`](docs/06-USERS-SSH-AND-PERMISSIONS.md), [`11`](docs/11-SECURITY-HARDENING.md) | +| Docker CE | (Business/Scout mimaride yok) | Ücretsiz | [`12`](docs/12-DOCKER-OPERATIONS.md) | +| RustDesk istemci + sunucu (hbbs/hbbr) | 1.4.9 / 1.1.16, OSS | AGPL-3.0 | [`07`](docs/07-REMOTE-ACCESS.md) | +| MeshCentral | 1.2.5 | Apache-2.0 | [`07`](docs/07-REMOTE-ACCESS.md) | +| Ansible | CLI, agent'sız | GPL-3.0 | [`09`](docs/09-FLEET-MANAGEMENT.md) | +| Semaphore UI | Community ($0, free forever) | MIT | [`09`](docs/09-FLEET-MANAGEMENT.md) | +| Prometheus + Node Exporter | kotasız self-host | Apache-2.0 | [`14`](docs/14-MONITORING-AND-HEALTH.md) | +| Grafana | OSS (değişikliksiz self-host) | AGPL-3.0 | [`14`](docs/14-MONITORING-AND-HEALTH.md) | +| Uptime Kuma | tek konteyner | MIT | [`14`](docs/14-MONITORING-AND-HEALTH.md) | +| Docusaurus | statik site | MIT | [`22`](docs/22-DOCUMENTATION-PLATFORM.md) | +| systemd / journald | log + servis zinciri | LGPL bileşenler | [`15`](docs/15-LOGGING.md), [`16`](docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md) | + +**Ücretsiz DEĞİL / mimaride YOK (kullanılırsa lisans gerekir):** AnyDesk Free (ticari kullanımda ihlal) → [`23`](docs/23-SECURITY-AND-LICENSE-AUDIT.md); +RustDesk Pro (gerekmez); Semaphore Pro/Enterprise (500 node sınırı + ücretli); Netdata Cloud Free (5 node kotası); +Tailscale/ZeroTier ücretli kademeler; Ubuntu Pro/ESM (bel bağlanmaz). Tam hüküm tablosu [`23 §9`](docs/23-SECURITY-AND-LICENSE-AUDIT.md), +kaynak URL'ler [`24 Ek A`](docs/24-DECISION-LOG.md). + +--- + +## Ek: QA Doğrulama (2026-09-15) + +- **26 dosya mevcut:** `docs/00` … `docs/25` tamamı var (+ `_TEMPLATE.md`). +- **13 başlık:** 26 dosyanın tamamında §1–§13 sıralı ve eksiksiz. +- **Mermaid:** 26/26 dosyada ≥1 diyagram (03/19/21/23/24'e bu özetle birlikte eklendi). +- **BF-/bf- tutarlılığı:** insan arayüzü `BF-`, makine adı `bf-`; aykırı kullanım yok. +- **Ücretli sızıntı:** yok — ücretli geçen her satır elendi/hüküm/denetim bağlamında. +- **Onaysız-update kapısı:** yok — `unattended-upgrades`/`latest`/Watchtower yasakları 03/10/12'de ve + K-11'de kilitli; acil yama bile onay + rollback testinden geçer. + + + +.directory +.hermes/ + + +
diff --git a/rustdesk/README.md b/rustdesk/README.md new file mode 100644 index 0000000..d382228 --- /dev/null +++ b/rustdesk/README.md @@ -0,0 +1,48 @@ +# RustDesk Self-Hosted Server (hbbs + hbbr) + +OSS rendezvous + relay server for the Blueforce fleet. Field clients reach +the VDS public address directly over the internet, independent of WireGuard: +if the tunnel drops, this channel stays up (requires field internet + VDS up). + +## Prerequisites + +- A central VDS with a public DNS name (example: `rustdesk.example.com`). + Use your real DNS name; the value below is a placeholder. +- TCP/UDP ports 21115-21117 reachable from the internet (see port table in + `docker-compose.yml`). Keep 21118/21119 closed unless the web client is used. +- Docker Engine + Docker Compose plugin on the VDS. + +## Setup + +```bash +cd rustdesk +docker compose up -d +# First start generates the keypair in ./data (id_ed25519 + id_ed25519.pub). +cat data/id_ed25519.pub # <-- public key; embed in field clients +docker logs bf-hbbs # verify hbbs listening +docker logs bf-hbbr # verify hbbr listening +``` + +## Key distribution (field clients) + +1. Read the public key: `cat data/id_ed25519.pub` (single line, no newline issues). +2. Bake into the golden image / deployment script together with the server address: + `rustdesk.example.com` (replace with the real VDS DNS name). +3. Client config keys: `host = rustdesk.example.com`, `key = `. +4. Tag each client with its device identity `BF-` so the RustDesk address + book matches the Ansible inventory and MeshCentral names. +5. Key rotation: deploy the new key alongside the old one, confirm clients + connect, then retire the old key. + +## Health monitoring + +Point Uptime Kuma (see `../monitoring`) at: + +- TCP 21116 (hbbs heartbeat) +- TCP 21117 (hbbr relay) + +## Notes + +- `network_mode: host` is intentional; do not add port mappings. +- Data directory `./data` holds the keypair; back it up (see 17-BACKUP-RECOVERY). +- Never commit the private key (`data/id_ed25519`) to Git. diff --git a/rustdesk/docker-compose.yml b/rustdesk/docker-compose.yml new file mode 100644 index 0000000..4eaf5d8 --- /dev/null +++ b/rustdesk/docker-compose.yml @@ -0,0 +1,36 @@ +# RustDesk self-hosted server (hbbs + hbbr) on the central VDS. +# OSS edition (AGPL-3.0), unlimited clients, no license cost. +# Docs: https://rustdesk.com/docs/en/self-host/rustdesk-server-oss/ +# +# Port contract (VDS firewall must allow these from field-device internet): +# 21115/TCP hbbs NAT-type test +# 21116/TCP hbbs ID registration / heartbeat +# 21116/UDP hbbs TCP hole punching +# 21117/TCP hbbr relay traffic +# Optional, keep CLOSED unless the web client is used: +# 21118/TCP hbbs web client support +# 21119/TCP hbbr web client support +# Pro-only (not used here): +# 21114/TCP Pro web console +# +# network_mode: host is REQUIRED so hbbs/hbbr see the real client source +# IPs (rendezvous + relay decisions break behind Docker NAT). + +services: + hbbs: + image: rustdesk/rustdesk-server:1.1.16 + container_name: bf-hbbs + network_mode: host + restart: unless-stopped + volumes: + - ./data:/root + command: hbbs + + hbbr: + image: rustdesk/rustdesk-server:1.1.16 + container_name: bf-hbbr + network_mode: host + restart: unless-stopped + volumes: + - ./data:/root + command: hbbr diff --git a/scripts/diagnostics/bf-check-enrollment b/scripts/diagnostics/bf-check-enrollment new file mode 100755 index 0000000..37b275f --- /dev/null +++ b/scripts/diagnostics/bf-check-enrollment @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# bf-check-enrollment - ENROLLED level gate (fail closed). +# +# Proves the middle stage of the lifecycle without trusting the persisted phase +# alone: +# * the lifecycle state file is contract-valid and carries the central +# verification record produced by the validated enrollment response, +# * phase is ENROLLED or READY and enrollment_status is ENROLLED or COMPLETE +# (a skipped PROVISIONED_OFFLINE -> READY transition never passes), +# * the assigned WireGuard peer configuration exists and is root-only, +# * wg-quick@wg0 is enabled and active with the interface up and at least one +# handshake fresher than BF_WG_HANDSHAKE_MAX_AGE. +# +# A missing WireGuard handshake FAILS at this level (and at READY). It is only +# acceptable at the PROVISIONED_OFFLINE level, which bf-check-local owns. +# Without central evidence the gate never passes. +# +# Usage: bf-check-enrollment [--check] [--help] +# Exit status: 0 all enrollment gates passed, 1 at least one gate failed. +set -euo pipefail + +STATE_FILE="${BF_STATE_FILE:-${STATE_DIR:-/var/lib/blueforce}/state.json}" +WG_DIR="${WG_DIR:-/etc/wireguard}" +MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fails=0 +pass() { printf 'PASS: %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1"; fails=$((fails + 1)); } + +usage() { + cat <<'EOF' +Usage: bf-check-enrollment [--check] + +Verify the ENROLLED gates: contract-valid state with central verification +evidence, assigned WireGuard peer configuration, live tunnel and a fresh +handshake (BF_WG_HANDSHAKE_MAX_AGE, default 180s). + +Exit status: 0 all gates pass, 1 otherwise (fail closed without central evidence). +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) shift ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ "$MAX_AGE" =~ ^[1-9][0-9]*$ ]] || { echo 'ERROR: BF_WG_HANDSHAKE_MAX_AGE must be positive seconds' >&2; exit 2; } + +mode_bits() { stat -c '%a' "$1" 2>/dev/null || printf ''; } +wide_mode() { + local mode + mode="$(mode_bits "$1")" + [[ -n "$mode" ]] || return 1 + ((8#$mode & 8#077)) +} + +# 1. Lifecycle contract plus the central verification record. bf-enrollment-status +# refuses ENROLLED/READY states without well-formed central evidence. +if output="$("$SELF_DIR/bf-enrollment-status" 2>&1)"; then + pass 'lifecycle state contract valid (central verification record present)' +else + fail 'lifecycle state contract invalid or central verification evidence missing' + printf '%s\n' "$output" >&2 +fi +phase="$("$SELF_DIR/bf-enrollment-status" --field phase 2>/dev/null)" || phase='' +enrollment="$("$SELF_DIR/bf-enrollment-status" --field enrollment 2>/dev/null)" || enrollment='' + +if [[ "$phase" == 'ENROLLED' || "$phase" == 'READY' ]]; then + pass "lifecycle phase is $phase" +else + fail "lifecycle phase is ${phase:-missing}, expected ENROLLED or READY" +fi +if [[ "$enrollment" == 'ENROLLED' || "$enrollment" == 'COMPLETE' ]]; then + pass "enrollment_status is $enrollment" +else + fail "enrollment_status is ${enrollment:-missing}, expected ENROLLED or COMPLETE" +fi +if [[ ! -e "$STATE_FILE" ]]; then + fail "state file missing: $STATE_FILE" +elif wide_mode "$STATE_FILE"; then + fail "state file mode is $(mode_bits "$STATE_FILE"); the contract requires 0600" +else + pass 'state file permissions are root-only' +fi + +# 2. Assigned WireGuard peer configuration (written by bf-enroll, never by the +# offline installer and never carrying a server-supplied private key). +if [[ -s "$WG_DIR/wg0.conf" ]]; then + pass "assigned WireGuard configuration present: $WG_DIR/wg0.conf" + if wide_mode "$WG_DIR/wg0.conf"; then + fail "wg0.conf mode is $(mode_bits "$WG_DIR/wg0.conf"); it must be root-only (0600)" + else + pass 'wg0.conf is root-only' + fi + for field in '\[Interface\]' '^PrivateKey = ' '^Address = ' '\[Peer\]' '^PublicKey = ' '^Endpoint = ' '^AllowedIPs = '; do + if grep -Eq "$field" "$WG_DIR/wg0.conf"; then + pass "wg0.conf declares ${field//\\/}" + else + fail "wg0.conf is missing ${field//\\/}" + fi + done +else + fail "assigned WireGuard configuration missing: $WG_DIR/wg0.conf" +fi + +# 3. Runtime tunnel state. +if systemctl is-enabled --quiet wg-quick@wg0 2>/dev/null; then + pass 'wg-quick@wg0 is enabled' +else + fail 'wg-quick@wg0 is not enabled' +fi +if systemctl is-active --quiet wg-quick@wg0 2>/dev/null; then + pass 'wg-quick@wg0 is active' +else + fail 'wg-quick@wg0 is not active' +fi +if command -v ip >/dev/null 2>&1 && ip link show wg0 >/dev/null 2>&1; then + pass 'wg0 interface is present' +else + fail 'wg0 interface is missing' +fi + +# 4. Fresh handshake: proof the assigned peer actually reaches central. +if ! command -v wg >/dev/null 2>&1; then + fail 'wg tool missing: handshake cannot be proven' +elif wg show wg0 latest-handshakes 2>/dev/null | awk -v now="$(date +%s)" -v max="$MAX_AGE" '$2 > 0 && now - $2 <= max { ok = 1 } END { exit !ok }'; then + pass "WireGuard handshake is fresher than ${MAX_AGE}s" +else + fail "WireGuard handshake missing or older than ${MAX_AGE}s" +fi + +if [[ "$fails" -ne 0 ]]; then + printf 'bf-check-enrollment: FAILED (%s enrollment gate(s)); device stays ENROLLED or PROVISIONED_OFFLINE\n' "$fails" + exit 1 +fi +printf 'bf-check-enrollment: enrollment gates PASS\n' diff --git a/scripts/diagnostics/bf-check-local b/scripts/diagnostics/bf-check-local new file mode 100755 index 0000000..fbe19a9 --- /dev/null +++ b/scripts/diagnostics/bf-check-local @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# bf-check-local - PROVISIONED_OFFLINE level gate (local proof only). +# +# This gate must PASS on a device that has never reached the internet: +# * it performs no DNS, HTTP or central call at all, +# * a missing wg0.conf or a missing WireGuard handshake is reported as WARN +# because enrollment has not happened yet - it is never a failure here, +# * WireGuard handshake freshness belongs to bf-check-enrollment (ENROLLED) +# and bf-check-ready (READY) only. +# +# Usage: bf-check-local [--check] [--help] +# --check accepted for symmetry with the installer modules; checks never +# mutate anything, so --check and the default are identical. +# +# Exit status: 0 every local gate passed, 1 at least one gate failed. +# Never prints secrets. +# +set -euo pipefail + +STATE_FILE="${BF_STATE_FILE:-${STATE_DIR:-/var/lib/blueforce}/state.json}" +WG_DIR="${WG_DIR:-/etc/wireguard}" +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +fails=0 +pass() { printf 'PASS: %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1"; fails=$((fails + 1)); } +warn() { printf 'WARN: %s\n' "$1"; } +info() { printf 'INFO: %s\n' "$1"; } + +usage() { + cat <<'EOF' +Usage: bf-check-local [--check] + +Verify the local, pre-enrollment (PROVISIONED_OFFLINE) gates: + * lifecycle state file present and contract-valid, + * WireGuard device keypair present, root-only and self-consistent, + * xrdp and xrdp-sesman enabled and active, + * default systemd target is multi-user.target. + +No internet, no WireGuard handshake and no central evidence is required. +Exit status: 0 all gates pass, 1 at least one gate failed. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) shift ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +mode_bits() { stat -c '%a' "$1" 2>/dev/null || printf ''; } +# True when a file is readable/writable beyond its owner. +wide_mode() { + local mode + mode="$(mode_bits "$1")" + [[ -n "$mode" ]] || return 1 + ((8#$mode & 8#077)) +} + +info 'local-only gate: no internet, no WireGuard handshake and no central evidence is required' + +# 1. Lifecycle state contract. bf-enrollment-status owns the validation rules. +if "$SELF_DIR/bf-enrollment-status" >/dev/null 2>&1; then + pass 'lifecycle state file present and contract-valid' +else + fail 'lifecycle state file missing or contract-invalid (run bf-enrollment-status)' +fi +phase="$("$SELF_DIR/bf-enrollment-status" --field phase 2>/dev/null)" || phase='' +if [[ "$phase" =~ ^(PROVISIONED_OFFLINE|ENROLLED|READY)$ ]]; then + pass "lifecycle phase is $phase" +else + fail 'lifecycle phase is missing or invalid' +fi +if [[ -r "$STATE_FILE" ]]; then + pass "state file readable: $STATE_FILE" +else + fail "state file not readable: $STATE_FILE" +fi +if wide_mode "$STATE_FILE"; then + warn "state file mode is $(mode_bits "$STATE_FILE"); the contract requires 0600" +fi + +# 2. WireGuard device identity. The private key is a device-only secret: it must +# exist, be unreadable by group/other, and belong to the public key that +# enrollment publishes. +if [[ -s "$WG_DIR/privatekey" ]]; then + pass 'WireGuard private key present' +else + fail "WireGuard private key missing or empty: $WG_DIR/privatekey" +fi +if [[ -e "$WG_DIR/privatekey" ]]; then + if wide_mode "$WG_DIR/privatekey"; then + fail "WireGuard private key mode is $(mode_bits "$WG_DIR/privatekey"); it must be root-only (0600)" + else + pass 'WireGuard private key is root-only' + fi +fi +if [[ -s "$WG_DIR/publickey" ]]; then + pass 'WireGuard public key present' +else + fail "WireGuard public key missing or empty: $WG_DIR/publickey" +fi +if command -v wg >/dev/null 2>&1 && [[ -s "$WG_DIR/privatekey" && -s "$WG_DIR/publickey" ]]; then + derived="$(wg pubkey < "$WG_DIR/privatekey" 2>/dev/null | tr -d '[:space:]')" || derived='' + if [[ -n "$derived" && "$derived" == "$(tr -d '[:space:]' < "$WG_DIR/publickey")" ]]; then + pass 'WireGuard keypair is self-consistent' + else + fail 'WireGuard public key does not match the local private key' + fi +else + warn 'wg tool unavailable: keypair consistency not verified' +fi + +# 3. Local access services (the field technician path must work offline). +if systemctl is-enabled --quiet xrdp 2>/dev/null && systemctl is-active --quiet xrdp 2>/dev/null; then + pass 'xrdp enabled and active' +else + fail 'xrdp is not enabled and active' +fi +if systemctl is-enabled --quiet xrdp-sesman 2>/dev/null && systemctl is-active --quiet xrdp-sesman 2>/dev/null; then + pass 'xrdp-sesman enabled and active' +else + fail 'xrdp-sesman is not enabled and active' +fi +if [[ "$(systemctl get-default 2>/dev/null)" == 'multi-user.target' ]]; then + pass 'default target is multi-user.target' +else + fail "default target is $(systemctl get-default 2>/dev/null || echo unknown), expected multi-user.target" +fi + +# 4. Post-enrollment artifacts are explicitly out of scope here. +if [[ -e "$WG_DIR/wg0.conf" ]]; then + info 'wg0.conf present (enrollment already ran); handshake belongs to bf-check-enrollment' +else + warn 'wg0.conf absent: not enrolled yet, which is expected before enrollment' +fi + +if [[ "$fails" -ne 0 ]]; then + printf 'bf-check-local: FAILED (%s local gate(s))\n' "$fails" + exit 1 +fi +printf 'bf-check-local: local gates PASS (offline-safe, no central evidence required)\n' diff --git a/scripts/diagnostics/bf-check-ready b/scripts/diagnostics/bf-check-ready new file mode 100755 index 0000000..972c6d0 --- /dev/null +++ b/scripts/diagnostics/bf-check-ready @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# bf-check-ready - READY level gate. +# +# READY proof is always re-derived from current independent checks; a persisted +# READY value is never trusted and never used as evidence. All of the following +# must pass in this order: +# 1. bf-check-local - local provisioning and access services, +# 2. bf-check-enrollment - ENROLLED stage: state contract, central +# verification record, assigned WireGuard peer, +# live tunnel and a fresh handshake, +# 3. bf-remote-status --check - xRDP, RustDesk, optional Mesh and every +# central channel (monitoring, remote, management) +# marked verified. +# +# Without evidence from every central channel this gate fails closed: a device +# with WireGuard only stays ENROLLED, it never becomes READY. +# +# Usage: bf-check-ready [--check] [--help] +# Exit status: 0 READY proof complete, 1 at least one gate failed. +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: bf-check-ready [--check] + +Re-derive the READY proof: local gates, ENROLLED gates and complete central +channel verification (bf-remote-status --check). Nothing is written. + +Exit status: 0 READY proof complete, 1 otherwise. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) shift ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +"$SELF_DIR/bf-check-local" || { printf 'bf-check-ready: FAILED at local gates (device stays PROVISIONED_OFFLINE)\n'; exit 1; } +"$SELF_DIR/bf-check-enrollment" || { printf 'bf-check-ready: FAILED at enrollment gates (device stays ENROLLED)\n'; exit 1; } +"$SELF_DIR/bf-remote-status" --check || { printf 'bf-check-ready: FAILED at channel verification (device stays ENROLLED)\n'; exit 1; } + +printf 'bf-check-ready: READY proof complete (local health plus every central channel verified)\n' diff --git a/scripts/diagnostics/bf-diagnostics b/scripts/diagnostics/bf-diagnostics new file mode 100755 index 0000000..43eef57 --- /dev/null +++ b/scripts/diagnostics/bf-diagnostics @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# +# bf-diagnostics - Deep health check for a Blueforce field terminal. +# +# Detailed version of bf-status: SMART, journal errors, docker ps, +# WireGuard state, unattended-upgrades lock, sockets, inodes, textfiles. +# Never prints secrets, keys, or passwords (handshake keys are redacted). +# +# Install: install -m 0755 bf-diagnostics /usr/local/bin/bf-diagnostics +# Usage: bf-diagnostics [--help] +# +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bf-diagnostics [--help] + +Run deep checks: SMART, journal errors, docker ps, wg show, +unattended-upgrades lock state, sockets, inodes, textfile collectors. + +Options: + --help Show this help and exit +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +elif [[ $# -gt 0 ]]; then + echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 +fi + +set +e # report, don't abort + +sec() { printf '\n### %s\n' "$*"; } + +sec "HOST" +hostname; uptime; systemctl get-default 2>/dev/null || echo "target: unknown" + +sec "SMART" +if command -v smartctl >/dev/null 2>&1; then + for dev in /dev/sd? /dev/nvme?n1; do + [[ -b "${dev}" ]] || continue + echo "--- ${dev} ---" + smartctl -H -A "${dev}" 2>/dev/null | grep -Ei 'health|reallocated|pending|uncorrect|wear|temperature|power_on' || echo "no SMART attributes parsed" + done +else + echo "smartctl not installed; skipping SMART." +fi + +sec "JOURNAL ERRORS (last 24h, top 20)" +if command -v journalctl >/dev/null 2>&1; then + journalctl --since "24 hours ago" -p err --no-pager -o short 2>/dev/null | tail -20 || echo "no journal access" +else + echo "journalctl not available." +fi + +sec "FAILED SYSTEMD UNITS" +if command -v systemctl >/dev/null 2>&1; then + systemctl --failed --no-pager 2>/dev/null || echo "no failed units" +else + echo "systemctl not available." +fi + +sec "DOCKER PS" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null || echo "docker ps failed" + echo "--- restart counts ---" + docker ps --format '{{.Names}} restarts={{.RestartCount}}' 2>/dev/null || true +else + echo "docker daemon not reachable." +fi + +sec "WIREGUARD (keys redacted)" +if command -v wg >/dev/null 2>&1; then + wg show 2>/dev/null | sed -E 's/(private-key|public-key|preshared-key): .*/\1: /' || echo "no WireGuard interfaces" + echo "--- latest handshakes ---" + wg show all latest-handshakes 2>/dev/null || echo "no handshake data" +else + echo "wg tool not installed." +fi + +sec "UNATTENDED-UPGRADES LOCK (must be DISABLED)" +grep -H . /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null || echo "20auto-upgrades file missing" +for t in apt-daily.timer apt-daily-upgrade.timer; do + printf '%s: %s\n' "$t" "$(systemctl is-enabled "$t" 2>/dev/null || echo unknown)" +done + +sec "SOCKETS (listening)" +ss -ltnp 2>/dev/null || ss -ltn 2>/dev/null || echo "ss not available" + +sec "DISK + INODES" +df -h / /var 2>/dev/null +df -i / /var 2>/dev/null | head -5 + +sec "TEXTFILE COLLECTORS" +ls -l /var/lib/node_exporter/textfile/ 2>/dev/null || echo "textfile dir missing" +for f in /var/lib/node_exporter/textfile/*.prom; do + [[ -f "$f" ]] || continue + echo "--- $f ---"; head -10 "$f" +done + +sec "SECURITY UPDATES PENDING" +if command -v apt >/dev/null 2>&1; then + apt list --upgradable 2>/dev/null | grep -ci security || echo "0 security updates (or apt list unavailable)" +else + echo "apt not available." +fi + +sec "REBOOT REQUIRED" +if [[ -f /var/run/reboot-required ]]; then echo "YES"; cat /var/run/reboot-required.pkgs 2>/dev/null || true +else echo "NO"; fi + +echo +echo "Diagnostics complete. For remote triage run: bf-support-bundle" +exit 0 diff --git a/scripts/diagnostics/bf-enroll b/scripts/diagnostics/bf-enroll new file mode 100755 index 0000000..c0d0ea8 --- /dev/null +++ b/scripts/diagnostics/bf-enroll @@ -0,0 +1,609 @@ +#!/usr/bin/env bash +# +# bf-enroll - one-time enrollment client for a PROVISIONED_OFFLINE device. +# +# Security contract (docs/29-DEVICE-ENROLLMENT.md, provisioning/enrollment/README.md): +# * The one-time token is read from stdin (--token-stdin) or from a root-only +# single-use file staged for the boot timer (--systemd / --stage). It is +# never taken from argv, never logged, never written into the lifecycle +# state, a support bundle or removable media. +# * The WireGuard private key never leaves the device. The request carries only +# the public key plus non-secret hardware/release facts; the WireGuard +# configuration is assembled locally from the local private key and the +# validated peer fields of the response. A response that tries to inject a +# private key or an unexpected field is rejected. +# * The idempotency key is derived deterministically from +# provisioning_id + BF- + public key, so central can deduplicate retries +# of the same device/release while the token itself stays single-use. +# * Every state update is an atomic tmp+rename write with mode 0600 and a +# forward-only transition check under a lock. +# +# Lifecycle (forward-only, never skipping a stage): +# (none) -> PROVISIONED_OFFLINE -> ENROLLED -> READY +# * This script performs PROVISIONED_OFFLINE -> ENROLLED only. +# * ENROLLED may be re-applied with --reenroll (idempotent re-enrollment); +# the phase never moves backwards. +# * READY never returns to ENROLLED; re-provision from the offline media. +# +# Failure handling: a failed request or a failed WireGuard activation rolls the +# configuration back and leaves the lifecycle at its prior state (PENDING or +# PROVISIONED_OFFLINE). Nothing reboots and nothing retries in a loop; the +# bounded retry lives in blueforce-enroll.service. +# +# Usage: +# bf-enroll --token-stdin [--reenroll] [--check] immediate enrollment +# bf-enroll --token-stdin --stage [--check] stage the token, enroll later +# bf-enroll --systemd [--check] attempt used by the boot timer +# +# Environment: +# BF_ENROLLMENT_ENDPOINT HTTPS enrollment endpoint (central configuration) +# BF_ENROLLMENT_TIMEOUT connect/max seconds per attempt (default 20) +# BF_ENROLLMENT_TOKEN_FILE staged token path (default /enrollment.token) +# BF_ENROLLMENT_TOKEN_MAX_AGE staged token TTL in seconds (default 1800) +# BF_STATE_FILE / STATE_DIR / WG_DIR test and installer overrides +# +set -euo pipefail +umask 077 + +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +STATE_FILE="${BF_STATE_FILE:-$STATE_DIR/state.json}" +WG_DIR="${WG_DIR:-/etc/wireguard}" +ENDPOINT="${BF_ENROLLMENT_ENDPOINT:-}" +TIMEOUT="${BF_ENROLLMENT_TIMEOUT:-20}" +TOKEN_FILE="${BF_ENROLLMENT_TOKEN_FILE:-$STATE_DIR/enrollment.token}" +TOKEN_MAX_AGE="${BF_ENROLLMENT_TOKEN_MAX_AGE:-1800}" + +MODE='' +STAGE=0 +REENROLL=0 +CHECK_MODE=0 +TOKEN='' +HTTP_CODE='' +REQUEST_FILE='' +RESPONSE_FILE='' +STAGED_CONFIG='' +BACKUP_CONFIG='' +CV_FILE='' + +usage() { + cat <<'EOF' +Usage: bf-enroll --token-stdin [--reenroll] [--check] + bf-enroll --token-stdin --stage [--check] + bf-enroll --systemd [--check] + +Obtain the hub WireGuard peer configuration for a PROVISIONED_OFFLINE device +and record the ENROLLED lifecycle phase. + +Token sources (a token is never accepted from argv and never logged): + --token-stdin read the one-time token once from stdin and enroll now + --stage store the stdin token as a root-only single-use file + (mode 0600, TTL BF_ENROLLMENT_TOKEN_MAX_AGE) for the boot timer + --systemd attempt used by blueforce-enroll.service: PENDING while no + token is staged, transient failures exit non-zero so the + bounded Restart= can retry, definitive rejections discard the + staged token + +Options: + --reenroll allow re-applying enrollment on a device already in ENROLLED + (the phase never moves backwards; READY is refused) + --check dry run: validate configuration and state, print the planned + actions, write nothing and send nothing + --help show this help + +Exit status: 0 success (or nothing to do), 1 failure, 2 usage error. +EOF +} + +die() { printf 'bf-enroll: %s\n' "$*" >&2; exit 1; } +note() { printf 'bf-enroll: %s\n' "$*"; } + +# A lifecycle precondition that cannot be met. Interactive runs fail loudly, +# the boot attempt reports PENDING and exits 0 so it never becomes a retry loop. +gate_block() { + if [[ "$MODE" == 'systemd' ]]; then + printf 'bf-enroll: PENDING: %s\n' "$*" >&2 + exit 0 + fi + die "$*" +} + +cleanup() { + unset TOKEN HTTP_CODE REQUEST_FILE RESPONSE_FILE STAGED_CONFIG BACKUP_CONFIG CV_FILE + return 0 +} +trap cleanup EXIT + +while [[ $# -gt 0 ]]; do + case "$1" in + --token-stdin) + [[ -z "$MODE" ]] || { echo 'ERROR: only one token source can be selected' >&2; exit 2; } + MODE='stdin'; shift ;; + --systemd) + [[ -z "$MODE" ]] || { echo 'ERROR: only one token source can be selected' >&2; exit 2; } + MODE='systemd'; shift ;; + --stage) STAGE=1; shift ;; + --reenroll) REENROLL=1; shift ;; + --check) CHECK_MODE=1; shift ;; + --help|-h) usage; exit 0 ;; + *) printf 'ERROR: unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done +[[ -n "$MODE" ]] || { usage >&2; exit 2; } +if [[ "$STAGE" -eq 1 && "$MODE" != 'stdin' ]]; then + echo 'ERROR: --stage applies to --token-stdin only' >&2 + exit 2 +fi + +[[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || die 'BF_ENROLLMENT_TIMEOUT must be positive seconds' +[[ "$TOKEN_MAX_AGE" =~ ^[1-9][0-9]*$ ]] || die 'BF_ENROLLMENT_TOKEN_MAX_AGE must be positive seconds' + +validate_endpoint() { + [[ "$ENDPOINT" =~ ^https://[^[:space:]/]+(/[^[:space:]]*)?$ ]] || die 'BF_ENROLLMENT_ENDPOINT must be a non-placeholder HTTPS URL' + case "$ENDPOINT" in + *example*|*invalid*|*'<'*|*'>'*) die 'BF_ENROLLMENT_ENDPOINT must be a non-placeholder HTTPS URL' ;; + esac +} + +validate_token() { + local value="$1" + [[ -n "$value" ]] || return 1 + ((${#value} >= 8 && ${#value} <= 4096)) || return 1 + [[ "$value" != *[[:space:]]* ]] || return 1 + # [:graph:] is printable without space: no control characters, no blanks. + [[ "$value" =~ ^[[:graph:]]+$ ]] || return 1 +} + +mode_bits() { stat -c '%a' "$1" 2>/dev/null || printf ''; } + +# --- lifecycle state --------------------------------------------------------- + +DEALER_ID="$(tr -d '[:space:]' < "$STATE_DIR/dealer-id" 2>/dev/null || true)" +[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || gate_block 'dealer identity is missing or invalid' +DEVICE_ID="BF-$DEALER_ID" + +# read_state emits PHASE=/PROVISIONING_ID=/IDEMPOTENCY_KEY= for a usable state, +# or ERROR=. The idempotency key is stable across retries so central can +# deduplicate, while the one-time token remains single-use. +read_state() { + python3 - "$STATE_FILE" "$DEVICE_ID" "$WG_DIR/publickey" <<'PY' +import hashlib +import json +import sys + +path, device, public_key_path = sys.argv[1], sys.argv[2], sys.argv[3] +VALID_STATES = ('PROVISIONED_OFFLINE', 'ENROLLED', 'READY') + + +def unavailable(reason): + print('ERROR=' + reason) + raise SystemExit(0) + + +try: + with open(path, encoding='utf-8') as handle: + state = json.load(handle) +except OSError: + unavailable('state-missing') +except ValueError: + unavailable('state-invalid') +if not isinstance(state, dict): + unavailable('state-invalid') +if state.get('phase') not in VALID_STATES: + unavailable('state-invalid') +if state.get('device_id') not in (None, device): + unavailable('identity-mismatch') +provisioning_id = state.get('provisioning_id') +if not isinstance(provisioning_id, str) or not provisioning_id or len(provisioning_id) > 128 \ + or any(character.isspace() for character in provisioning_id): + unavailable('state-invalid') +try: + with open(public_key_path, encoding='utf-8') as handle: + public_key = handle.read().strip() +except OSError: + unavailable('public-key-missing') +if not public_key: + unavailable('public-key-missing') +idempotency_key = hashlib.sha256((provisioning_id + '|' + device + '|' + public_key).encode()).hexdigest() +print('PHASE=' + state['phase']) +print('PROVISIONING_ID=' + provisioning_id) +print('IDEMPOTENCY_KEY=' + idempotency_key) +PY +} + +STATE_OUT="$(read_state)" +PHASE='' +PROVISIONING_ID='' +IDEMPOTENCY='' +while IFS= read -r line; do + case "$line" in + ERROR=state-missing) gate_block 'device has no lifecycle state yet; offline provisioning must complete first' ;; + ERROR=identity-mismatch) gate_block 'lifecycle state belongs to another device (device_id mismatch)' ;; + ERROR=public-key-missing) gate_block 'WireGuard public key is missing; run local provisioning first' ;; + ERROR=*) gate_block 'lifecycle state is unreadable or violates the state contract' ;; + PHASE=*) PHASE="${line#PHASE=}" ;; + PROVISIONING_ID=*) PROVISIONING_ID="${line#PROVISIONING_ID=}" ;; + IDEMPOTENCY_KEY=*) IDEMPOTENCY="${line#IDEMPOTENCY_KEY=}" ;; + esac +done <<<"$STATE_OUT" +[[ -n "$PHASE" && -n "$PROVISIONING_ID" && -n "$IDEMPOTENCY" ]] || gate_block 'lifecycle state is incomplete' + +# Forward-only transition gate: no skipping, no backwards moves. +case "$PHASE" in + PROVISIONED_OFFLINE) ;; + ENROLLED) + [[ "$REENROLL" -eq 1 ]] || gate_block 'device is already ENROLLED: refusing to replay an enrollment. Pass --reenroll with a fresh one-time token to re-apply.' + note 're-applying enrollment on an ENROLLED device (--reenroll); the phase never moves backwards' + ;; + READY) + gate_block 'device is READY: lifecycle transitions never move backwards. Re-provision from the offline media to enroll again.' + ;; + *) gate_block "enrollment is not allowed from lifecycle phase $PHASE" ;; +esac + +[[ -s "$WG_DIR/privatekey" && -s "$WG_DIR/publickey" ]] || gate_block 'WireGuard key material is missing; run local provisioning first' +if [[ -n "$(mode_bits "$WG_DIR/privatekey")" ]] && ((8#$(mode_bits "$WG_DIR/privatekey") & 8#077)); then + gate_block "WireGuard private key mode is $(mode_bits "$WG_DIR/privatekey"); it must be root-only (0600)" +fi +WG_PUBLIC_KEY="$(tr -d '[:space:]' < "$WG_DIR/publickey")" + +# --- token sources ----------------------------------------------------------- + +# staged_token_problem reports why a staged token cannot be used. It never +# mutates anything, so --check can call it safely. +staged_token_problem() { + if [[ ! -e "$TOKEN_FILE" ]]; then + printf 'no staged one-time token (%s)\n' "$TOKEN_FILE" + return 0 + fi + [[ -f "$TOKEN_FILE" && ! -L "$TOKEN_FILE" ]] && return 1 + printf 'staged token path is not a regular file\n' + return 0 +} + +read_staged_token() { + if [[ ! -e "$TOKEN_FILE" ]]; then + printf 'bf-enroll: PENDING: no staged one-time token; the device waits for a technician (bf-enroll-now) or --stage\n' >&2 + exit 0 + fi + [[ -f "$TOKEN_FILE" && ! -L "$TOKEN_FILE" ]] || gate_block 'staged token path is not a regular file; refusing to read it' + local owner mode size now mtime + owner="$(stat -c '%u' "$TOKEN_FILE")" + [[ "$owner" == "$(id -u)" ]] || gate_block "staged token must be owned by uid $(id -u); refusing to read it" + mode="$(mode_bits "$TOKEN_FILE")" + if ((8#$mode & 8#077)); then + gate_block "staged token mode is $mode; it must be root-only (0600)" + fi + size="$(stat -c '%s' "$TOKEN_FILE")" + if ((size == 0 || size > 4100)); then + rm -f "$TOKEN_FILE" + gate_block 'staged token file size is invalid; the token was discarded' + fi + now="$(date +%s)" + mtime="$(stat -c '%Y' "$TOKEN_FILE")" + if ((now - mtime > TOKEN_MAX_AGE)); then + rm -f "$TOKEN_FILE" + printf 'bf-enroll: PENDING: staged one-time token expired after %ss and was discarded; stage a fresh token\n' "$TOKEN_MAX_AGE" >&2 + exit 0 + fi + TOKEN="$(head -n 1 "$TOKEN_FILE" | tr -d '\r\n')" + if ! validate_token "$TOKEN"; then + rm -f "$TOKEN_FILE" + gate_block 'staged token is malformed; it was discarded' + fi +} + +drop_staged_token() { + [[ "$MODE" == 'systemd' ]] || return 0 + rm -f "$TOKEN_FILE" +} + +stage_token() { + [[ -n "$TOKEN" ]] || die 'a non-empty enrollment token is required on stdin' + install -d -m 700 "$STATE_DIR" + local temp + temp="$(mktemp "$STATE_DIR/.enrollment.token.XXXXXX")" + chmod 600 "$temp" + printf '%s\n' "$TOKEN" > "$temp" + unset TOKEN + mv -f "$temp" "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + note "one-time token staged for the boot timer (mode 0600, TTL ${TOKEN_MAX_AGE}s): $TOKEN_FILE" +} + +if [[ "$STAGE" -eq 1 ]]; then + if [[ "$CHECK_MODE" -eq 1 ]]; then + note "check: would stage a one-time token for the boot timer at $TOKEN_FILE (stdin is not consumed in --check mode)" + exit 0 + fi + IFS= read -r TOKEN || true + validate_token "$TOKEN" || die 'a printable enrollment token of 8-4096 characters without whitespace is required on stdin' + stage_token + note 'staged; the boot attempt enrolls as soon as central is reachable' + exit 0 +fi + +if [[ "$MODE" == 'stdin' ]]; then + validate_endpoint + if [[ "$CHECK_MODE" -eq 0 ]]; then + IFS= read -r TOKEN || true + validate_token "$TOKEN" || die 'a printable enrollment token of 8-4096 characters without whitespace is required on stdin' + fi +else + if [[ -z "$ENDPOINT" ]]; then + # A device without central configuration is not a failure: it stays + # PENDING and must not spin in a retry loop. + printf 'bf-enroll: PENDING: BF_ENROLLMENT_ENDPOINT is not configured; the boot attempt has nothing to do\n' >&2 + exit 0 + fi + validate_endpoint + if [[ "$CHECK_MODE" -eq 1 ]]; then + if problem="$(staged_token_problem)"; then + note "check: $problem; the boot attempt would stay PENDING and exit 0" + exit 0 + fi + else + read_staged_token + fi +fi + +if [[ "$CHECK_MODE" -eq 1 ]]; then + note "check: would enroll $DEVICE_ID ($PHASE -> ENROLLED) against $ENDPOINT; no request sent, nothing written" + note 'check: the WireGuard private key stays local; only the public key and non-secret facts are sent' + exit 0 +fi + +# --- request ----------------------------------------------------------------- + +REQUEST_FILE="$(mktemp)"; RESPONSE_FILE="$(mktemp)"; CV_FILE="$(mktemp)" +chmod 600 "$REQUEST_FILE" "$RESPONSE_FILE" "$CV_FILE" +python3 - "$REQUEST_FILE" "$DEVICE_ID" "$WG_PUBLIC_KEY" "$IDEMPOTENCY" <<'PY' +import json +import platform +import sys + + +def read_if_readable(path): + try: + with open(path, encoding='utf-8') as handle: + return handle.read().strip() + except OSError: + return '' + + +# Only the public key and non-secret device facts are sent. The WireGuard +# private key, the token and any credential are deliberately absent. +request = { + 'device_id': sys.argv[2], + 'wireguard_public_key': sys.argv[3], + 'idempotency_key': sys.argv[4], + 'hardware': { + 'machine_id': read_if_readable('/etc/machine-id'), + 'product_uuid': read_if_readable('/sys/class/dmi/id/product_uuid'), + 'sys_vendor': read_if_readable('/sys/class/dmi/id/sys_vendor'), + }, + 'release': { + 'os_release': read_if_readable('/etc/os-release'), + 'kernel': platform.release(), + }, +} +with open(sys.argv[1], 'w', encoding='utf-8') as handle: + json.dump(request, handle, separators=(',', ':')) +PY + +# The Authorization header arrives through an anonymous process-substitution fd: +# the one-time token is never on argv, on disk, in the state file or in a log. +# --proto/--max-redirs pin the transport: HTTPS only, no redirect can move the +# token to another origin. +if HTTP_CODE="$(curl --proto '=https' --tlsv1.2 --max-redirs 0 \ + --request POST --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" \ + --silent --show-error --fail --write-out '%{http_code}' \ + -H 'Content-Type: application/json' \ + -H @<(printf 'Authorization: Bearer %s' "$TOKEN") \ + --data-binary "@$REQUEST_FILE" --output "$RESPONSE_FILE" "$ENDPOINT")"; then + CURL_RC=0 +else + CURL_RC=$? +fi +unset TOKEN +if [[ "$CURL_RC" -ne 0 ]]; then + case "$HTTP_CODE" in + 4??) + drop_staged_token + die "central rejected the enrollment request (HTTP $HTTP_CODE, curl exit $CURL_RC); that one-time token is consumed, obtain a fresh token" + ;; + *) + die "enrollment request did not complete (curl exit $CURL_RC, HTTP ${HTTP_CODE:-000}); lifecycle stays at its prior state and any staged token is kept for the bounded retry" + ;; + esac +fi + +# --- response validation and local configuration ----------------------------- + +STAGED_CONFIG="$(mktemp "$WG_DIR/.wg0.conf.XXXXXX")" +chmod 600 "$STAGED_CONFIG" +python3 - "$RESPONSE_FILE" "$STAGED_CONFIG" "$CV_FILE" "$DEVICE_ID" "$WG_PUBLIC_KEY" "$IDEMPOTENCY" "$WG_DIR" <<'PY' +import json +import os +import re +import sys + +response_path, config_path, cv_path, device, public_key, idempotency, wg_dir = sys.argv[1:8] + + +def bad(message): + sys.stderr.write('Enrollment response invalid: ' + message + '\n') + raise SystemExit(1) + + +try: + with open(response_path, encoding='utf-8') as handle: + response = json.load(handle) +except ValueError: + bad('not valid JSON') +allowed = {'status', 'device_id', 'idempotency_key', 'enrollment_id', 'wireguard', 'central_verification'} +required = {'status', 'device_id', 'idempotency_key', 'wireguard', 'central_verification'} +if not isinstance(response, dict) or set(response) - allowed: + bad('unexpected top-level field (no private key or extra secret is accepted)') +if not required <= set(response): + bad('required field missing') +if response['status'] != 'accepted' or response['device_id'] != device or response['idempotency_key'] != idempotency: + bad('status or identity mismatch') +if 'enrollment_id' in response and not isinstance(response['enrollment_id'], str): + bad('enrollment_id type') +wireguard = response['wireguard'] +if not isinstance(wireguard, dict) or set(wireguard) != {'address', 'endpoint', 'server_public_key', 'allowed_ips'}: + bad('WireGuard fields') +server_public_key = wireguard['server_public_key'] +endpoint = wireguard['endpoint'] +address = wireguard['address'] +allowed_ips = wireguard['allowed_ips'] +if not all(isinstance(value, str) for value in (server_public_key, endpoint, address, allowed_ips)): + bad('WireGuard types') +if not re.fullmatch(r'[A-Za-z0-9+/]{43}=', server_public_key): + bad('server public key') +if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]*:[1-9][0-9]{0,4}', endpoint) \ + or any(marker in endpoint.lower() for marker in ('example', 'invalid', '<', '>')): + bad('endpoint') +if int(endpoint.rsplit(':', 1)[1]) > 65535: + bad('endpoint port') +if not re.fullmatch(r'(?:\d{1,3}\.){3}\d{1,3}/(?:[0-9]|[12][0-9]|3[0-2])', address): + bad('address') +if not allowed_ips or '\n' in allowed_ips or '\r' in allowed_ips: + bad('allowed_ips') +central = response['central_verification'] +if not isinstance(central, dict) or set(central) != {'status', 'verification_id', 'monitoring', 'remote', 'management'}: + bad('central verification fields') +if central['status'] not in ('pending', 'verified') or not isinstance(central['verification_id'], str) or not central['verification_id']: + bad('central verification status') +for name in ('monitoring', 'remote', 'management'): + item = central[name] + if not isinstance(item, dict) or set(item) != {'status', 'verification_id'} \ + or item['status'] not in ('pending', 'verified') \ + or not isinstance(item['verification_id'], str) or not item['verification_id']: + bad('central ' + name + ' evidence') + +# The device keeps its own private key: it is never accepted from central, never +# sent, and never stored outside /etc/wireguard. +try: + with open(os.path.join(wg_dir, 'privatekey'), encoding='utf-8') as handle: + private_key = handle.read().strip() +except OSError: + private_key = '' +if not private_key: + bad('local WireGuard private key is unreadable') + +config = ( + '[Interface]\n' + 'PrivateKey = ' + private_key + '\n' + 'Address = ' + address + '\n' + '\n' + '[Peer]\n' + 'PublicKey = ' + server_public_key + '\n' + 'Endpoint = ' + endpoint + '\n' + 'AllowedIPs = ' + allowed_ips + '\n' + 'PersistentKeepalive = 25\n' +) +descriptor = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: + handle.write(config) +with open(cv_path, 'w', encoding='utf-8') as handle: + json.dump({'central_verification': central}, handle, separators=(',', ':')) +PY +chmod 600 "$STAGED_CONFIG" + +# --- activation with rollback ------------------------------------------------ + +rollback_activation() { + if [[ -n "$BACKUP_CONFIG" && -e "$BACKUP_CONFIG" ]]; then + mv -f "$BACKUP_CONFIG" "$WG_DIR/wg0.conf" + BACKUP_CONFIG='' + chmod 600 "$WG_DIR/wg0.conf" 2>/dev/null || true + systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true + else + rm -f "$WG_DIR/wg0.conf" + systemctl disable --now wg-quick@wg0 >/dev/null 2>&1 || true + fi +} + +if [[ -e "$WG_DIR/wg0.conf" ]]; then + BACKUP_CONFIG="$(mktemp "$WG_DIR/.wg0.conf.backup.XXXXXX")" + cp -p "$WG_DIR/wg0.conf" "$BACKUP_CONFIG" +fi +mv "$STAGED_CONFIG" "$WG_DIR/wg0.conf" +STAGED_CONFIG='' +chmod 600 "$WG_DIR/wg0.conf" +if ! systemctl enable wg-quick@wg0 || ! systemctl restart wg-quick@wg0 \ + || ! systemctl is-enabled --quiet wg-quick@wg0 || ! systemctl is-active --quiet wg-quick@wg0 \ + || ! ip link show wg0 >/dev/null 2>&1; then + rollback_activation + die 'WireGuard configuration could not be activated; lifecycle remains at its prior state (configuration rolled back)' +fi + +# --- atomic state commit ----------------------------------------------------- + +python3 - "$STATE_FILE" "$DEVICE_ID" "$IDEMPOTENCY" "$CV_FILE" "$RESPONSE_FILE" "$REENROLL" <<'PY' +import datetime +import fcntl +import json +import os +import sys +import tempfile + +state_path, device, idempotency, cv_path, response_path, reenroll = sys.argv[1:7] +directory = os.path.dirname(state_path) or '.' +with open(cv_path, encoding='utf-8') as handle: + central = json.load(handle)['central_verification'] +with open(response_path, encoding='utf-8') as handle: + enrollment_id = json.load(handle).get('enrollment_id') or 'accepted' + +# Serialize lifecycle writers (boot timer vs. technician) and re-check the +# transition under the lock so a stale decision can never be committed. +lock = os.open(os.path.join(directory, '.state.lock'), os.O_CREAT | os.O_RDWR | os.O_CLOEXEC, 0o600) +try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) +except OSError: + raise SystemExit('bf-enroll: another lifecycle update is in progress; nothing was committed') + +with open(state_path, encoding='utf-8') as handle: + state = json.load(handle) +current = state.get('phase') +if current == 'READY': + raise SystemExit('bf-enroll: refusing to move a READY device backwards; nothing was committed') +if current not in ('PROVISIONED_OFFLINE', 'ENROLLED'): + raise SystemExit('bf-enroll: device is not PROVISIONED_OFFLINE; nothing was committed') +if current == 'ENROLLED' and reenroll != '1': + raise SystemExit('bf-enroll: device is already ENROLLED; refusing a replay') +if state.get('device_id') not in (None, device): + raise SystemExit('bf-enroll: lifecycle state device_id mismatch; nothing was committed') + +# Forward-only transition, exactly one stage: PROVISIONED_OFFLINE -> ENROLLED. +state.update({ + 'phase': 'ENROLLED', + 'enrollment_status': 'ENROLLED', + 'fleet_status': 'PENDING', + 'device_id': device, + 'enrollment_id': enrollment_id, + 'idempotency_key': idempotency, + 'central_verification': central, + 'enrolled_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), +}) +descriptor, temporary = tempfile.mkstemp(prefix='.state.json.', dir=directory) +try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: + json.dump(state, handle, sort_keys=True) + handle.write('\n') + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, state_path) +except BaseException: + if os.path.exists(temporary): + os.unlink(temporary) + raise +os.chmod(state_path, 0o600) +PY + +drop_staged_token +rm -f "$BACKUP_CONFIG" +BACKUP_CONFIG='' +note 'Enrollment accepted and WireGuard is active. Device is ENROLLED; READY requires fresh local checks and central verification evidence.' diff --git a/scripts/diagnostics/bf-enroll-now b/scripts/diagnostics/bf-enroll-now new file mode 100755 index 0000000..96baedc --- /dev/null +++ b/scripts/diagnostics/bf-enroll-now @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Interactive wrapper: token input is accepted only through stdin by bf-enroll. +set -euo pipefail +exec "$(dirname "$0")/bf-enroll" --token-stdin "$@" diff --git a/scripts/diagnostics/bf-enrollment-status b/scripts/diagnostics/bf-enrollment-status new file mode 100755 index 0000000..88feb7a --- /dev/null +++ b/scripts/diagnostics/bf-enrollment-status @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# +# bf-enrollment-status - read-only canonical Field OS lifecycle view. +# +# Lifecycle contract (docs/29-DEVICE-ENROLLMENT.md, provisioning/enrollment/README.md): +# +# PROVISIONED_OFFLINE local (offline) provisioning is complete. No internet, +# no central record and no WireGuard handshake is needed +# or claimed at this stage. +# ENROLLED central accepted the device, a WireGuard peer was +# assigned and monitoring/inventory/remote access +# configuration is applied (or being applied). +# READY local health plus every central channel (monitoring, +# remote, management) is verified. +# +# Transitions are forward-only and never skip a stage: +# (none) -> PROVISIONED_OFFLINE -> ENROLLED -> READY +# A missing stage, a backwards move, or READY without verified central +# evidence is reported as INVALID and exits non-zero (fail closed). +# +# State file: ${BF_STATE_FILE:-/var/lib/blueforce/state.json} +# mode 0600; fields phase, enrollment_status, fleet_status, device_id, +# provisioning_id. Unknown extra fields are preserved by writers and ignored +# here. +# +# Output vocabulary (stable, consumed by bf-status/bf-check-*): +# Lifecycle : PROVISIONED_OFFLINE | ENROLLED | READY | PENDING | INVALID +# Enrollment : PENDING | ENROLLED | COMPLETE +# Fleet : PENDING | NOT_READY | READY +# +# Exit status: 0 lifecycle valid, 1 missing/invalid state, 2 usage error. +# Never writes anything and never prints secrets. +# +set -euo pipefail + +STATE_FILE="${BF_STATE_FILE:-${STATE_DIR:-/var/lib/blueforce}/state.json}" +# Canonical lifecycle phases; the order in this list is the only legal order. +VALID_STATES='PROVISIONED_OFFLINE ENROLLED READY' +FIELD='all' + +usage() { + cat <<'EOF' +Usage: bf-enrollment-status [--field phase|enrollment|fleet] [--check] + +Print the canonical Field OS lifecycle state (read-only, no secrets). + +Options: + --field NAME print one bare value instead of the three-line report + (NAME is phase, enrollment or fleet) + --check accepted for symmetry with the installer modules; this + command never mutates anything + --help show this help + +Exit status: 0 valid lifecycle, 1 missing or contract-violating state, +2 usage error. + +Lifecycle vocabulary : PROVISIONED_OFFLINE | ENROLLED | READY | PENDING | INVALID +Enrollment vocabulary: PENDING | ENROLLED | COMPLETE +Fleet vocabulary : PENDING | NOT_READY | READY + +Forward-only transitions: PROVISIONED_OFFLINE -> ENROLLED -> READY. +EOF +} + +if [[ "${1:-}" == '--help' || "${1:-}" == '-h' ]]; then + usage + exit 0 +fi +while [[ $# -gt 0 ]]; do + case "$1" in + --field) + [[ $# -ge 2 ]] || { echo 'ERROR: --field needs a value' >&2; exit 2; } + FIELD="$2" + shift 2 + ;; + --check) + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done +case "$FIELD" in + all|phase|enrollment|fleet) ;; + *) echo "ERROR: --field must be phase, enrollment or fleet (got: $FIELD)" >&2; exit 2 ;; +esac + +if [[ ! -r "$STATE_FILE" ]]; then + # No lifecycle state yet: the device has not completed offline provisioning. + if [[ "$FIELD" == 'all' ]]; then + printf 'Lifecycle: PENDING\nEnrollment: PENDING\nFleet: PENDING\n' + else + printf 'PENDING\n' + fi + exit 1 +fi + +python3 - "$STATE_FILE" "$FIELD" <<'PY' +import json +import os +import re +import stat +import sys + +path, field = sys.argv[1], sys.argv[2] +VALID_STATES = ('PROVISIONED_OFFLINE', 'ENROLLED', 'READY') +VALID_ENROLLMENT = ('PENDING', 'ENROLLED', 'COMPLETE') +VALID_FLEET = ('PENDING', 'NOT_READY', 'READY') +CHANNELS = ('monitoring', 'remote', 'management') +DEVICE_ID = re.compile(r'^BF-[0-9]{8}$') + + +def invalid(reason): + sys.stderr.write('INVALID: %s\n' % reason) + print('INVALID' if field != 'all' else 'Lifecycle: INVALID') + raise SystemExit(1) + + +try: + with open(path, encoding='utf-8') as handle: + state = json.load(handle) +except OSError as error: + invalid('state file is unreadable (%s)' % error.__class__.__name__) +except ValueError as error: + invalid('state file is not valid JSON (%s)' % error.__class__.__name__) +if not isinstance(state, dict): + invalid('state file is not a JSON object') + +phase = state.get('phase') +if phase not in VALID_STATES: + invalid('phase is missing or not one of %s' % ', '.join(VALID_STATES)) +enrollment = state.get('enrollment_status') +if enrollment not in VALID_ENROLLMENT: + invalid('enrollment_status is missing or not one of %s' % ', '.join(VALID_ENROLLMENT)) +fleet = state.get('fleet_status') +if fleet not in VALID_FLEET: + invalid('fleet_status is missing or not one of %s' % ', '.join(VALID_FLEET)) +device_id = state.get('device_id') +if not isinstance(device_id, str) or not DEVICE_ID.match(device_id): + invalid('device_id is missing or does not match BF-<8 digits>') +provisioning_id = state.get('provisioning_id') +if not isinstance(provisioning_id, str) or not provisioning_id or len(provisioning_id) > 128 \ + or any(character.isspace() for character in provisioning_id): + invalid('provisioning_id is missing or malformed') + + +def evidence_problem(record): + """Return a reason string when central verification evidence is unusable.""" + if not isinstance(record, dict): + return 'central_verification is missing' + if set(record) - {'status', 'verification_id', 'monitoring', 'remote', 'management'}: + return 'central_verification carries unexpected fields' + if not {'status', 'verification_id'}.issubset(record): + return 'central_verification is incomplete' + if record['status'] not in ('pending', 'verified'): + return 'central_verification.status is invalid' + if not isinstance(record['verification_id'], str) or not record['verification_id']: + return 'central_verification.verification_id is missing' + for name in CHANNELS: + item = record.get(name) + if not isinstance(item, dict) or set(item) != {'status', 'verification_id'}: + return 'central_verification.%s is missing or malformed' % name + if item['status'] not in ('pending', 'verified'): + return 'central_verification.%s.status is invalid' % name + if not isinstance(item['verification_id'], str) or not item['verification_id']: + return 'central_verification.%s.verification_id is missing' % name + return None + + +record = state.get('central_verification') +problem = evidence_problem(record) + +if phase == 'PROVISIONED_OFFLINE': + if enrollment != 'PENDING': + invalid('PROVISIONED_OFFLINE requires enrollment_status PENDING (got %s)' % enrollment) + if fleet == 'READY': + invalid('PROVISIONED_OFFLINE cannot be fleet READY: a stage was skipped') + if record is not None: + sys.stderr.write('WARN: central_verification is present before enrollment\n') +elif phase == 'ENROLLED': + if enrollment not in ('ENROLLED', 'COMPLETE'): + invalid('ENROLLED requires enrollment_status ENROLLED or COMPLETE (got %s)' % enrollment) + if fleet == 'READY': + invalid('ENROLLED cannot be fleet READY: fleet READY is recorded with phase READY') + if problem: + invalid(problem) +elif phase == 'READY': + if enrollment not in ('ENROLLED', 'COMPLETE'): + invalid('READY requires a completed enrollment (got %s)' % enrollment) + if fleet != 'READY': + invalid('READY requires fleet_status READY (got %s)' % fleet) + if problem: + invalid(problem) + pending = [name for name in CHANNELS if record[name]['status'] != 'verified'] + if record['status'] != 'verified' or pending: + invalid('READY requires verified central evidence for every channel') + +try: + mode = stat.S_IMODE(os.stat(path).st_mode) + if mode & 0o077: + sys.stderr.write('WARN: %s is mode %04o; the lifecycle contract requires 0600\n' % (path, mode)) +except OSError: + pass + +# Enrolment is reported as COMPLETE once READY is recorded: both facts come from +# the same forward-only transition chain. +enrollment_display = 'COMPLETE' if (phase == 'READY' or enrollment == 'COMPLETE') else enrollment +fleet_display = 'READY' if fleet == 'READY' else 'NOT READY' + +if field == 'phase': + print(phase) +elif field == 'enrollment': + print(enrollment_display) +elif field == 'fleet': + print(fleet_display) +else: + print('Lifecycle: ' + phase) + print('Enrollment: ' + enrollment_display) + print('Fleet: ' + fleet_display) +PY diff --git a/scripts/diagnostics/bf-hardware-inventory.sh b/scripts/diagnostics/bf-hardware-inventory.sh new file mode 100755 index 0000000..83de94e --- /dev/null +++ b/scripts/diagnostics/bf-hardware-inventory.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# bf-hardware-inventory.sh - Scan field-terminal hardware and emit JSON + Markdown. +# +# Covers every field in docs/02-DEVICE-NAMING-AND-INVENTORY.md §9: +# Manufacturer/Model/Serial/BIOS/CPU/RAM/Disk/SMART/NIC/MAC/GPU/USB/ +# SerialPorts/Kernel/Arch/TPM/Virt/Temps. Missing probes report +# "unknown" instead of aborting. Never prints secrets, keys, or passwords. +# +# Install: install -m 0755 bf-hardware-inventory.sh /usr/local/bin/bf-hardware-inventory.sh +# Usage: bf-hardware-inventory.sh [--dealer-id ID] [--out-dir DIR] [--json] [--markdown] [--help] +# +set -euo pipefail + +DEALER_ID="" +OUT_DIR="" +MODE="both" # json | markdown | both + +usage() { + cat <<'EOF' +Usage: bf-hardware-inventory.sh [--dealer-id ID] [--out-dir DIR] [--json] [--markdown] [--help] + +Scan hardware and emit inventory (JSON for machines, Markdown for humans). + +Options: + --dealer-id ID 8-digit dealer number (validates ^[0-9]{8}$ when given) + --out-dir DIR Write BF-.json and BF-.md into DIR + (requires --dealer-id; default filenames use hostname) + --json Print only JSON to stdout + --markdown Print only Markdown to stdout + --help Show this help and exit + +Fields (doc 02 §9): Manufacturer, Model, Serial, BIOS, CPU, RAM, +Disk, SMART, NIC, MAC, GPU, USB, SerialPorts, Kernel, Arch, TPM, +Virt, Temps. Unavailable probes report "unknown". +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dealer-id) DEALER_ID="${2:?missing value for --dealer-id}"; shift 2 ;; + --out-dir) OUT_DIR="${2:?missing value for --out-dir}"; shift 2 ;; + --json) MODE="json"; shift ;; + --markdown) MODE="markdown"; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ -n "${DEALER_ID}" ]] && ! [[ "${DEALER_ID}" =~ ^[0-9]{8}$ ]]; then + echo "ERROR: --dealer-id must match ^[0-9]{8}$ (got: ${DEALER_ID})" >&2 + exit 2 +fi +if [[ -n "${OUT_DIR}" && -z "${DEALER_ID}" ]]; then + echo "ERROR: --out-dir requires --dealer-id" >&2 + exit 2 +fi + +set +e # probes must not abort the report + +HOST="$(hostname 2>/dev/null || echo unknown)" +if [[ -n "${DEALER_ID}" ]]; then + DEVICE_ID="BF-${DEALER_ID}" + LOWER_HOST="bf-${DEALER_ID}" +else + DEVICE_ID="$(echo "${HOST}" | tr '[:lower:]' '[:upper:]')" + LOWER_HOST="$(echo "${HOST}" | tr '[:upper:]' '[:lower:]')" +fi +NOW="$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)" + +json_escape() { # stdin -> JSON string body + python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || sed 's/"/\\"/g' | tr -d '\n' +} +val() { # $1 raw -> "unknown" fallback, no newlines + local v="$1" + v="$(echo "${v}" | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -z "${v}" ]] && echo "unknown" || echo "${v}" +} + +# --- Probes (all fields from doc 02) --- +MANUFACTURER="$(val "$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || dmidecode -s system-manufacturer 2>/dev/null)")" +MODEL="$(val "$(cat /sys/class/dmi/id/product_name 2>/dev/null || dmidecode -s system-product-name 2>/dev/null)")" +SERIAL="$(val "$(cat /sys/class/dmi/id/product_serial 2>/dev/null || dmidecode -s system-serial-number 2>/dev/null)")" +BIOS_VENDOR="$(val "$(cat /sys/class/dmi/id/bios_vendor 2>/dev/null || dmidecode -s bios-vendor 2>/dev/null)")" +BIOS_VERSION="$(val "$(cat /sys/class/dmi/id/bios_version 2>/dev/null || dmidecode -s bios-version 2>/dev/null)")" +BIOS_DATE="$(val "$(cat /sys/class/dmi/id/bios_date 2>/dev/null || dmidecode -s bios-release-date 2>/dev/null)")" +CPU_MODEL="$(val "$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo 2>/dev/null || lscpu 2>/dev/null | awk -F': *' '/Model name/ {print $2; exit}')")" +CPU_CORES="$(val "$(nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null)")" +ARCH="$(val "$(uname -m 2>/dev/null)")" +KERNEL="$(val "$(uname -sr 2>/dev/null)")" +MEM_KB="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null)" +MEMORY_MB="$([ -n "${MEM_KB}" ] && echo $((MEM_KB / 1024)) || echo "unknown")" +MEMORY_STR="$([ "${MEMORY_MB}" != "unknown" ] && echo "${MEMORY_MB} MB" || echo "unknown")" + +DISKS="$(lsblk -d -n -o NAME,SIZE,TYPE,ROTA 2>/dev/null || echo unknown)" +GPU="$(val "$(lspci 2>/dev/null | grep -iE 'vga|3d|display' | head -3 | sed 's/^[^:]*: //')")" +NIC_LIST="$(val "$(ls /sys/class/net 2>/dev/null | grep -v '^lo$' | tr '\n' ',' | sed 's/,$//')")" +MAC_LIST="$(val "$(for n in /sys/class/net/*; do [[ "$(basename "$n")" == lo ]] && continue; cat "$n/address" 2>/dev/null; done | tr '\n' ',' | sed 's/,$//')")" +USB_LIST="$(val "$(lsusb 2>/dev/null | head -20 || echo '')")" +SERIAL_PORTS="$(val "$(ls /dev/ttyS* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null | tr '\n' ',' | sed 's/,$//')")" +TPM="$(val "$( ([ -c /dev/tpm0 ] && echo -n 'present (/dev/tpm0)') ; dmesg 2>/dev/null | grep -im1 'tpm' | head -1 | sed 's/^/ /')")" +VIRT="$(val "$(systemd-detect-virt 2>/dev/null || echo none)")" +TEMPS="$(val "$( (cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | head -5 | tr '\n' ' '); sensors 2>/dev/null | grep -iE 'core|temp|package' | head -5 | tr '\n' ';')")" + +SMART_SUMMARY="unknown" +if command -v smartctl >/dev/null 2>&1; then + SMART_SUMMARY="$(for dev in /dev/sd? /dev/nvme?n1; do + [[ -b "${dev}" ]] || continue + echo -n "${dev}: " + smartctl -H "${dev}" 2>/dev/null | grep -im1 'health\|result' | sed 's/^[[:space:]]*//' | head -1 + echo -n '; ' + done)" + SMART_SUMMARY="$(val "${SMART_SUMMARY}")" +else + SMART_SUMMARY="smartctl not installed" +fi + +OS_PRETTY="$(val "$(grep -E '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"')")" + +E() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || printf '%s' "$1"; } + +JSON="$(cat < "${OUT_DIR}/BF-${DEALER_ID}.json" + printf '%s\n' "${MD}" > "${OUT_DIR}/BF-${DEALER_ID}.md" + echo "wrote ${OUT_DIR}/BF-${DEALER_ID}.json ${OUT_DIR}/BF-${DEALER_ID}.md" + else + printf '%s\n\n%s\n' "${JSON}" "${MD}" + fi + ;; +esac diff --git a/scripts/diagnostics/bf-live-hw-check b/scripts/diagnostics/bf-live-hw-check new file mode 100755 index 0000000..0794385 --- /dev/null +++ b/scripts/diagnostics/bf-live-hw-check @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Read-only Field OS hardware compatibility check. It does not install, wipe, or modify state. +# +# This script is itself the read-only "--check" gate of the Windows -> Field OS +# migration: it only reads /proc, /sys, lsblk, lsusb and (optionally) smartctl. +# No package is installed, no disk is written, no service or network state is +# touched. +# +# Checks and thresholds (verdict = worst single check): +# +# check SUPPORTED WARNING BLOCKED +# ------------ ------------------------------------- ---------------------------------------- --------------------------------- +# architecture x86_64 - any other value +# ram >= 4096 MiB 2048..4095 MiB < 2048 MiB +# disk at least one disk >= 64 GiB - no disk >= 64 GiB +# disk_type SSD (or mixed/unknown) only rotational disks (HDD) - +# smart smartctl reports PASSED smartctl missing / no readable result smartctl reports FAILED +# nic >= 1 non-loopback interface - no non-loopback interface +# nic_link >= 1 interface operstate up interface present but none up - +# serial /dev/ttyS*|ttyUSB*|ttyACM* present no serial device at all - +# usb lsusb returns devices lsusb unavailable/empty - +# boot_mode UEFI boot legacy BIOS boot - +# secure_boot enabled/disabled/not applicable UEFI but state unreadable - +# tpm TPM device present - - +# virt bare metal (virt = none) running inside a hypervisor - +# cpu_virt vmx/svm present no virtualization extensions exposed - +# +# A BLOCKED result means the host must not proceed to a migration install until +# reviewed. WARNING means LAB / peripheral acceptance work is still outstanding. +set -euo pipefail + +MODE="markdown" +usage() { + cat <<'EOF' +Usage: bf-live-hw-check [--json | --markdown] [--help] + +Evaluates the live host for Field OS baseline compatibility without changing the +host. Verdicts are SUPPORTED, WARNING, or BLOCKED. A BLOCKED result means the +host must not proceed to a migration install until reviewed. + +Checks: architecture, ram, disk, disk_type, smart, nic, nic_link, serial, usb, +boot_mode, secure_boot, tpm, virt, cpu_virt. The overall verdict is the worst +single check. Thresholds: + BLOCKED non-x86_64 | RAM < 2048 MiB | no disk >= 64 GiB | SMART FAILED | no NIC + WARNING RAM 2048-4095 MiB | HDD-only | smartctl missing | no NIC link up | + no serial device | no USB inventory | legacy BIOS | UEFI Secure Boot + state unreadable | hypervisor detected | no vmx/svm + SUPPORTED everything else; this is not a replacement for the doc 13 MEG + acceptance test. +EOF +} +while [[ $# -gt 0 ]]; do + case "$1" in + --json) MODE="json"; shift ;; + --markdown) MODE="markdown"; shift ;; + --help|-h) usage; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; + esac +done + +clean() { tr '\n' ' ' | tr -s ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; } +json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || sed 's/\\/\\\\/g;s/"/\\"/g'; } +status_rank() { case "$1" in SUPPORTED) echo 0;; WARNING) echo 1;; BLOCKED) echo 2;; esac; } + +ARCH="$(uname -m 2>/dev/null || printf unknown)" +RAM_MB="$(awk '/MemTotal:/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null || printf 0)" +DISK_LINES="$(lsblk -bdn -o NAME,SIZE,TYPE 2>/dev/null | awk '$3 == "disk" {print $1 ":" $2}' || true)" +DISK_COUNT="$(printf '%s\n' "$DISK_LINES" | awk 'NF {count++} END {print count+0}')" +LARGEST_DISK_BYTES="$(printf '%s\n' "$DISK_LINES" | awk -F: 'NF == 2 && $2 > max {max=$2} END {print max+0}')" +NIC_LIST="$(find /sys/class/net -mindepth 1 -maxdepth 1 -printf '%f\n' 2>/dev/null | grep -vx 'lo' | paste -sd, - || true)" +SERIAL_LIST="$({ compgen -G '/dev/ttyS*' || true; compgen -G '/dev/ttyUSB*' || true; compgen -G '/dev/ttyACM*' || true; } 2>/dev/null | paste -sd, - || true)" +USB_LIST="$(lsusb 2>/dev/null | head -20 | clean || true)" + +# --- architecture ----------------------------------------------------------- +ARCH_STATUS="SUPPORTED"; ARCH_NOTE="x86_64 architecture detected" +if [[ "$ARCH" != "x86_64" ]]; then ARCH_STATUS="BLOCKED"; ARCH_NOTE="Field OS baseline requires x86_64; detected $ARCH"; fi + +# --- ram -------------------------------------------------------------------- +RAM_STATUS="SUPPORTED"; RAM_NOTE="$RAM_MB MiB detected (minimum 4096 MiB)" +if (( RAM_MB < 2048 )); then RAM_STATUS="BLOCKED"; RAM_NOTE="$RAM_MB MiB detected; below 2048 MiB hard floor" +elif (( RAM_MB < 4096 )); then RAM_STATUS="WARNING"; RAM_NOTE="$RAM_MB MiB detected; below 4096 MiB baseline"; fi + +# --- disks ------------------------------------------------------------------ +DISK_STATUS="SUPPORTED"; DISK_NOTE="$DISK_COUNT disk(s), largest $((LARGEST_DISK_BYTES / 1024 / 1024 / 1024)) GiB (minimum 64 GiB)" +if (( DISK_COUNT == 0 || LARGEST_DISK_BYTES < 68719476736 )); then DISK_STATUS="BLOCKED"; DISK_NOTE="No disk at or above 64 GiB was detected"; fi + +# --- disk type (rotational vs solid state) ---------------------------------- +# loop/zram/sr pseudo-devices are excluded: they are not migration targets. +DISK_TYPE_LIST="$(lsblk -bdn -o NAME,ROTA 2>/dev/null | awk '$1 !~ /^(loop|zram|ram|sr|fd)/ {if ($2 == "0") printf "%s:ssd ", $1; else if ($2 == "1") printf "%s:hdd ", $1}' | clean || true)" +DISK_HDD_COUNT="$(printf '%s\n' "$DISK_TYPE_LIST" | tr ' ' '\n' | grep -c ':hdd$' || true)" +DISK_SSD_COUNT="$(printf '%s\n' "$DISK_TYPE_LIST" | tr ' ' '\n' | grep -c ':ssd$' || true)" +DISK_TYPE_STATUS="SUPPORTED" +if [[ -z "$DISK_TYPE_LIST" ]]; then + DISK_TYPE_NOTE="Rotational information unavailable; validate disk type in LAB" +elif (( DISK_HDD_COUNT > 0 && DISK_SSD_COUNT == 0 )); then + DISK_TYPE_STATUS="WARNING"; DISK_TYPE_NOTE="Only rotational disk(s) detected ($DISK_TYPE_LIST); SSD baseline expected for field durability" +elif (( DISK_HDD_COUNT > 0 )); then + DISK_TYPE_NOTE="Disk media: $DISK_TYPE_LIST(mixed: $DISK_HDD_COUNT hdd / $DISK_SSD_COUNT ssd)" +else + DISK_TYPE_NOTE="Disk media: $DISK_TYPE_LIST" +fi + +# --- smart health ----------------------------------------------------------- +SMART_STATUS="SUPPORTED"; SMART_NOTE="SMART health not reported" +if command -v smartctl >/dev/null 2>&1; then + SMART_RESULT="" + for device in /dev/sd? /dev/nvme?n1; do + [[ -b "$device" ]] || continue + health="$(smartctl -H "$device" 2>/dev/null | awk '/SMART overall-health|SMART Health Status/ {print; exit}' | clean || true)" + [[ -n "$health" ]] && SMART_RESULT="${SMART_RESULT}${device}: ${health}; " + done + if [[ -z "$SMART_RESULT" ]]; then + SMART_STATUS="WARNING"; SMART_NOTE="smartctl present but no disk reported a health result (unsupported device or permissions); validate in LAB" + elif grep -qiE 'FAILED|BAD' <<<"$SMART_RESULT"; then + SMART_STATUS="BLOCKED"; SMART_NOTE="Disk health FAILED: $(clean <<<"$SMART_RESULT")" + else + SMART_NOTE="SMART: $(clean <<<"$SMART_RESULT")" + fi +else + SMART_STATUS="WARNING"; SMART_NOTE="smartctl not installed; disk health unverified (install smartmontools or validate in LAB)" +fi + +# --- nic / link state ------------------------------------------------------- +NIC_STATUS="SUPPORTED"; NIC_NOTE="Network interfaces: ${NIC_LIST:-none}" +if [[ -z "$NIC_LIST" ]]; then NIC_STATUS="BLOCKED"; NIC_NOTE="No non-loopback network interface detected"; fi + +NIC_LINK_PHYSICAL="" +NIC_LINK_PHYSICAL_UP=0 +NIC_UP_COUNT=0 +VIRTUAL_IFACE_PATTERN='^(veth|br-|docker|virbr|vmnet|tun|tap|wg|zram|dummy|bond|vnet)' +for iface_path in /sys/class/net/*; do + [[ -d "$iface_path" ]] || continue + iface_name="$(basename "$iface_path")" + [[ "$iface_name" == "lo" ]] && continue + iface_state="$(cat "$iface_path/operstate" 2>/dev/null || printf unknown)" + [[ "$iface_state" == "up" ]] && NIC_UP_COUNT=$((NIC_UP_COUNT + 1)) + if [[ ! "$iface_name" =~ $VIRTUAL_IFACE_PATTERN ]]; then + NIC_LINK_PHYSICAL="${NIC_LINK_PHYSICAL}${iface_name}=${iface_state} " + [[ "$iface_state" == "up" ]] && NIC_LINK_PHYSICAL_UP=$((NIC_LINK_PHYSICAL_UP + 1)) + fi +done +NIC_LINK_DETAIL="$(clean <<<"$NIC_LINK_PHYSICAL")" +NIC_LINK_STATUS="SUPPORTED" +NIC_LINK_NOTE="Up: $NIC_UP_COUNT interface(s), physical: ${NIC_LINK_DETAIL:-none} (virtual interfaces excluded from the physical column)" +if [[ -n "$NIC_LIST" && $NIC_UP_COUNT -eq 0 ]]; then + NIC_LINK_STATUS="WARNING"; NIC_LINK_NOTE="Interfaces exist but none is up; check cabling/switch before migration" +elif [[ -n "$NIC_LIST" && $NIC_LINK_PHYSICAL_UP -eq 0 ]]; then + NIC_LINK_STATUS="WARNING"; NIC_LINK_NOTE="No physical interface is up (only virtual interfaces): ${NIC_LINK_DETAIL:-none}; check cabling/switch before migration" +fi + +# --- serial ports ----------------------------------------------------------- +SERIAL_STATUS="SUPPORTED"; SERIAL_NOTE="Serial interfaces: ${SERIAL_LIST:-none}" +if [[ -z "$SERIAL_LIST" ]]; then SERIAL_STATUS="WARNING"; SERIAL_NOTE="No serial interface detected; validate MEG peripheral path in LAB"; fi +if [[ -n "$SERIAL_LIST" && "$SERIAL_LIST" != *"ttyUSB"* && "$SERIAL_LIST" != *"ttyACM"* ]]; then + SERIAL_NOTE="$SERIAL_NOTE; no USB-serial adapter (ttyUSB/ttyACM) detected" +fi + +# --- usb -------------------------------------------------------------------- +USB_STATUS="SUPPORTED"; USB_NOTE="USB devices detected" +if [[ -z "$USB_LIST" ]]; then USB_STATUS="WARNING"; USB_NOTE="No USB inventory available (install nothing; validate USB peripherals manually)"; fi + +# --- boot mode -------------------------------------------------------------- +BOOT_MODE="bios" +[[ -d /sys/firmware/efi ]] && BOOT_MODE="uefi" +BOOT_STATUS="SUPPORTED" +if [[ "$BOOT_MODE" == "uefi" ]]; then BOOT_NOTE="UEFI boot detected"; else BOOT_STATUS="WARNING"; BOOT_NOTE="Legacy BIOS boot detected; confirm firmware mode and boot chain in LAB"; fi + +# --- secure boot ------------------------------------------------------------ +SECURE_BOOT="unknown" +if [[ "$BOOT_MODE" != "uefi" ]]; then + SECURE_BOOT="not-applicable" +else + sb_file="$(ls /sys/firmware/efi/efivars/SecureBoot-* 2>/dev/null | head -n 1 || true)" + if [[ -n "$sb_file" ]]; then + sb_byte="$(od -An -tu1 -j4 -N1 "$sb_file" 2>/dev/null | tr -d ' \n' || true)" + case "$sb_byte" in + 1) SECURE_BOOT="enabled" ;; + 0) SECURE_BOOT="disabled" ;; + esac + fi +fi +SECURE_BOOT_STATUS="SUPPORTED" +case "$SECURE_BOOT" in + enabled) SECURE_BOOT_NOTE="Secure Boot enabled; verify signed-boot chain with the Field OS medium in LAB" ;; + disabled) SECURE_BOOT_NOTE="Secure Boot disabled; acceptable for the baseline, record the state" ;; + not-applicable) SECURE_BOOT_NOTE="Not applicable (legacy BIOS boot)" ;; + *) SECURE_BOOT_STATUS="WARNING"; SECURE_BOOT_NOTE="Secure Boot state unreadable (root required for efivars); verify in firmware setup" ;; +esac + +# --- tpm -------------------------------------------------------------------- +TPM_STATUS="SUPPORTED" +if [[ -e /dev/tpm0 || -e /dev/tpmrm0 ]]; then + TPM_NOTE="TPM device present (inventory field; not a baseline requirement)" +else + TPM_NOTE="No TPM device detected (inventory field only; the baseline does not require a TPM)" +fi + +# --- virtualisation --------------------------------------------------------- +# systemd-detect-virt exits 1 on bare metal (prints "none"), hence "|| true". +VIRT_TYPE="$(systemd-detect-virt 2>/dev/null || true)" +VIRT_TYPE="$(clean <<<"$VIRT_TYPE")" +[[ -z "$VIRT_TYPE" ]] && VIRT_TYPE="unknown" +if [[ "$VIRT_TYPE" == "none" ]]; then + VIRT_STATUS="SUPPORTED"; VIRT_NOTE="Bare metal" +else + VIRT_STATUS="WARNING"; VIRT_NOTE="Running inside a hypervisor ($VIRT_TYPE); confirm this is the intended LAB/template host" +fi + +# --- cpu virtualization extensions ----------------------------------------- +if grep -qm1 -E '(^flags|^Features).*( vmx| svm)' /proc/cpuinfo 2>/dev/null; then + CPU_VIRT_STATUS="SUPPORTED"; CPU_VIRT_NOTE="CPU virtualization extensions exposed (vmx/svm)" +else + CPU_VIRT_STATUS="WARNING"; CPU_VIRT_NOTE="No vmx/svm flag exposed; QEMU/KVM LAB profiles need nested virtualization on the host" +fi + +OVERALL="SUPPORTED" +for current in "$ARCH_STATUS" "$RAM_STATUS" "$DISK_STATUS" "$DISK_TYPE_STATUS" "$SMART_STATUS" "$NIC_STATUS" "$NIC_LINK_STATUS" "$SERIAL_STATUS" "$USB_STATUS" "$BOOT_STATUS" "$SECURE_BOOT_STATUS" "$TPM_STATUS" "$VIRT_STATUS" "$CPU_VIRT_STATUS"; do + if (( $(status_rank "$current") > $(status_rank "$OVERALL") )); then OVERALL="$current"; fi +done + +if [[ "$MODE" == "json" ]]; then + cat </dev/null 2>&1 && printf 'smartctl present' || printf 'smartctl missing')","note":"$(printf '%s' "$SMART_NOTE" | json_escape)"}, + {"name":"nic","status":"$NIC_STATUS","value":"$(printf '%s' "${NIC_LIST:-none}" | json_escape)","note":"$(printf '%s' "$NIC_NOTE" | json_escape)"}, + {"name":"nic_link","status":"$NIC_LINK_STATUS","value":"$(printf '%s' "${NIC_LINK_DETAIL:-none}" | json_escape)","note":"$(printf '%s' "$NIC_LINK_NOTE" | json_escape)"}, + {"name":"serial","status":"$SERIAL_STATUS","value":"$(printf '%s' "${SERIAL_LIST:-none}" | json_escape)","note":"$(printf '%s' "$SERIAL_NOTE" | json_escape)"}, + {"name":"usb","status":"$USB_STATUS","value":"$(printf '%s' "${USB_LIST:-unavailable}" | json_escape)","note":"$(printf '%s' "$USB_NOTE" | json_escape)"}, + {"name":"boot_mode","status":"$BOOT_STATUS","value":"$BOOT_MODE","note":"$(printf '%s' "$BOOT_NOTE" | json_escape)"}, + {"name":"secure_boot","status":"$SECURE_BOOT_STATUS","value":"$SECURE_BOOT","note":"$(printf '%s' "$SECURE_BOOT_NOTE" | json_escape)"}, + {"name":"tpm","status":"$TPM_STATUS","value":"$(if [[ -e /dev/tpm0 || -e /dev/tpmrm0 ]]; then printf 'present'; else printf 'absent'; fi)","note":"$(printf '%s' "$TPM_NOTE" | json_escape)"}, + {"name":"virt","status":"$VIRT_STATUS","value":"$(printf '%s' "$VIRT_TYPE" | json_escape)","note":"$(printf '%s' "$VIRT_NOTE" | json_escape)"}, + {"name":"cpu_virt","status":"$CPU_VIRT_STATUS","value":"$(if [[ "$CPU_VIRT_STATUS" == "SUPPORTED" ]]; then printf 'vmx/svm'; else printf 'not exposed'; fi)","note":"$(printf '%s' "$CPU_VIRT_NOTE" | json_escape)"} + ] +} +EOF +else + cat </dev/null 2>&1 && printf 'smartctl var' || printf 'smartctl yok') | $SMART_NOTE | +| NIC | $NIC_STATUS | ${NIC_LIST:-none} | $NIC_NOTE | +| NIC link | $NIC_LINK_STATUS | ${NIC_LINK_DETAIL:-none} | $NIC_LINK_NOTE | +| Seri | $SERIAL_STATUS | ${SERIAL_LIST:-none} | $SERIAL_NOTE | +| USB | $USB_STATUS | ${USB_LIST:-unavailable} | $USB_NOTE | +| Boot modu | $BOOT_STATUS | $BOOT_MODE | $BOOT_NOTE | +| Secure Boot | $SECURE_BOOT_STATUS | $SECURE_BOOT | $SECURE_BOOT_NOTE | +| TPM | $TPM_STATUS | $(if [[ -e /dev/tpm0 || -e /dev/tpmrm0 ]]; then printf 'present'; else printf 'absent'; fi) | $TPM_NOTE | +| Sanallaştırma | $VIRT_STATUS | $VIRT_TYPE | $VIRT_NOTE | +| CPU virt. | $CPU_VIRT_STATUS | $(if [[ "$CPU_VIRT_STATUS" == "SUPPORTED" ]]; then printf 'vmx/svm'; else printf 'not exposed'; fi) | $CPU_VIRT_NOTE | + +## Eşikler + +| Kontrol | BLOCKED | WARNING | SUPPORTED | +|---|---|---|---| +| Mimari | x86_64 dışı | — | x86_64 | +| RAM | < 2048 MiB | 2048–4095 MiB | ≥ 4096 MiB | +| Disk | ≥ 64 GiB disk yok | — | ≥ 64 GiB disk var | +| Disk tipi | — | yalnız döner disk (HDD) | SSD veya karışık/bilinmiyor | +| Disk sağlığı | SMART FAILED | smartctl yok / sonuç okunamadı | SMART PASSED | +| NIC | non-loopback arayüz yok | — | ≥ 1 arayüz | +| NIC link | — | hiçbir arayüz UP değil | ≥ 1 arayüz UP | +| Seri | — | hiç seri aygıt yok | ttyS/ttyUSB/ttyACM var | +| USB | — | lsusb yok/boş | aygıt listesi var | +| Boot modu | — | Legacy BIOS | UEFI | +| Secure Boot | — | UEFI'de durum okunamadı | enabled/disabled/N/A | +| TPM | — | — | present/absent (envanter alanı) | +| Sanallaştırma | — | hypervisor içinde | bare metal | +| CPU virt. | — | vmx/svm yok | vmx/svm var | + +BLOCKED: geçiş kurulumu başlatma; donanımı gözden geçir. WARNING: LAB/periferik kabulü tamamla. SUPPORTED: bu kontrolün kapsamındaki temel eşikleri geçti; 13 numaralı kabulün yerine geçmez. +EOF +fi diff --git a/scripts/diagnostics/bf-release b/scripts/diagnostics/bf-release new file mode 100755 index 0000000..c9a9e88 --- /dev/null +++ b/scripts/diagnostics/bf-release @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Print nonsecret release identity. Skeleton manifests are explicitly not deployable releases. +set -euo pipefail +if [[ -n "${BF_RELEASE_MANIFEST:-}" ]]; then MANIFEST="$BF_RELEASE_MANIFEST" +elif [[ -r /etc/blueforce/release-manifest.yaml ]]; then MANIFEST=/etc/blueforce/release-manifest.yaml +elif [[ -r /etc/blueforce-release ]]; then MANIFEST=/etc/blueforce-release +else MANIFEST="$(cd "$(dirname "$0")/../.." && pwd)/provisioning/release/manifest.yaml"; fi +[[ -r "$MANIFEST" ]] || { printf 'Field OS: unknown\nRelease status: UNKNOWN\nRelease commit: unknown\n'; exit 1; } +python3 - "$MANIFEST" <<'PY' +import sys +try: + import yaml + raw=open(sys.argv[1],encoding='utf-8').read() + data=yaml.safe_load(raw) or {} + if not isinstance(data,dict): + # Legacy /etc/blueforce-release may be a nonsecret KEY=VALUE identity file. + data={} + for line in raw.splitlines(): + if '=' in line: + key,value=line.split('=',1) + data[key.strip().lower()]=value.strip().strip('"') +except Exception: + print('Field OS: unknown'); print('Release status: INVALID_MANIFEST'); print('Release commit: unknown'); sys.exit(1) +r=data.get('release') if isinstance(data.get('release'),dict) else data +name=r.get('name') or r.get('release_id') or 'unknown' +version=r.get('version') or 'unknown' +status=str(r.get('status') or 'unknown').lower() +commit=r.get('commit') or r.get('installer_commit') or data.get('commit') or data.get('installer_commit') or 'unknown' +print(f'Field OS: {name} {version}') +if status == 'skeleton': + print('Release status: UNAPPROVED_SKELETON') + print(f'Release commit: {commit}') + sys.exit(1) +print('Release status: '+status.upper()) +print(f'Release commit: {commit}') +PY \ No newline at end of file diff --git a/scripts/diagnostics/bf-remote-status b/scripts/diagnostics/bf-remote-status new file mode 100755 index 0000000..fd6ef4f --- /dev/null +++ b/scripts/diagnostics/bf-remote-status @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Independently verify remote, network, and central evidence; never trusts READY state. +set -euo pipefail +STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}" +RUSTDESK_SYSTEM_CONFIG="${BF_RUSTDESK_SYSTEM_CONFIG:-/etc/rustdesk/RustDesk2.toml}" +RUSTDESK_USER_CONFIG="${BF_RUSTDESK_USER_CONFIG:-/home/blueforce/.config/RustDesk/RustDesk2.toml}" +MESH_CONFIG="${BF_MESH_CONFIG:-/etc/meshagent/meshagent.json}" +MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" +CHECK_MODE=0 +[[ "${1:-}" == --check && $# -eq 1 ]] && CHECK_MODE=1 +[[ $# -eq 0 || "$CHECK_MODE" == 1 ]] || { echo 'Usage: bf-remote-status [--check]' >&2; exit 2; } +[[ "$MAX_AGE" =~ ^[1-9][0-9]*$ ]] || { echo 'BF_WG_HANDSHAKE_MAX_AGE must be positive seconds' >&2; exit 2; } +valid_endpoint() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] && [[ "$1" != *example* && "$1" != *invalid* && "$1" != *'<'* && "$1" != *'>'* && "$1" != localhost* ]]; } +rustdesk_configured() { + local endpoint + endpoint="$(sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$RUSTDESK_SYSTEM_CONFIG" 2>/dev/null | head -1)" + valid_endpoint "$endpoint" && grep -Fqx "relay-server = '$endpoint'" "$RUSTDESK_SYSTEM_CONFIG" && grep -Fqx "rendezvous-server = '$endpoint'" "$RUSTDESK_USER_CONFIG" && grep -Fqx "relay-server = '$endpoint'" "$RUSTDESK_USER_CONFIG" +} +wg_fresh() { wg show wg0 latest-handshakes 2>/dev/null | awk -v now="$(date +%s)" -v max="$MAX_AGE" '$2 > 0 && now-$2 <= max {ok=1} END {exit !ok}'; } +central_verified() { python3 - "$STATE_FILE" <<'PY' +import json,sys +try: + c=json.load(open(sys.argv[1],encoding='utf-8'))['central_verification'] + ok=(c.get('status')=='verified' and bool(c.get('verification_id')) and all(isinstance(c.get(n),dict) and c[n].get('status')=='verified' and bool(c[n].get('verification_id')) for n in ('monitoring','remote','management'))) + raise SystemExit(0 if ok else 1) +except Exception: raise SystemExit(1) +PY +} +fail=0 +systemctl is-enabled --quiet xrdp 2>/dev/null && systemctl is-active --quiet xrdp 2>/dev/null && systemctl is-enabled --quiet xrdp-sesman 2>/dev/null && systemctl is-active --quiet xrdp-sesman 2>/dev/null && xrdp='enabled-active' || { xrdp='not-ready'; fail=1; } +dpkg-query -W -f='${Status}' rustdesk 2>/dev/null | grep -q 'install ok installed' && rustdesk_configured && rustdesk='configured' || { rustdesk='not-ready'; fail=1; } +wg_fresh && wireguard='fresh-handshake' || { wireguard='not-ready'; fail=1; } +if [[ -e "$MESH_CONFIG" ]]; then systemctl is-enabled --quiet meshagent 2>/dev/null && systemctl is-active --quiet meshagent 2>/dev/null && mesh='configured-active' || { mesh='not-ready'; fail=1; }; else mesh='not-configured'; fi +central_verified && central='verified' || { central='PENDING'; fail=1; } +printf 'xRDP: %s\nRustDesk: %s\nWireGuard: %s\nMesh: %s\nCentral verification: %s\n' "$xrdp" "$rustdesk" "$wireguard" "$mesh" "$central" +[[ "$fail" -eq 0 ]] \ No newline at end of file diff --git a/scripts/diagnostics/bf-status b/scripts/diagnostics/bf-status new file mode 100755 index 0000000..9333961 --- /dev/null +++ b/scripts/diagnostics/bf-status @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# +# bf-status - Quick health glance for a Blueforce field terminal. +# +# Output contract: docs/14-MONITORING-AND-HEALTH.md ("bf-status ornek ciktisi") +# for the health block, plus the Field OS lifecycle block defined by +# docs/29-DEVICE-ENROLLMENT.md and provisioning/enrollment/README.md: +# +# Provisioning : PROVISIONED_OFFLINE | COMPLETE +# Enrollment : PENDING | ENROLLED | COMPLETE +# Fleet State : NOT READY | READY +# +# The lifecycle block is derived read-only from /var/lib/blueforce/state.json +# (override with BF_STATE_FILE). A missing state file shows PENDING, and a +# contract-violating state (skipped stage, backwards move, READY without +# verified central evidence) shows INVALID and counts as a critical alert. +# Never prints secrets, keys, or passwords. +# +# Install: install -m 0755 bf-status /usr/local/bin/bf-status +# Usage: bf-status [--help] +# +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bf-status [--help] + +Print the BLUEFORCE FIELD TERMINAL health summary: the Field OS lifecycle +block (docs/29) followed by the 16-line health block (docs/14). + +Lifecycle vocabulary: + Provisioning : PROVISIONED_OFFLINE | COMPLETE | PENDING + Enrollment : PENDING | ENROLLED | COMPLETE + Fleet State : NOT READY | READY + +Options: + --help Show this help and exit + +Exit status is always 0; individual failing probes report DOWN/UNKNOWN +inline instead of aborting the report. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +elif [[ $# -gt 0 ]]; then + echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 +fi + +# Never abort the report on a failing probe. +set +e + +HOST="$(hostname 2>/dev/null || echo unknown)" +DEVICE_ID="$(echo "${HOST}" | tr '[:lower:]' '[:upper:]')" +NOW="$(date '+%Y-%m-%d %H:%M %z' 2>/dev/null || echo unknown)" +OVERALL="OK" + +uptime_str() { + uptime -p 2>/dev/null | sed 's/^up //' || echo unknown +} +load_str() { + awk '{print $1, $2, $3}' /proc/loadavg 2>/dev/null || echo "unknown" +} +cpu_pct() { + # 1-second sample from /proc/stat. + read -r _ a b c d e f g h i j < /proc/stat 2>/dev/null || { echo "?"; return; } + idle1=$((d + e)); total1=$((a+b+c+d+e+f+g+h+i+j)) + sleep 1 + read -r _ a b c d e f g h i j < /proc/stat 2>/dev/null || { echo "?"; return; } + idle2=$((d + e)); total2=$((a+b+c+d+e+f+g+h+i+j)) + dt=$((total2 - total1)); di=$((idle2 - idle1)) + if [[ "${dt}" -le 0 ]]; then echo "?"; else echo $(( (100 * (dt - di)) / dt )); fi +} +mem_str() { + local avail total + avail="$(awk '/MemAvailable/ {print $2}' /proc/meminfo 2>/dev/null)" + total="$(awk '/MemTotal/ {print $2}' /proc/meminfo 2>/dev/null)" + if [[ -n "${avail}" && -n "${total}" && "${total}" -gt 0 ]]; then + local used=$((total - avail)) + local pct=$((100 * used / total)) + printf '%s/%sG (%s%%)' \ + "$(awk -v v="${used}" 'BEGIN{printf "%.1f", v/1048576}')" \ + "$(awk -v v="${total}" 'BEGIN{printf "%.1f", v/1048576}')" \ + "${pct}" + else + echo "unknown" + fi +} +disk_str() { + df -BG / 2>/dev/null | awk 'NR==2 {gsub(/G/,"",$2); gsub(/G/,"",$3); gsub(/%/,"",$5); printf "%s/%sG (%s%%)", $3, $2, $5}' || echo "unknown" +} +svc_state() { # $1 = unit + if ! command -v systemctl >/dev/null 2>&1; then echo "UNKNOWN"; return; fi + if systemctl is-active --quiet "$1" 2>/dev/null; then echo "OK"; return; fi + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^$1"; then echo "DOWN"; else echo "OFF"; fi +} +wg_info() { + if ! command -v wg >/dev/null 2>&1; then echo "wg0 MISSING (no wg tool)"; return; fi + if ! ip link show wg0 >/dev/null 2>&1; then echo "wg0 DOWN (no interface)"; return; fi + local hs + hs="$(wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)" + if [[ -z "${hs}" || "${hs}" == "0" ]]; then echo "wg0 UP, no handshake yet"; return; fi + local now ago + now="$(date +%s)"; ago=$((now - hs)) + echo "wg0 UP, handshake ${ago}s ago" +} +net_counters() { + local rx=0 tx=0 f + for f in /sys/class/net/*/statistics/rx_bytes; do rx=$((rx + $(cat "$f" 2>/dev/null || echo 0))); done + for f in /sys/class/net/*/statistics/tx_bytes; do tx=$((tx + $(cat "$f" 2>/dev/null || echo 0))); done + echo "rx $(numfmt --to=iec "$rx" 2>/dev/null || echo "$rx") tx $(numfmt --to=iec "$tx" 2>/dev/null || echo "$tx")" +} +docker_info() { + if ! command -v docker >/dev/null 2>&1; then echo "daemon MISSING"; return; fi + if docker info >/dev/null 2>&1; then echo "daemon UP"; else echo "daemon DOWN"; fi +} +meg_info() { + # MEG container name convention: meg-* ; report first match. + if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then echo "meg:unknown (docker down)"; return; fi + local line + line="$(docker ps --format '{{.Names}} {{.Status}} {{.RestartCount}}' 2>/dev/null | grep -i '^meg' | head -1)" + if [[ -z "${line}" ]]; then echo "meg NOT RUNNING"; return; fi + echo "${line}" +} +meg_last_success() { + local f="/var/lib/blueforce/meg_last_success" + if [[ -f "${f}" ]]; then + local ts now + ts="$(cat "${f}" 2>/dev/null | tr -d '[:space:]')" + now="$(date +%s)" + if [[ "${ts}" =~ ^[0-9]+$ ]]; then echo "UP, last success $((now - ts))s ago"; else echo "UP, last success unknown"; fi + else + echo "unknown (no timestamp file)" + fi +} +unattended_state() { + local masked="no" cfg="unknown" + if systemctl is-enabled apt-daily.timer 2>/dev/null | grep -q masked; then masked="yes"; fi + if grep -Eq '"0"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; then cfg="0"; fi + if [[ "${masked}" == "yes" && "${cfg}" == "0" ]]; then echo "DISABLED (timer masked)"; else echo "CHECK (timer/cfg drift)"; fi +} +ufw_str() { + if ! command -v ufw >/dev/null 2>&1; then echo "ufw not installed"; return; fi + if ufw status 2>/dev/null | head -1 | grep -qi active; then echo "active, default DENY in | 22/3389 wg0-only"; else echo "INACTIVE"; fi +} +gui_str() { + local target sessions="0" + target="$(systemctl get-default 2>/dev/null || echo unknown)" + if ss -ltn 2>/dev/null | grep -q ':3389 '; then sessions="$(ss -tn state established '( dport = :3389 or sport = :3389 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')"; fi + if [[ "${target}" == "graphical.target" ]]; then echo "ON (graphical) | rdp sessions: ${sessions}"; else echo "OFF (multi-user) | rdp sessions: ${sessions}"; fi +} +kuma_str() { + local f="/var/lib/blueforce/kuma_last_push" + if [[ -f "${f}" ]]; then + local ts now + ts="$(cat "${f}" 2>/dev/null | tr -d '[:space:]')" + now="$(date +%s)" + if [[ "${ts}" =~ ^[0-9]+$ ]]; then echo "OK $((now - ts))s ago"; else echo "unknown"; fi + else + echo "unknown (no push record)" + fi +} +scrape_str() { + local f="/var/lib/node_exporter/textfile/last_scrape" + if [[ -f "${f}" ]]; then echo "OK ($(cat "${f}" 2>/dev/null | tr -d '[:space:]'))"; else echo "unknown"; fi +} +rustdesk_str() { + if pgrep -x rustdesk >/dev/null 2>&1 || systemctl is-active --quiet rustdesk 2>/dev/null; then echo "online"; else echo "offline"; fi +} +mesh_str() { + if pgrep -i meshcentral >/dev/null 2>&1 || systemctl is-active --quiet meshagent 2>/dev/null; then echo "online"; else echo "offline"; fi +} +boot_str() { + local b + b="$(who -b 2>/dev/null | awk '{print $3, $4}' || echo unknown)" + echo "${b} (power-loss recovery OK)" +} +alerts_str() { + # Warning heuristic: disk use > 80% counts one warning. + # A lifecycle state that violates the forward-only contract is critical. + local pct + if [[ "${LIFECYCLE_INVALID:-0}" -eq 1 ]]; then + echo '1 critical, 0 warning (lifecycle state invalid)' + return + fi + pct="$(df / 2>/dev/null | awk 'NR==2 {gsub(/%/,"",$5); print $5}')" + if [[ -n "${pct}" && "${pct}" -ge 90 ]] 2>/dev/null; then echo "1 critical, 0 warning (disk ${pct}%)"; + elif [[ -n "${pct}" && "${pct}" -ge 80 ]] 2>/dev/null; then echo "0 critical, 1 warning (disk trend)"; + else echo "0 critical, 0 warning"; fi +} + +DOCKER_SVC="$(svc_state docker)" +WG_SVC="$(svc_state "wg-quick@wg0")" +XRDP_SVC="$(svc_state xrdp)" +NODE_SVC="$(svc_state node_exporter)" + +FIELD_STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}" +LIFECYCLE_PHASE='PENDING' +LIFECYCLE_PROVISIONING='PENDING' +LIFECYCLE_ENROLLMENT='PENDING' +LIFECYCLE_FLEET='NOT READY' +LIFECYCLE_INVALID=0 +# Lifecycle vocabulary is fixed by docs/29; values are never upgraded locally: +# only bf-enroll (ENROLLED) and the final install gate (READY) may advance them. +read_lifecycle() { + local out lifecycle enrollment fleet + out="$("$(dirname "$0")/bf-enrollment-status" 2>/dev/null)" || true + lifecycle="$(printf '%s\n' "$out" | sed -n 's/^Lifecycle: //p')" + enrollment="$(printf '%s\n' "$out" | sed -n 's/^Enrollment: //p')" + fleet="$(printf '%s\n' "$out" | sed -n 's/^Fleet: //p')" + case "$lifecycle" in + PROVISIONED_OFFLINE) LIFECYCLE_PHASE='PROVISIONED_OFFLINE'; LIFECYCLE_PROVISIONING='PROVISIONED_OFFLINE' ;; + ENROLLED) LIFECYCLE_PHASE='ENROLLED'; LIFECYCLE_PROVISIONING='COMPLETE' ;; + READY) LIFECYCLE_PHASE='READY'; LIFECYCLE_PROVISIONING='COMPLETE' ;; + INVALID) LIFECYCLE_PHASE='INVALID'; LIFECYCLE_PROVISIONING='PENDING'; LIFECYCLE_INVALID=1 ;; + *) LIFECYCLE_PHASE='PENDING'; LIFECYCLE_PROVISIONING='PENDING' ;; + esac + case "$enrollment" in + PENDING|ENROLLED|COMPLETE) LIFECYCLE_ENROLLMENT="$enrollment" ;; + *) LIFECYCLE_ENROLLMENT='PENDING' ;; + esac + case "$fleet" in + READY) LIFECYCLE_FLEET='READY' ;; + *) LIFECYCLE_FLEET='NOT READY' ;; + esac +} +release_commit() { git -C "$(cd "$(dirname "$0")/../.." && pwd)" rev-parse --short HEAD 2>/dev/null || echo unknown; } + +release_info() { "$(dirname "$0")/bf-release" 2>&1 || true; } + +read_lifecycle +[[ "$LIFECYCLE_INVALID" -eq 1 ]] && OVERALL='WARN' +printf 'Field OS lifecycle: %s | Release: %s\n' "$LIFECYCLE_PHASE" "$(release_info | paste -sd ';' -)" +printf 'Provisioning : %s\n' "$LIFECYCLE_PROVISIONING" +printf 'Enrollment : %s\n' "$LIFECYCLE_ENROLLMENT" +printf 'Fleet State : %s\n' "$LIFECYCLE_FLEET" +printf '%s %s %s\n' "${DEVICE_ID}" "${NOW}" "${OVERALL}" +printf 'uptime: %s | load: %s\n' "$(uptime_str)" "$(load_str)" +printf 'cpu: %s%% | mem: %s | disk /: %s\n' "$(cpu_pct)" "$(mem_str)" "$(disk_str)" +printf 'net: %s | %s\n' "$(wg_info)" "$(net_counters)" +printf 'docker: %s | %s\n' "$(docker_info)" "$(meg_info)" +printf 'meg: %s\n' "$(meg_last_success)" +printf 'systemd: docker %s | wg-quick@wg0 %s | xrdp %s | node_exporter %s\n' "${DOCKER_SVC}" "${WG_SVC}" "${XRDP_SVC}" "${NODE_SVC}" +printf 'unattended-upgrades: %s\n' "$(unattended_state)" +printf 'ufw: %s\n' "$(ufw_str)" +printf 'gui: %s\n' "$(gui_str)" +printf 'kuma push: %s | prometheus scrape: %s\n' "$(kuma_str)" "$(scrape_str)" +printf 'rustdesk: %s | meshcentral: %s\n' "$(rustdesk_str)" "$(mesh_str)" +printf 'boot: %s\n' "$(boot_str)" +printf 'alerts: %s\n' "$(alerts_str)" +printf 'next: support bundle: bf-support-bundle\n' diff --git a/scripts/diagnostics/bf-support-bundle b/scripts/diagnostics/bf-support-bundle new file mode 100755 index 0000000..920f1e6 --- /dev/null +++ b/scripts/diagnostics/bf-support-bundle @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# +# bf-support-bundle - Collect a secret-free support tarball for a Blueforce field terminal. +# +# Gathers bf-status + bf-diagnostics output, journal tail, textfile collectors +# and basic system facts into a tar.gz. Files matching secret patterns +# (*.key, *secret*, password material, private keys) are NEVER included, +# and key-like values in captured command output are redacted. +# A manifest.txt inside the bundle lists every file + collection time. +# +# Output contract: docs/14-MONITORING-AND-HEALTH.md ("bf-support-bundle ... +# secret ... ASLA pakete girmez"). +# +# Install: install -m 0755 bf-support-bundle /usr/local/bin/bf-support-bundle +# Usage: bf-support-bundle [--out-dir DIR] [--help] +# +set -euo pipefail + +OUT_DIR="/var/tmp" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +usage() { + cat <<'EOF' +Usage: bf-support-bundle [--out-dir DIR] [--help] + +Collect a secret-free support bundle (tar.gz with manifest.txt). + +Options: + --out-dir DIR Directory for the tarball (default: /var/tmp) + --help Show this help and exit + +Excluded (never packaged): + *.key, *secret*, *password*, *passwd*, *private*, + *id_rsa*, *id_ed25519*, *.pem, WireGuard private keys, + RustDesk passwords, SSH private keys. + +Manifest: + manifest.txt inside the tarball lists each file, byte size, + collection timestamp, and the exclusion patterns applied. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --out-dir) OUT_DIR="${2:?missing value for --out-dir}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +HOST="$(hostname 2>/dev/null || echo unknown)" +STAMP="$(date '+%Y%m%d-%H%M%S' 2>/dev/null || echo unknown)" +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT + +BUNDLE_NAME="blueforce-support-${HOST}-${STAMP}" +STAGE="${WORK}/${BUNDLE_NAME}" +mkdir -p "${STAGE}" + +log() { echo "[bf-support-bundle] $*" >&2; } + +# Exclude patterns: filename-level, applied to every copy step and tar. +EXCLUDE_PATTERNS=( + '*.key' '*secret*' '*password*' '*passwd*' '*private*' + '*id_rsa*' '*id_ed25519*' '*.pem' '*.p12' '*.pfx' +) +is_excluded() { # $1 = basename + local base="$1" pat + for pat in "${EXCLUDE_PATTERNS[@]}"; do + # shellcheck disable=SC2053 + [[ "${base}" == ${pat} ]] && return 0 + done + return 1 +} + +redact() { # stdin -> stdout: mask key-like values + sed -E \ + -e 's/(private-key|public-key|preshared-key)([[:space:]]*[:=][[:space:]]*).*/\1\2/I' \ + -e 's/(password|passwd|secret|token)([[:space:]]*[:=][[:space:]]*)[^[:space:]]+/\1\2/I' +} + +collect_cmd() { # $1 = outfile, $@ = command + local out="$1"; shift + { "$@" 2>&1 || echo "(command failed: $*)"; } | redact > "${out}" || true +} + +log "collecting into ${STAGE}" + +collect_cmd "${STAGE}/bf-status.txt" "${SCRIPT_DIR}/bf-status" || true +if [[ -x "${SCRIPT_DIR}/bf-diagnostics" ]]; then + collect_cmd "${STAGE}/bf-diagnostics.txt" "${SCRIPT_DIR}/bf-diagnostics" || true +fi +collect_cmd "${STAGE}/system.txt" bash -c 'hostname; uptime; uname -a; systemctl get-default 2>/dev/null || echo "target: unknown"' +collect_cmd "${STAGE}/disk-df.txt" bash -c 'df -h / /var 2>/dev/null; echo ---; df -i / /var 2>/dev/null' +collect_cmd "${STAGE}/sockets.txt" bash -c 'ss -ltnp 2>/dev/null || ss -ltn 2>/dev/null || echo "ss not available"' +collect_cmd "${STAGE}/failed-units.txt" bash -c 'systemctl --failed --no-pager 2>/dev/null || echo "no failed units"' + +if command -v journalctl >/dev/null 2>&1; then + journalctl --since "24 hours ago" -p err --no-pager -o short 2>/dev/null | redact | tail -200 > "${STAGE}/journal-err-24h.txt" || echo "no journal access" > "${STAGE}/journal-err-24h.txt" +else + echo "journalctl not available." > "${STAGE}/journal-err-24h.txt" +fi + +if command -v wg >/dev/null 2>&1; then + { wg show 2>/dev/null | redact || echo "no WireGuard interfaces"; echo "--- latest handshakes ---"; wg show all latest-handshakes 2>/dev/null || echo "no handshake data"; } > "${STAGE}/wireguard.txt" || true +else + echo "wg tool not installed." > "${STAGE}/wireguard.txt" +fi + +# Textfile collectors: copy *.prom only, never anything matching excludes. +mkdir -p "${STAGE}/textfile" +if [[ -d /var/lib/node_exporter/textfile ]]; then + while IFS= read -r -d '' f; do + base="$(basename "${f}")" + if is_excluded "${base}"; then + log "skipping excluded file: ${base}" + continue + fi + cp "${f}" "${STAGE}/textfile/${base}" 2>/dev/null || true + done < <(find /var/lib/node_exporter/textfile -maxdepth 1 -name '*.prom' -type f -print0 2>/dev/null || true) + if [[ -z "$(ls -A "${STAGE}/textfile" 2>/dev/null)" ]]; then + echo "textfile dir empty." > "${STAGE}/textfile/README.txt" + fi +else + echo "textfile dir missing." > "${STAGE}/textfile/README.txt" +fi + +# Manifest: file list + sizes + timestamp + exclusion rules. +{ + echo "blueforce support bundle manifest" + echo "host: ${HOST}" + echo "collected_at: $(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo unknown)" + echo "generator: bf-support-bundle" + echo "excluded_patterns: ${EXCLUDE_PATTERNS[*]}" + echo "note: secrets, private keys and passwords are never included; key-like values redacted." + echo "" + echo "files:" + ( cd "${STAGE}" && find . -type f -printf '%p %s bytes\n' 2>/dev/null | sort ) || ( cd "${STAGE}" && find . -type f -exec wc -c {} + | sort ) +} > "${STAGE}/manifest.txt" + +mkdir -p "${OUT_DIR}" +TARBALL="${OUT_DIR}/${BUNDLE_NAME}.tar.gz" +tar --sort=name --mtime='UTC 2020-01-01' \ + --exclude='*.key' --exclude='*secret*' --exclude='*password*' \ + --exclude='*passwd*' --exclude='*private*' \ + --exclude='*id_rsa*' --exclude='*id_ed25519*' --exclude='*.pem' \ + -czf "${TARBALL}" -C "${WORK}" "${BUNDLE_NAME}" + +log "wrote ${TARBALL}" +echo "${TARBALL}" diff --git a/scripts/install/README.md b/scripts/install/README.md new file mode 100644 index 0000000..aea6deb --- /dev/null +++ b/scripts/install/README.md @@ -0,0 +1,27 @@ +# Blueforce installer (`scripts/install/`) + +Single entry point: `sudo ./blueforce-install.sh --dealer-id <8 digits>`. + +- Orchestrator: `blueforce-install.sh` (dealer-id gate `^[0-9]{8}$`, + hostname `bf-` + device `BF-`, ordered module runs, `--resume`, + results to `/var/log/blueforce-install.log` + `/var/lib/blueforce/install-state`). +- Modules: `installer/modules/01-*.sh` … `18-*.sh`. Every module is idempotent + and supports `--check` (read-only audit). Exit `0` = OK, `3` = SKIP. +- Required remote-channel preconditions: set per-device `WG_ADDRESS`, `WG_ENDPOINT`, + and `WG_PEER_PUBKEY`; WireGuard readiness requires a peer handshake no older than + `BF_WG_HANDSHAKE_MAX_AGE` seconds (default `180`) unless `REQUIRE_WIREGUARD=false`. + Set `RUSTDESK_SERVER` to a real self-hosted endpoint and provide the pinned local + `RUSTDESK_DEB`; placeholders are rejected. +- Docker published ports default to deny. Add only reviewed `tcp PORT` or `udp PORT` + entries to `/etc/blueforce/docker-published-port-allowlist.conf`; the enabled + `blueforce-docker-firewall.service` reapplies that policy after Docker starts. +- Exit codes: `0` READY · `1` module failure · `2` argument/format error. + +Module map: 01-precheck · 02-system · 03-user · 04-hostname · 05-network · +06-wireguard · 07-ssh · 08-rdp · 09-rustdesk · 10-docker · 11-meg · +12-monitoring · 13-firewall · 14-update-policy · 15-logrotate · 16-gui · +17-healthcheck · 18-final-check. + +NOTE on AnyDesk: there is deliberately no AnyDesk module. Remote access is +covered by SSH-over-WireGuard, xRDP and RustDesk. AnyDesk stays a manual, +per-device exception outside this installer. diff --git a/scripts/install/blueforce-install.sh b/scripts/install/blueforce-install.sh new file mode 100755 index 0000000..51a88e7 --- /dev/null +++ b/scripts/install/blueforce-install.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Blueforce one-click installer — single entry point for field setup. +set -euo pipefail +INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULES_DIR="$INSTALLER_DIR/installer/modules" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +STATE_FILE="$STATE_DIR/install-state" +MODULES=(01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 09-rustdesk 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck 18-final-check) +DEALER_ID="" FROM="" ONLY="" CHECK_MODE=0 RESUME=0 ASSUME_YES=0 OFFLINE=0 +BF_OFFLINE_REPO="${BF_OFFLINE_REPO:-/opt/blueforce/offline-repo}" +usage() { cat <<'EOF' +Usage: sudo ./blueforce-install.sh --dealer-id <8 digits> [--offline] [--from NN] [--only NAME] [--check] [--resume] [--yes] +--offline uses supplied local packages only and completes as PROVISIONED_OFFLINE. +--check is read-only: it creates no logs, state, or directories. +EOF +} +while [[ $# -gt 0 ]]; do case "$1" in --dealer-id) DEALER_ID="${2:-}"; shift 2;; --offline) OFFLINE=1; shift;; --from) FROM="${2:-}"; shift 2;; --only) ONLY="${2:-}"; shift 2;; --check) CHECK_MODE=1; shift;; --resume) RESUME=1; shift;; --yes|-y) ASSUME_YES=1; shift;; --help|-h) usage; exit 0;; *) echo "Unknown argument: $1" >&2; usage >&2; exit 2;; esac; done +[[ -n "$DEALER_ID" ]] || { echo "Missing --dealer-id (8 digits)" >&2; exit 2; } +[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { echo "Invalid dealer id '$DEALER_ID': must match ^[0-9]{8}$" >&2; exit 2; } +log() { if [[ "$CHECK_MODE" -eq 1 ]]; then printf '[installer check] %s\n' "$1" >&2; else mkdir -p "$(dirname "$LOG_FILE")" "$STATE_DIR"; echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [installer] $1" | tee -a "$LOG_FILE"; fi; } +record_state() { mkdir -p "$STATE_DIR"; local tmp="$STATE_FILE.tmp"; grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$tmp" || true; echo "$1=$2" >> "$tmp"; mv "$tmp" "$STATE_FILE"; } +state_of() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true; } +export DEALER_ID BF_HOSTNAME="bf-${DEALER_ID}" BF_DEVICE="BF-${DEALER_ID}" STATE_DIR BF_OFFLINE="$OFFLINE" BF_OFFLINE_REPO +validate_offline_repository() { + local manifest pin package version source + [[ "$BF_OFFLINE_REPO" = /* && -d "$BF_OFFLINE_REPO" ]] || { echo "OFFLINE BLOCKED: BF_OFFLINE_REPO must be an existing absolute local directory." >&2; return 1; } + [[ -s "$BF_OFFLINE_REPO/Packages" && -s "$BF_OFFLINE_REPO/Packages.gz" && -s "$BF_OFFLINE_REPO/packages.lock.tsv" && -s "$BF_OFFLINE_REPO/SHA256SUMS" ]] || { echo 'OFFLINE BLOCKED: local APT index, lock, or checksums are missing; ISO bundle is incomplete.' >&2; return 1; } + (cd "$BF_OFFLINE_REPO" && sha256sum -c SHA256SUMS >/dev/null) || { echo 'OFFLINE BLOCKED: local APT repository checksum validation failed.' >&2; return 1; } + awk -F '\t' 'NR == 1 { if ($0 != "package\tversion\tarchitecture\tsha256\tsource") exit 1; next } $1 !~ /^[a-z0-9][a-z0-9+.-]*$/ || $2 == "" || $3 == "" || $4 !~ /^[a-f0-9]{64}$/ || $5 == "" { exit 1 } END { exit NR < 2 }' "$BF_OFFLINE_REPO/packages.lock.tsv" || { echo 'OFFLINE BLOCKED: package lock is empty or invalid.' >&2; return 1; } + for manifest in "$INSTALLER_DIR/../../provisioning/offline-repo/manifests"/*.txt; do + [[ -s "$manifest" ]] || { echo "OFFLINE BLOCKED: required pin manifest is empty: $(basename "$manifest")." >&2; return 1; } + while IFS= read -r pin || [[ -n "$pin" ]]; do + [[ -z "$pin" || "$pin" == \#* ]] && continue + [[ "$pin" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ ]] && [[ "$pin" != *placeholder* && "$pin" != *REPLACE* && "$pin" != *replace* ]] || { echo "OFFLINE BLOCKED: invalid package pin in $(basename "$manifest")." >&2; return 1; } + package="${pin%%=*}"; version="${pin#*=}" + source="$(awk -F '\t' -v p="$package" -v v="$version" '$1 == p && $2 == v {print $5; exit}' "$BF_OFFLINE_REPO/packages.lock.tsv")" + [[ -n "$source" && -f "$BF_OFFLINE_REPO/pool/$source" ]] || { echo "OFFLINE BLOCKED: pinned package absent from bundle: $package." >&2; return 1; } + done < "$manifest" + grep -qvE '^[[:space:]]*(#|$)' "$manifest" || { echo "OFFLINE BLOCKED: required pin manifest has no package pins: $(basename "$manifest")." >&2; return 1; } + done + install -d -m 700 "$STATE_DIR" + printf 'deb [trusted=yes] file:%s ./\n' "$BF_OFFLINE_REPO" > "$STATE_DIR/offline.list" + chmod 600 "$STATE_DIR/offline.list" + BF_OFFLINE_APT_CONFIG="$(mktemp)"; chmod 600 "$BF_OFFLINE_APT_CONFIG" + printf 'Dir::Etc::sourcelist "%s";\nDir::Etc::sourceparts "-";\nAcquire::Languages "none";\n' "$STATE_DIR/offline.list" > "$BF_OFFLINE_APT_CONFIG" + export BF_OFFLINE_APT_CONFIG +} +if [[ "$OFFLINE" -eq 1 ]]; then + validate_offline_repository || exit 1 +fi +if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then read -r -p "Proceed with installation as $BF_DEVICE? [y/N] " answer; [[ "$answer" =~ ^[Yy]$ ]] || { echo Aborted.; exit 2; }; fi +RUN=(); for m in "${MODULES[@]}"; do + if [[ -n "$ONLY" ]]; then [[ "$m" == "$ONLY" || "$m" == "$ONLY"* ]] && RUN+=("$m"); continue; fi + [[ -n "$FROM" && "${m%%-*}" < "$FROM" ]] && continue + if [[ "$CHECK_MODE" -eq 0 && "$RESUME" -eq 1 && "$(state_of "$m")" == OK ]]; then log "SKIP $m (already OK)"; continue; fi + RUN+=("$m") +done +[[ ${#RUN[@]} -gt 0 ]] || { echo "No modules selected." >&2; exit 2; } +log "=== install start dealer=$BF_DEVICE mode=$([[ "$CHECK_MODE" -eq 1 ]] && echo check || echo apply) modules=${RUN[*]} ===" +failures=0 +for m in "${RUN[@]}"; do + script="$MODULES_DIR/$m.sh"; if [[ ! -x "$script" ]]; then log "FAIL $m (script missing)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); break; fi + args=(); [[ "$CHECK_MODE" -eq 1 ]] && args+=(--check) + set +e; if [[ "$CHECK_MODE" -eq 1 ]]; then LOG_FILE=/dev/null bash "$script" "${args[@]}"; else LOG_FILE="$LOG_FILE" bash "$script"; fi; rc=$?; set -e + case "$rc" in 0) log "OK $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" OK;; 3) log "SKIP $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" SKIP;; *) log "FAIL $m (exit $rc)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); [[ "$CHECK_MODE" -eq 0 ]] && exit 1;; esac +done +[[ "$CHECK_MODE" -eq 1 && "$failures" -gt 0 ]] && exit 1 +if [[ "$CHECK_MODE" -eq 0 && "$OFFLINE" -eq 1 && "$failures" -eq 0 ]]; then + install -d -m 700 "$STATE_DIR" + umask 077 + python3 - "$STATE_DIR/state.json" "$DEALER_ID" <<'PY' +import json, os, sys, tempfile, uuid +path, dealer = sys.argv[1:] +state={'phase':'PROVISIONED_OFFLINE','enrollment_status':'PENDING','fleet_status':'PENDING','device_id':'BF-'+dealer,'provisioning_id':str(uuid.uuid4())} +fd,tmp=tempfile.mkstemp(prefix='state.json.',dir=os.path.dirname(path)); os.fchmod(fd,0o600) +with os.fdopen(fd,'w') as f: json.dump(state,f,sort_keys=True); f.write('\n') +os.replace(tmp,path) +PY + chmod 600 "$STATE_DIR/state.json" + log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY" +fi +log "=== install done dealer=$BF_DEVICE failures=$failures ===" \ No newline at end of file diff --git a/scripts/install/installer/modules/01-precheck.sh b/scripts/install/installer/modules/01-precheck.sh new file mode 100755 index 0000000..39c6155 --- /dev/null +++ b/scripts/install/installer/modules/01-precheck.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Pre-flight checks: root, Ubuntu 26.04, RAM/disk, internet. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="01-precheck" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [01-precheck] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +MIN_RAM_MB=2048 +MIN_DISK_MB=20480 + +fail() { log "FAIL: $1"; echo "FAIL: $1" >&2; exit 1; } + +run_checks() { + [[ "$(id -u)" -eq 0 ]] || fail "must run as root" + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + [[ "${VERSION_ID:-}" == 26.04* ]] || fail "unsupported OS: ${NAME:-unknown} ${VERSION_ID:-unknown} (need Ubuntu 26.04)" + else + fail "/etc/os-release not found" + fi + local ram_mb disk_mb + ram_mb=$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo) + [[ "$ram_mb" -ge "$MIN_RAM_MB" ]] || fail "RAM too low: ${ram_mb}MB (need >= ${MIN_RAM_MB}MB)" + disk_mb=$(df -m / --output=avail | tail -1 | tr -d ' ') + [[ "$disk_mb" -ge "$MIN_DISK_MB" ]] || fail "disk too low: ${disk_mb}MB free on / (need >= ${MIN_DISK_MB}MB)" + if [[ "${BF_OFFLINE:-0}" != 1 ]] && ! getent hosts archive.ubuntu.com >/dev/null 2>&1 && ! ping -c1 -W5 1.1.1.1 >/dev/null 2>&1; then + fail "no internet connectivity (DNS and ping both failed)" + fi + log "OK: precheck passed (Ubuntu ${VERSION_ID}, RAM ${ram_mb}MB, disk ${disk_mb}MB free; offline=${BF_OFFLINE:-0})" +} + +run_checks diff --git a/scripts/install/installer/modules/02-system.sh b/scripts/install/installer/modules/02-system.sh new file mode 100755 index 0000000..854a3e4 --- /dev/null +++ b/scripts/install/installer/modules/02-system.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Base APT sources, disable unattended-upgrades, set timezone. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="02-system" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +# shellcheck source=../offline-apt.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [02-system] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +TIMEZONE="${TIMEZONE:-Europe/Istanbul}" +BASE_PKGS="curl wget ca-certificates gnupg lsb-release" + +check_state() { + local ok=1 + for p in $BASE_PKGS; do dpkg -s "$p" >/dev/null 2>&1 || { echo "missing package: $p"; ok=0; }; done + grep -q '^APT::Periodic::Update-Package-Lists "0"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null || { echo "20auto-upgrades not locked"; ok=0; } + for t in apt-daily.service apt-daily.timer apt-daily-upgrade.timer; do + systemctl is-enabled "$t" 2>/dev/null | grep -q "masked" || { echo "timer not masked: $t"; ok=0; } + done + [[ "$(cat /etc/timezone 2>/dev/null || timedatectl show -p Timezone --value 2>/dev/null)" == "$TIMEZONE" ]] || { echo "timezone not $TIMEZONE"; ok=0; } + return $((1 - ok)) +} + +if [[ "$CHECK_MODE" -eq 1 ]]; then + if check_state; then log "OK(check): system baseline matches"; else log "FAIL(check): system baseline drift"; exit 1; fi + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +bf_apt update +# shellcheck disable=SC2086 +bf_apt_install $BASE_PKGS + +# Lock automatic updates OFF (fleet policy: no unapproved updates). +printf 'APT::Periodic::Update-Package-Lists "0";\nAPT::Periodic::Unattended-Upgrade "0";\n' > /tmp/20auto-upgrades.bf +mv /tmp/20auto-upgrades.bf /etc/apt/apt.conf.d/20auto-upgrades +for t in apt-daily.service apt-daily.timer apt-daily-upgrade.timer apt-daily-upgrade.service; do + systemctl mask "$t" >/dev/null 2>&1 || true +done + +if command -v timedatectl >/dev/null 2>&1; then + timedatectl set-timezone "$TIMEZONE" || echo "$TIMEZONE" > /etc/timezone +else + echo "$TIMEZONE" > /etc/timezone +fi +log "OK: base packages installed, unattended-upgrades disabled, timezone $TIMEZONE" diff --git a/scripts/install/installer/modules/03-user.sh b/scripts/install/installer/modules/03-user.sh new file mode 100755 index 0000000..2b30216 --- /dev/null +++ b/scripts/install/installer/modules/03-user.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Blueforce maintenance user, restricted sudoers, mandatory administrator key. +set -euo pipefail +MODULE="03-user" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SUDOERS_FILE="/etc/sudoers.d/blueforce" +AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" +BF_ADMIN_PUBLIC_KEY="${BF_ADMIN_PUBLIC_KEY:-}" +BF_ADMIN_PUBLIC_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; } +valid_key_file() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } +load_admin_key() { + [[ -z "$BF_ADMIN_PUBLIC_KEY" && -n "$BF_ADMIN_PUBLIC_KEY_FILE" && -r "$BF_ADMIN_PUBLIC_KEY_FILE" ]] || { log "FAIL: a seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required; inline keys are refused"; return 1; } + [[ "$(stat -c '%U:%a' "$BF_ADMIN_PUBLIC_KEY_FILE")" =~ ^root:6[04]0$ ]] || { log "FAIL: administrator key file must be root-owned and not group/world writable"; return 1; } + grep -qE '^(ssh-(ed25519|rsa|ecdsa)|sk-ssh-ed25519)' "$BF_ADMIN_PUBLIC_KEY_FILE" && ! grep -q 'PRIVATE KEY' "$BF_ADMIN_PUBLIC_KEY_FILE" || { log "FAIL: administrator key file must contain a public SSH key only"; return 1; } + cat "$BF_ADMIN_PUBLIC_KEY_FILE" +} +if [[ "$CHECK_MODE" -eq 1 ]]; then + id blueforce >/dev/null 2>&1 || { log "FAIL(check): user blueforce missing"; exit 1; } + [[ -f "$SUDOERS_FILE" ]] && visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1 || { log "FAIL(check): sudoers invalid"; exit 1; } + valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; } + log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0 +fi +key_data="$(load_admin_key)" || exit 1 +[[ -n "${key_data//[[:space:]]/}" ]] || { log "FAIL: administrator key is empty"; exit 1; } +tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT +printf '%s\n' "$key_data" > "$tmp_key" +ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; } +if ! id blueforce >/dev/null 2>&1; then useradd -m -s /bin/bash blueforce; fi +passwd -l blueforce >/dev/null 2>&1 || true +install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh +install -m 600 -o blueforce -g blueforce "$tmp_key" "$AUTHORIZED_KEYS" +valid_key_file || { log "FAIL: atomic authorized_keys install did not validate"; exit 1; } +cat > /tmp/blueforce.sudoers <<'EOF' +# Managed by blueforce-installer (03-user). Local edits will be overwritten. +blueforce ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs * +EOF +visudo -cf /tmp/blueforce.sudoers +install -m 440 /tmp/blueforce.sudoers "$SUDOERS_FILE" +log "OK: user blueforce has a validated administrator key and restricted sudoers" \ No newline at end of file diff --git a/scripts/install/installer/modules/04-hostname.sh b/scripts/install/installer/modules/04-hostname.sh new file mode 100755 index 0000000..623a6ec --- /dev/null +++ b/scripts/install/installer/modules/04-hostname.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Set hostname bf- from validated dealer number. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="04-hostname" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [04-hostname] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +[[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; } +[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; } +TARGET="bf-${DEALER_ID}" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; } + log "OK(check): hostname is $TARGET" + exit 0 +fi + +if [[ "$(hostname)" == "$TARGET" ]]; then + log "hostname already $TARGET, keeping" +else + hostnamectl set-hostname "$TARGET" + if grep -q "127.0.1.1" /etc/hosts; then + sed -i "s/^127.0.1.1.*/127.0.1.1\t$TARGET/" /etc/hosts + else + echo -e "127.0.1.1\t$TARGET" >> /etc/hosts + fi + log "hostname set to $TARGET" +fi +mkdir -p "$STATE_DIR" +echo "$DEALER_ID" > "$STATE_DIR/dealer-id" +echo "bf-${DEALER_ID}" > "$STATE_DIR/hostname" +echo "BF-${DEALER_ID}" > "$STATE_DIR/device-id" +log "OK: identity files written (BF-${DEALER_ID} / bf-${DEALER_ID})" diff --git a/scripts/install/installer/modules/05-network.sh b/scripts/install/installer/modules/05-network.sh new file mode 100755 index 0000000..71da663 --- /dev/null +++ b/scripts/install/installer/modules/05-network.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Network gates distinguish local link, Internet, and central enrollment reachability. +set -euo pipefail +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"; CHECK_MODE=0 +[[ "${1:-}" == --check ]] && CHECK_MODE=1 +OFFLINE="${BF_OFFLINE:-0}"; ENDPOINT="${BF_ENROLLMENT_ENDPOINT:-}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [05-network] $1" | tee -a "$LOG_FILE"; } +local_link() { ip -o link show up 2>/dev/null | grep -qv ' lo:'; } +internet() { getent hosts archive.ubuntu.com >/dev/null 2>&1 && (ping -c1 -W3 1.1.1.1 >/dev/null 2>&1 || curl --connect-timeout 3 -fsSI https://archive.ubuntu.com >/dev/null 2>&1); } +central() { [[ "$ENDPOINT" =~ ^https://[^[:space:]/]+ ]] && curl --connect-timeout 3 -fsSI "$ENDPOINT" >/dev/null 2>&1; } +local_link || { log 'FAIL: no local network link'; exit 1; } +log 'OK: local link present' +if [[ "$OFFLINE" == 1 ]]; then log 'OK: offline mode defers Internet and central endpoint checks'; exit 0; fi +internet || { log 'FAIL: Internet reachability unavailable'; exit 1; } +log 'OK: Internet reachable' +if [[ -n "$ENDPOINT" ]]; then central && log 'OK: central enrollment endpoint reachable' || log 'WARN: central enrollment endpoint unavailable (enrollment remains pending)'; else log 'INFO: central enrollment endpoint not configured'; fi diff --git a/scripts/install/installer/modules/06-wireguard.sh b/scripts/install/installer/modules/06-wireguard.sh new file mode 100755 index 0000000..049181b --- /dev/null +++ b/scripts/install/installer/modules/06-wireguard.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# WireGuard local identity: provisioning creates only a device keypair; enrollment owns wg0.conf. +set -euo pipefail +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"; WG_DIR="${WG_DIR:-/etc/wireguard}"; CHECK_MODE=0 +[[ "${1:-}" == --check ]] && CHECK_MODE=1 +OFFLINE="${BF_OFFLINE:-0}" +# READY freshness is checked by bf-check-ready with BF_WG_HANDSHAKE_MAX_AGE. +BF_WG_HANDSHAKE_MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [06-wireguard] $1" | tee -a "$LOG_FILE"; } +keypair() { [[ -s "$WG_DIR/privatekey" && -s "$WG_DIR/publickey" ]]; } +if [[ "$CHECK_MODE" == 1 ]]; then + keypair || { log 'FAIL(check): WireGuard device keypair missing'; exit 1; } + if [[ "$OFFLINE" == 1 ]]; then [[ ! -e "$WG_DIR/wg0.conf" ]] || { log 'FAIL(check): offline provisioning must not create wg0.conf'; exit 1; }; log 'OK(check): offline WireGuard identity only'; exit 0; fi + [[ -f "$WG_DIR/wg0.conf" ]] || { log 'FAIL(check): enrollment WireGuard config missing'; exit 1; } + systemctl is-enabled --quiet wg-quick@wg0 && systemctl is-active --quiet wg-quick@wg0 || { log 'FAIL(check): enrolled WireGuard inactive'; exit 1; } + log 'OK(check): enrolled WireGuard active'; exit 0 +fi +command -v wg >/dev/null 2>&1 || { [[ "$OFFLINE" == 1 ]] && { log 'FAIL: offline media must supply wireguard-tools'; exit 1; }; apt-get update && apt-get install -y wireguard-tools; } +install -d -m 700 "$WG_DIR" +if ! keypair; then umask 077; wg genkey | tee "$WG_DIR/privatekey" | wg pubkey > "$WG_DIR/publickey"; chmod 600 "$WG_DIR/privatekey" "$WG_DIR/publickey"; fi +if [[ "$OFFLINE" == 1 ]]; then rm -f "$WG_DIR/wg0.conf"; systemctl disable --now wg-quick@wg0 2>/dev/null || true; log 'OK: offline WireGuard keypair generated; hub configuration deferred to enrollment'; exit 0; fi +log 'OK: WireGuard keypair ready; use bf-enroll --token-stdin to obtain hub configuration' diff --git a/scripts/install/installer/modules/07-ssh.sh b/scripts/install/installer/modules/07-ssh.sh new file mode 100755 index 0000000..70fa286 --- /dev/null +++ b/scripts/install/installer/modules/07-ssh.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Harden sshd only after a validated Blueforce administrator key is installed. +set -euo pipefail +MODULE="07-ssh" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +DROPIN="/etc/ssh/sshd_config.d/99-blueforce.conf" +AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [07-ssh] $1" | tee -a "$LOG_FILE"; } +key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } +ADMIN_KEY="${BF_ADMIN_PUBLIC_KEY:-}" +ADMIN_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" +install_admin_key() { + [[ -n "$ADMIN_KEY" || -n "$ADMIN_KEY_FILE" ]] || return 0 + [[ -z "$ADMIN_KEY" ]] || printf '%s\n' "$ADMIN_KEY" > /tmp/blueforce-admin-key + [[ -z "$ADMIN_KEY_FILE" ]] || { [[ -r "$ADMIN_KEY_FILE" ]] || { log "FAIL: BF_ADMIN_PUBLIC_KEY_FILE is unreadable"; exit 1; }; cp "$ADMIN_KEY_FILE" /tmp/blueforce-admin-key; } + ssh-keygen -l -f /tmp/blueforce-admin-key >/dev/null 2>&1 || { log "FAIL: BF_ADMIN_PUBLIC_KEY is not a valid public key"; exit 1; } + install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh + install -m 600 -o blueforce -g blueforce /tmp/blueforce-admin-key "$AUTHORIZED_KEYS" +} +read -r -d '' WANT <<'EOF' || true +# Managed by blueforce-installer (07-ssh). Local edits will be overwritten. +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +ChallengeResponseAuthentication no +UsePAM yes +X11Forwarding no +ClientAliveInterval 300 +ClientAliveCountMax 2 +MaxAuthTries 3 +AllowUsers blueforce +EOF +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; } + key_ready || { log "FAIL(check): no valid non-empty blueforce authorized_keys"; exit 1; } + grep -qx 'AllowUsers blueforce' "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; } + sshd -t 2>/dev/null || { log "FAIL(check): sshd config test failed"; exit 1; } + log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server +install_admin_key +key_ready || { log "FAIL: valid non-empty /home/blueforce/.ssh/authorized_keys or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; } +printf '%s\n' "$WANT" > /tmp/99-blueforce.conf.bf +install -m 644 /tmp/99-blueforce.conf.bf "$DROPIN" +sshd -t || { log "FAIL: sshd -t rejected the new config"; exit 1; } +if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi +systemctl enable "$SVC" >/dev/null 2>&1 || true +systemctl reload-or-restart "$SVC" +log "OK: hardened SSH installed after valid key verification; AllowUsers=blueforce" \ No newline at end of file diff --git a/scripts/install/installer/modules/08-rdp.sh b/scripts/install/installer/modules/08-rdp.sh new file mode 100755 index 0000000..ea5b2b9 --- /dev/null +++ b/scripts/install/installer/modules/08-rdp.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# xRDP + GNOME session, terminal boot default, multi-user. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="08-rdp" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +# shellcheck source=../offline-apt.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [08-rdp] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s xrdp >/dev/null 2>&1 || { log "FAIL(check): xrdp not installed"; exit 1; } + systemctl is-enabled --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not enabled"; exit 1; } + systemctl is-active --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not active"; exit 1; } + systemctl is-enabled --quiet xrdp-sesman 2>/dev/null || { log "FAIL(check): xrdp-sesman not enabled"; exit 1; } + systemctl is-active --quiet xrdp-sesman 2>/dev/null || { log "FAIL(check): xrdp-sesman not active"; exit 1; } + [[ "$(systemctl get-default)" == "multi-user.target" ]] || { log "FAIL(check): default target is not multi-user"; exit 1; } + log "OK(check): xrdp installed+enabled, terminal boot default" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +dpkg -s xrdp >/dev/null 2>&1 || bf_apt_install xrdp +dpkg -s gnome-session >/dev/null 2>&1 || bf_apt_install gnome-session + +# Default GNOME session for RDP logins (per-user override still wins). +if [[ ! -f /etc/skel/.xsession ]]; then + echo "gnome-session" > /etc/skel/.xsession +fi +if id blueforce >/dev/null 2>&1 && [[ ! -f /home/blueforce/.xsession ]]; then + echo "gnome-session" > /home/blueforce/.xsession + chown blueforce:blueforce /home/blueforce/.xsession +fi + +# Module 13 starts xRDP only after the UFW WireGuard-only baseline succeeds. +systemctl set-default multi-user.target +log "OK: xrdp installed and configured; module 13 will enable it after firewall baseline" diff --git a/scripts/install/installer/modules/09-rustdesk.sh b/scripts/install/installer/modules/09-rustdesk.sh new file mode 100755 index 0000000..30f32f6 --- /dev/null +++ b/scripts/install/installer/modules/09-rustdesk.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# RustDesk is a required remote channel: only a pinned supplied package is accepted. +set -euo pipefail +MODULE="09-rustdesk" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +# shellcheck source=../offline-apt.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +RUSTDESK_SERVER="${RUSTDESK_SERVER:-}" +RUSTDESK_DEB="${RUSTDESK_DEB:-}" +SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml" +USER_CONFIG="/home/blueforce/.config/RustDesk/RustDesk2.toml" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; } +valid_server() { + local endpoint="$1" host port + [[ -n "$endpoint" ]] || return 1 + [[ "$endpoint" != *example* && "$endpoint" != *invalid* && "$endpoint" != *REPLACE* && "$endpoint" != *replace* && "$endpoint" != *'<'* && "$endpoint" != *'>'* && "$endpoint" != localhost* ]] || return 1 + [[ "$endpoint" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] || return 1 + host="${endpoint%%:*}" + [[ -n "$host" ]] || return 1 + if [[ "$endpoint" == *:* ]]; then + port="${endpoint##*:}" + ((10#$port <= 65535)) || return 1 + fi +} +config_endpoint() { + sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$SYSTEM_CONFIG" 2>/dev/null | head -n 1 +} +configured() { + local endpoint + endpoint="$(config_endpoint)" + valid_server "$endpoint" || return 1 + grep -Fqx "rendezvous-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && + grep -Fqx "relay-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && + grep -Fqx "rendezvous-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 && + grep -Fqx "relay-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 +} +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s rustdesk >/dev/null 2>&1 || { log "FAIL(check): rustdesk remote channel is not installed"; exit 1; } + configured || { log "FAIL(check): RustDesk client config is missing, inconsistent, or uses a placeholder/non-self-hosted endpoint"; exit 1; } + rustdesk --get-id 2>/dev/null | grep -qE '^[0-9]+$' || { log "FAIL(check): RustDesk client cannot provide a device ID"; exit 1; } + log "OK(check): RustDesk remote channel is installed with a self-hosted client configuration" + exit 0 +fi +valid_server "$RUSTDESK_SERVER" || { log "FAIL: RUSTDESK_SERVER must be a non-placeholder self-hosted host or host:port"; exit 1; } +export DEBIAN_FRONTEND=noninteractive +if [[ "${BF_OFFLINE:-0}" == 1 ]]; then + dpkg -s rustdesk >/dev/null 2>&1 || bf_apt_install rustdesk +else + [[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; } + dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; } +fi +for cfgdir in /etc/rustdesk /home/blueforce/.config/RustDesk; do + install -d -m 700 "$cfgdir" + cat > "$cfgdir/RustDesk2.toml" </dev/null || true +configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; } +install -d -m 700 "$STATE_DIR" +rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true +[[ -s "$STATE_DIR/rustdesk-id" ]] || { log "FAIL: RustDesk client did not provide a device ID"; exit 1; } +log "OK: RustDesk pinned package installed and self-hosted channel configured" \ No newline at end of file diff --git a/scripts/install/installer/modules/10-docker.sh b/scripts/install/installer/modules/10-docker.sh new file mode 100755 index 0000000..7074543 --- /dev/null +++ b/scripts/install/installer/modules/10-docker.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Docker CE + daemon.json with log rotation, service enabled. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="10-docker" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +# shellcheck source=../offline-apt.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [10-docker] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +DAEMON_JSON="/etc/docker/daemon.json" +WANT_JSON='{ + "log-driver": "json-file", + "log-opts": {"max-size": "10m", "max-file": "3"}, + "live-restore": true +}' + +if [[ "$CHECK_MODE" -eq 1 ]]; then + command -v docker >/dev/null 2>&1 || { log "FAIL(check): docker missing"; exit 1; } + systemctl is-enabled --quiet docker 2>/dev/null || { log "FAIL(check): docker not enabled"; exit 1; } + [[ -f "$DAEMON_JSON" ]] || { log "FAIL(check): daemon.json missing"; exit 1; } + log "OK(check): docker installed, enabled, daemon.json present" + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + if [[ "${BF_OFFLINE:-0}" == 1 ]]; then + bf_apt_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + else + apt-get install -y ca-certificates curl gnupg + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + install -d -m 0755 /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + . /etc/os-release + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list + apt-get update + fi + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || apt-get install -y docker.io + fi +fi + +printf '%s\n' "$WANT_JSON" > /tmp/daemon.json.bf +mkdir -p /etc/docker +mv /tmp/daemon.json.bf "$DAEMON_JSON" +systemctl enable --now docker +systemctl is-active --quiet docker || { log "FAIL: docker installed but service not running"; exit 1; } +log "OK: docker ready with log rotation (10m x3) and live-restore" diff --git a/scripts/install/installer/modules/11-meg.sh b/scripts/install/installer/modules/11-meg.sh new file mode 100755 index 0000000..db0d52a --- /dev/null +++ b/scripts/install/installer/modules/11-meg.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# MEG placeholder: install vendor .deb if present, else SKIP + acceptance checklist. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="11-meg" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [11-meg] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +MEG_DEB="${MEG_DEB:-$(ls /opt/blueforce/debs/meg*.deb /opt/blueforce/meg*.deb 2>/dev/null | head -1)}" +MEG_CONTAINER="${MEG_CONTAINER:-meg}" + +acceptance_checklist() { + local fails=0 + # Gate 2: pinned version, never latest. + local img + img=$(docker inspect --format '{{.Config.Image}}' "$MEG_CONTAINER" 2>/dev/null || echo "") + if [[ -z "$img" ]]; then echo "CHECK container-running: SKIP (no container $MEG_CONTAINER yet)"; else echo "CHECK container-running: PASS ($img)"; fi + if [[ "$img" == *":latest" ]]; then echo "CHECK pinned-tag: FAIL (latest forbidden)"; fails=$((fails+1)); else echo "CHECK pinned-tag: PASS"; fi + # Gate 4: restart policy unless-stopped. + if [[ -n "$img" ]]; then + local pol + pol=$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$MEG_CONTAINER" 2>/dev/null) + [[ "$pol" == "unless-stopped" ]] && echo "CHECK restart-policy: PASS" || { echo "CHECK restart-policy: FAIL ($pol)"; fails=$((fails+1)); } + fi + # Gate 5: no 0.0.0.0 port bind to the outside. + if ss -tlnp 2>/dev/null | grep -q "0.0.0.0"; then echo "CHECK port-bind: WARN (external bind present — review)"; else echo "CHECK port-bind: PASS"; fi + return "$fails" +} + +if [[ "$CHECK_MODE" -eq 1 ]]; then + if [[ -z "${MEG_DEB:-}" || ! -f "$MEG_DEB" ]] && ! dpkg -s meg >/dev/null 2>&1 && ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$MEG_CONTAINER"; then + log "OK(check): MEG not supplied yet (SKIP state)" + exit 0 + fi + acceptance_checklist || { log "FAIL(check): MEG acceptance checklist failed"; exit 1; } + log "OK(check): MEG acceptance checklist passed" + exit 0 +fi + +if [[ "${MEG_SKIP:-0}" == "1" ]]; then log "SKIP: MEG_SKIP=1"; exit 3; fi + +if ! dpkg -s meg >/dev/null 2>&1 && ! docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$MEG_CONTAINER"; then + if [[ -n "${MEG_DEB:-}" && -f "$MEG_DEB" ]]; then + export DEBIAN_FRONTEND=noninteractive + dpkg -i "$MEG_DEB" || apt-get install -f -y + log "MEG package installed from $MEG_DEB" + else + log "SKIP: no MEG .deb supplied — vendor package pending, continuing" + exit 3 + fi +else + log "MEG already present, keeping" +fi + +if ! acceptance_checklist; then + log "FAIL: MEG acceptance checklist failed — see output above" + exit 1 +fi +log "OK: MEG present and acceptance checklist passed" diff --git a/scripts/install/installer/modules/12-monitoring.sh b/scripts/install/installer/modules/12-monitoring.sh new file mode 100755 index 0000000..8eb5179 --- /dev/null +++ b/scripts/install/installer/modules/12-monitoring.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Node exporter + textfile collector directory. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="12-monitoring" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +# shellcheck source=../offline-apt.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [12-monitoring] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +TEXTFILE_DIR="/var/lib/node_exporter/textfile" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + systemctl is-enabled --quiet prometheus-node-exporter 2>/dev/null || systemctl is-enabled --quiet node_exporter 2>/dev/null || { log "FAIL(check): node exporter not enabled"; exit 1; } + [[ -d "$TEXTFILE_DIR" ]] || { log "FAIL(check): textfile dir missing"; exit 1; } + log "OK(check): node exporter enabled, textfile dir present" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +dpkg -s prometheus-node-exporter >/dev/null 2>&1 || bf_apt_install prometheus-node-exporter + +install -d -m 755 "$TEXTFILE_DIR" +# Ensure the textfile collector flag is set (package default may omit it). +OVERRIDE="/etc/systemd/system/prometheus-node-exporter.service.d/blueforce.conf" +mkdir -p "$(dirname "$OVERRIDE")" +printf '[Service]\nExecStart=\nExecStart=/usr/bin/prometheus-node-exporter --collector.textfile.directory=%s\n' "$TEXTFILE_DIR" > /tmp/override.bf +mv /tmp/override.bf "$OVERRIDE" +systemctl daemon-reload +systemctl enable --now prometheus-node-exporter 2>/dev/null || systemctl enable --now node_exporter +log "OK: node exporter enabled with textfile dir $TEXTFILE_DIR" diff --git a/scripts/install/installer/modules/13-firewall.sh b/scripts/install/installer/modules/13-firewall.sh new file mode 100755 index 0000000..12b48e4 --- /dev/null +++ b/scripts/install/installer/modules/13-firewall.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Apply UFW deny-in baseline and persist Docker published-port policy. +set -euo pipefail +MODULE="13-firewall" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SSH_PORT="${SSH_PORT:-22}" +RDP_PORT="${RDP_PORT:-3389}" +DOCKER_ALLOWLIST="/etc/blueforce/docker-published-port-allowlist.conf" +DOCKER_POLICY_BIN="/usr/local/sbin/blueforce-docker-firewall" +DOCKER_POLICY_UNIT="blueforce-docker-firewall.service" +DOCKER_POLICY_DROPIN="/etc/systemd/system/docker.service.d/blueforce-docker-firewall.conf" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +# shellcheck source=../offline-apt.sh +source "$SCRIPT_DIR/../offline-apt.sh" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [13-firewall] $1" | tee -a "$LOG_FILE"; } + +if [[ "$CHECK_MODE" -eq 1 ]]; then + ufw status 2>/dev/null | grep -q 'Status: active' || { log "FAIL(check): ufw not active"; exit 1; } + ufw status 2>/dev/null | grep -Eq "${SSH_PORT}/tcp.*on wg0|${SSH_PORT}/tcp.*wg0" || { log "FAIL(check): SSH is not restricted to wg0"; exit 1; } + ufw status 2>/dev/null | grep -Eq "${RDP_PORT}/tcp.*on wg0|${RDP_PORT}/tcp.*wg0" || { log "FAIL(check): RDP is not restricted to wg0"; exit 1; } + [[ -f "$DOCKER_ALLOWLIST" && -x "$DOCKER_POLICY_BIN" && -f "$DOCKER_POLICY_DROPIN" ]] || { log "FAIL(check): persistent Docker firewall assets missing"; exit 1; } + systemctl is-enabled --quiet "$DOCKER_POLICY_UNIT" || { log "FAIL(check): persistent Docker firewall unit is not enabled"; exit 1; } + "$DOCKER_POLICY_BIN" --check || { log "FAIL(check): Docker published-port policy is not enforced"; exit 1; } + log "OK(check): SSH/RDP restricted to wg0 and Docker policy persists through systemd" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +command -v ufw >/dev/null 2>&1 || bf_apt_install ufw +command -v iptables >/dev/null 2>&1 || bf_apt_install iptables +ufw default deny incoming +ufw default allow outgoing +ufw delete allow "$SSH_PORT/tcp" 2>/dev/null || true +ufw delete allow "$RDP_PORT/tcp" 2>/dev/null || true +ufw allow in on wg0 to any port "$SSH_PORT" proto tcp comment 'SSH over WireGuard only' +ufw allow in on wg0 to any port "$RDP_PORT" proto tcp comment 'RDP over WireGuard only' +install -d -m 755 /etc/blueforce /etc/iptables +if [[ ! -f "$DOCKER_ALLOWLIST" ]]; then + cat > "$DOCKER_ALLOWLIST" <<'EOF' +# Explicit Docker published-port exceptions. One reviewed entry per line: +# tcp 8443 +# udp 51820 +# This allowlist intentionally permits no externally published Docker ports by default. +EOF +fi +chown root:root "$DOCKER_ALLOWLIST" +chmod 640 "$DOCKER_ALLOWLIST" +install -o root -g root -m 700 "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" "$DOCKER_POLICY_BIN" +install -o root -g root -m 644 "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" "/etc/systemd/system/$DOCKER_POLICY_UNIT" +install -d -m 755 "$(dirname "$DOCKER_POLICY_DROPIN")" +install -o root -g root -m 644 "$REPO_ROOT/config/systemd/docker.service.d/blueforce-docker-firewall.conf" "$DOCKER_POLICY_DROPIN" +systemctl daemon-reload +systemctl enable "$DOCKER_POLICY_UNIT" +# Docker creates DOCKER-USER during startup; applying now proves the parsed policy. +systemctl start docker +"$DOCKER_POLICY_BIN" +ufw --force enable +# xRDP remains stopped until the deny-in/WireGuard-only rules are active. +systemctl enable xrdp xrdp-sesman >/dev/null 2>&1 || systemctl enable xrdp +systemctl start xrdp xrdp-sesman >/dev/null 2>&1 || systemctl start xrdp +systemctl is-enabled --quiet xrdp && systemctl is-active --quiet xrdp || { log 'FAIL: xrdp did not start after firewall baseline'; exit 1; } +log "OK: UFW allows SSH/RDP only on wg0; xrdp started only after firewall baseline; persisted Docker policy denies all unapproved published ports" \ No newline at end of file diff --git a/scripts/install/installer/modules/14-update-policy.sh b/scripts/install/installer/modules/14-update-policy.sh new file mode 100755 index 0000000..96dea58 --- /dev/null +++ b/scripts/install/installer/modules/14-update-policy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# APT holds + disable automatic update timers. +set -euo pipefail +MODULE="14-update-policy" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +APT_HOLDS="${APT_HOLDS:-}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [14-update-policy] $1" | tee -a "$LOG_FILE"; } +if [[ "$CHECK_MODE" -eq 1 ]]; then + for t in apt-daily.timer apt-daily-upgrade.timer; do systemctl is-enabled "$t" 2>/dev/null | grep -q masked || { log "FAIL(check): $t not masked"; exit 1; }; done + log "OK(check): update timers masked"; exit 0 +fi +for t in apt-daily.service apt-daily.timer apt-daily-upgrade.service apt-daily-upgrade.timer; do systemctl mask "$t" >/dev/null 2>&1 || true; systemctl stop "$t" >/dev/null 2>&1 || true; done +printf 'APT::Periodic::Update-Package-Lists "0";\nAPT::Periodic::Unattended-Upgrade "0";\nAPT::Periodic::Enable "0";\n' > /tmp/20auto-upgrades.bf +mv /tmp/20auto-upgrades.bf /etc/apt/apt.conf.d/20auto-upgrades +if [[ -n "$APT_HOLDS" ]]; then + read -r -a apt_holds <<< "$APT_HOLDS" + ((${#apt_holds[@]})) && apt-mark hold -- "${apt_holds[@]}" + log "holds applied: ${apt_holds[*]}" +fi +log "OK: automatic updates locked off (timers masked, periodic=0)" \ No newline at end of file diff --git a/scripts/install/installer/modules/15-logrotate.sh b/scripts/install/installer/modules/15-logrotate.sh new file mode 100755 index 0000000..1839fd2 --- /dev/null +++ b/scripts/install/installer/modules/15-logrotate.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# journald caps + app logrotate policy. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="15-logrotate" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [15-logrotate] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +JOURNAL_DROPIN="/etc/systemd/journald.conf.d/99-blueforce.conf" +APP_ROTATE="/etc/logrotate.d/blueforce" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ -f "$JOURNAL_DROPIN" && -f "$APP_ROTATE" ]] || { log "FAIL(check): logrotate configs missing"; exit 1; } + log "OK(check): journald caps + app logrotate present" + exit 0 +fi + +mkdir -p /etc/systemd/journald.conf.d +cat > /tmp/journald.bf <<'EOF' +# Managed by blueforce-installer (15-logrotate). +[Journal] +SystemMaxUse=500M +RuntimeMaxUse=100M +MaxRetentionSec=30day +ForwardToSyslog=no +EOF +mv /tmp/journald.bf "$JOURNAL_DROPIN" +systemctl restart systemd-journald 2>/dev/null || true + +cat > /tmp/blueforce-rotate.bf <<'EOF' +# Managed by blueforce-installer (15-logrotate). +/var/log/blueforce*.log /var/log/bf-*.log { + size 10M + rotate 5 + compress + delaycompress + missingok + notifempty + copytruncate +} +EOF +mv /tmp/blueforce-rotate.bf "$APP_ROTATE" +chmod 644 "$APP_ROTATE" +logrotate --debug "$APP_ROTATE" 2>&1 | head -3 || true +log "OK: journald capped (500M/30d), app logs rotate at 10M x5" diff --git a/scripts/install/installer/modules/16-gui.sh b/scripts/install/installer/modules/16-gui.sh new file mode 100755 index 0000000..f929518 --- /dev/null +++ b/scripts/install/installer/modules/16-gui.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# GNOME minimal and maintained GUI maintenance tools; terminal boot remains default. +set -euo pipefail +MODULE="16-gui" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [16-gui] $1" | tee -a "$LOG_FILE"; } +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s ubuntu-desktop-minimal >/dev/null 2>&1 || { log "FAIL(check): ubuntu-desktop-minimal missing"; exit 1; } + [[ -x /usr/local/bin/bf-gui-on && -x /usr/local/bin/bf-gui-off ]] || { log "FAIL(check): maintained bf-gui switches missing"; exit 1; } + [[ -f /etc/systemd/system/bf-gui-on.service && -f /etc/systemd/system/bf-gui-off.service ]] || { log "FAIL(check): manual GUI service units missing"; exit 1; } + [[ "$(systemctl get-default)" == multi-user.target ]] || { log "FAIL(check): terminal boot default was changed"; exit 1; } + log "OK(check): GNOME minimal, maintained switches, and terminal boot default present"; exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +dpkg -s ubuntu-desktop-minimal >/dev/null 2>&1 || apt-get install -y ubuntu-desktop-minimal +for tool in bf-gui-on bf-gui-off; do install -m 755 "$SCRIPT_ROOT/maintenance/$tool" "/usr/local/bin/$tool"; done +for unit in bf-gui-on.service bf-gui-off.service; do install -m 644 "$SCRIPT_ROOT/../config/systemd/$unit" "/etc/systemd/system/$unit"; done +systemctl daemon-reload +systemctl set-default multi-user.target +systemctl disable gdm3 2>/dev/null || systemctl disable gdm 2>/dev/null || true +log "OK: maintained GUI tools and manual units installed; boot default is multi-user" \ No newline at end of file diff --git a/scripts/install/installer/modules/17-healthcheck.sh b/scripts/install/installer/modules/17-healthcheck.sh new file mode 100755 index 0000000..ff337da --- /dev/null +++ b/scripts/install/installer/modules/17-healthcheck.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Install diagnostics and enrollment commands without executing enrollment. +set -euo pipefail +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"; CHECK_MODE=0 +[[ "${1:-}" == --check ]] && CHECK_MODE=1 +SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [17-healthcheck] $1" | tee -a "$LOG_FILE"; } +BINS=(bf-status bf-enroll bf-enroll-now bf-enrollment-status bf-release bf-remote-status bf-check-local bf-check-enrollment bf-check-ready) +if [[ "$CHECK_MODE" == 1 ]]; then + for bin in "${BINS[@]}"; do [[ -x "/usr/local/bin/$bin" ]] || { log "FAIL(check): $bin missing"; exit 1; }; done + [[ -f /etc/systemd/system/blueforce-enroll.service && -f /etc/systemd/system/blueforce-enroll.timer ]] || { log 'FAIL(check): enrollment units missing'; exit 1; } + log 'OK(check): diagnostics and enrollment trigger installed'; exit 0 +fi +install -d -m 700 "$STATE_DIR" /etc/blueforce +for bin in "${BINS[@]}"; do install -m 755 "$SCRIPT_ROOT/diagnostics/$bin" "/usr/local/bin/$bin"; done +install -m 644 "$SCRIPT_ROOT/../config/systemd/blueforce-enroll.service" /etc/systemd/system/blueforce-enroll.service +install -m 644 "$SCRIPT_ROOT/../config/systemd/blueforce-enroll.timer" /etc/systemd/system/blueforce-enroll.timer +[[ -f "$SCRIPT_ROOT/../provisioning/release/manifest.yaml" ]] && install -m 644 "$SCRIPT_ROOT/../provisioning/release/manifest.yaml" /etc/blueforce/release-manifest.yaml +systemctl daemon-reload +systemctl enable blueforce-enroll.timer >/dev/null 2>&1 || true +log 'OK: diagnostics installed; enrollment remains pending until an operator supplies a one-time token' diff --git a/scripts/install/installer/modules/18-final-check.sh b/scripts/install/installer/modules/18-final-check.sh new file mode 100755 index 0000000..618ae58 --- /dev/null +++ b/scripts/install/installer/modules/18-final-check.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Final lifecycle gate: offline completes locally; READY requires enrolled remote proof. +set -euo pipefail +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"; STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"; CHECK_MODE=0 +[[ "${1:-}" == --check ]] && CHECK_MODE=1 +OFFLINE="${BF_OFFLINE:-0}"; SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [18-final-check] $1" | tee -a "$LOG_FILE"; } +if [[ "$OFFLINE" == 1 ]]; then + # Only local gates are meaningful offline. No central peer, handshake, or READY claim. + for g in 01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck; do + LOG_FILE=/dev/null BF_OFFLINE=1 bash "$SCRIPT_DIR/$g.sh" --check >/dev/null || { log "FAIL: offline local gate $g failed"; exit 1; } + done + [[ "$CHECK_MODE" == 1 ]] && { log 'OK(check): offline local gates pass'; exit 0; } + log 'OK: offline local gates pass; installer records PROVISIONED_OFFLINE'; exit 0 +fi +[[ "$CHECK_MODE" == 1 ]] && exec /usr/local/bin/bf-check-ready +/usr/local/bin/bf-check-ready +python3 - "$STATE_DIR/state.json" <<'PY' +import json,os,sys,tempfile +p=sys.argv[1]; s=json.load(open(p)); +if s.get('phase') not in ('ENROLLED','READY'): raise SystemExit('not enrolled') +s.update({'phase':'READY','fleet_status':'READY'}) +fd,t=tempfile.mkstemp(prefix='state.json.',dir=os.path.dirname(p)); os.fchmod(fd,0o600) +with os.fdopen(fd,'w') as f: json.dump(s,f,sort_keys=True); f.write('\n') +os.replace(t,p) +PY +log 'OK: enrolled device has current handshake and remote checks; READY recorded' diff --git a/scripts/install/installer/offline-apt.sh b/scripts/install/installer/offline-apt.sh new file mode 100644 index 0000000..53d40fe --- /dev/null +++ b/scripts/install/installer/offline-apt.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Shared local APT transaction helpers. Offline mode never reads remote sources. +set -euo pipefail + +bf_apt() { + if [[ "${BF_OFFLINE:-0}" == 1 ]]; then + [[ -n "${BF_OFFLINE_APT_CONFIG:-}" && -r "${BF_OFFLINE_APT_CONFIG}" ]] || { + printf 'Offline APT configuration is unavailable.\n' >&2 + return 1 + } + APT_CONFIG="$BF_OFFLINE_APT_CONFIG" apt-get "$@" + else + apt-get "$@" + fi +} + +bf_require_offline_pin() { + local package="$1" + [[ "${BF_OFFLINE:-0}" != 1 ]] && return 0 + local pin + pin="$(awk -F '\t' -v package="$package" '$1 == package {print $1 "=" $2; exit}' "${BF_OFFLINE_REPO}/packages.lock.tsv")" + [[ -n "$pin" ]] || { + printf 'Offline repository lacks required pinned package: %s\n' "$package" >&2 + return 1 + } + printf '%s\n' "$pin" +} + +bf_apt_install() { + local packages=() package pin + for package in "$@"; do + if [[ "${BF_OFFLINE:-0}" == 1 ]]; then + pin="$(bf_require_offline_pin "$package")" || return 1 + packages+=("$pin") + else + packages+=("$package") + fi + done + bf_apt install -y "${packages[@]}" +} diff --git a/scripts/install/optional/anydesk/install-anydesk.sh b/scripts/install/optional/anydesk/install-anydesk.sh new file mode 100755 index 0000000..76f9f93 --- /dev/null +++ b/scripts/install/optional/anydesk/install-anydesk.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Licensed-only AnyDesk exception placeholder. It deliberately installs nothing. +set -euo pipefail + +if [[ "${ANYDESK_LICENSE_CONFIRMED:-}" != "true" ]]; then + printf '%s\n' 'Refusing AnyDesk exception: set ANYDESK_LICENSE_CONFIRMED=true only after the per-device license approval is recorded.' >&2 + exit 2 +fi + +printf '%s\n' 'AnyDesk license confirmation acknowledged. This skeleton intentionally does not download, install, configure, or contain AnyDesk licensing material.' +printf '%s\n' 'Obtain the approved package and license through the organization’s controlled process; record the device exception outside the Blueforce installer.' diff --git a/scripts/maintenance/bf-gui-off b/scripts/maintenance/bf-gui-off new file mode 100755 index 0000000..0cd84d3 --- /dev/null +++ b/scripts/maintenance/bf-gui-off @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# bf-gui-off - Switch the field terminal back to console (multi-user) mode. +# +# Stops only the local display manager, preserves xRDP remote access, and isolates +# multi-user.target. Idempotent: already-console systems are left untouched. +# +# Install: install -m 0755 bf-gui-off /usr/local/bin/bf-gui-off +# Usage: sudo bf-gui-off +# +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bf-gui-off [--help] + +Stop the graphical layer and return the device to console mode. + +Options: + --help Show this help and exit + +Behavior: + - Stops only the local display manager (gdm3/gdm/...); xRDP and xrdp-sesman are preserved + - Disables the local display manager so it does not restart on boot + - Runs 'systemctl isolate multi-user.target' +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: bf-gui-off must run as root (use sudo)." >&2 + exit 1 +fi + +log() { echo "[bf-gui-off] $*"; } + +# Only the local physical display manager is controlled here. xRDP stays enabled +# and active so remote recovery is available while the console is local-default. +UNITS=(gdm3 gdm sddm lightdm) + +for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${unit}"; then + if systemctl is-active --quiet "${unit}"; then + log "Stopping ${unit} ..." + systemctl stop "${unit}" || log "WARNING: failed to stop ${unit}." + else + log "${unit} already stopped." + fi + systemctl disable "${unit}" >/dev/null 2>&1 || true + fi +done + +log "Isolating multi-user.target ..." +systemctl isolate multi-user.target + +log "GUI is OFF (console mode)." diff --git a/scripts/maintenance/bf-gui-on b/scripts/maintenance/bf-gui-on new file mode 100755 index 0000000..bf64ed0 --- /dev/null +++ b/scripts/maintenance/bf-gui-on @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# bf-gui-on - Switch the field terminal to graphical mode and prepare RDP. +# +# Enables and starts the display manager plus xRDP services, isolates the +# graphical target, then waits until RDP is ready to accept sessions. +# Idempotent: already-graphical systems are left untouched. +# +# Install: install -m 0755 bf-gui-on /usr/local/bin/bf-gui-on +# Usage: sudo bf-gui-on [--timeout SECS] +# +set -euo pipefail + +TIMEOUT=60 +DISPLAY_MANAGER="" + +usage() { + cat <<'EOF' +Usage: bf-gui-on [--timeout SECS] [--help] + +Switch the device to graphical mode and wait until RDP is ready. + +Options: + --timeout SECS Seconds to wait for RDP port 3389 (default: 60) + --help Show this help and exit + +Behavior: + - Enables and starts the display manager (gdm3/gdm) + xrdp + xrdp-sesman + - Runs 'systemctl isolate graphical.target' + - Waits until TCP/3389 accepts connections (RDP ready standby) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --timeout) TIMEOUT="${2:?missing value for --timeout}"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: bf-gui-on must run as root (use sudo)." >&2 + exit 1 +fi + +log() { echo "[bf-gui-on] $*"; } + +# Detect installed display manager unit (LAB validates exact names on 26.04.1). +detect_dm() { + local candidate + for candidate in gdm3.service gdm.service sddm.service lightdm.service; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${candidate}"; then + echo "${candidate%.service}" + return 0 + fi + done + return 1 +} + +if DISPLAY_MANAGER="$(detect_dm)"; then + log "Display manager detected: ${DISPLAY_MANAGER}" +else + log "WARNING: no display manager unit found; proceeding with xrdp only." +fi + +UNITS=() +[[ -n "${DISPLAY_MANAGER}" ]] && UNITS+=("${DISPLAY_MANAGER}") +UNITS+=(xrdp xrdp-sesman) + +# Enable units that exist so GUI survives reboot while switched on. +for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${unit}"; then + systemctl enable "${unit}" >/dev/null 2>&1 || true + else + log "WARNING: unit ${unit} not installed; skipping." + fi +done + +CURRENT="$(systemctl get-default 2>/dev/null || echo unknown)" +log "Current default target: ${CURRENT}" + +log "Isolating graphical.target ..." +systemctl isolate graphical.target + +for unit in "${UNITS[@]}"; do + if systemctl list-unit-files --type=service 2>/dev/null | grep -q "^${unit}"; then + if ! systemctl is-active --quiet "${unit}"; then + log "Starting ${unit} ..." + systemctl start "${unit}" || log "WARNING: failed to start ${unit}." + else + log "${unit} already active." + fi + fi +done + +# Wait for RDP standby on TCP/3389. +log "Waiting for RDP (TCP/3389) up to ${TIMEOUT}s ..." +elapsed=0 +while [[ "${elapsed}" -lt "${TIMEOUT}" ]]; do + if (exec 3<>/dev/tcp/127.0.0.1/3389) >/dev/null 2>&1; then + exec 3>&- 2>/dev/null || true + log "RDP is ready (graphical ON)." + exit 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +echo "ERROR: RDP port 3389 did not open within ${TIMEOUT}s." >&2 +echo "HINT: check 'systemctl status xrdp xrdp-sesman' and journal." >&2 +exit 1 diff --git a/scripts/migration/windows/BF-WindowsDataExport.ps1 b/scripts/migration/windows/BF-WindowsDataExport.ps1 new file mode 100644 index 0000000..8e96fb6 --- /dev/null +++ b/scripts/migration/windows/BF-WindowsDataExport.ps1 @@ -0,0 +1,349 @@ +<# +.SYNOPSIS + Read-only Windows pre-migration data-export manifest for one Blueforce dealer. +.DESCRIPTION + Produces metadata only: approved user folders, removable-drive roots, mapped + network shares, printers and MEG data directories are listed for an operator + to review before any manual copy happens. + + READ-ONLY CONTRACT (asserted by tests/check-migration-static.sh): + * No Set-*, New-*, Remove-* cmdlets, no service control, no network + configuration command, no registry write, no download. + * Nothing is copied, moved, compressed or encrypted. + * No file contents, credentials, browser profiles, password stores, + private keys or application databases are read. + * Directory metadata (path, size, timestamps) only. + * The only writes are the requested JSON and Markdown reports under + -OutputDirectory; with -Check nothing is written at all. + + Output contract: BF--windows-inventory-export.json and + BF--windows-inventory-export.md, named from the 8-digit dealer number - + not from the hostname of the machine being inventoried. +.PARAMETER DealerId + 8-digit Blueforce dealer number (^[0-9]{8}$). Device ID becomes BF-. +.PARAMETER DealerIdUnknown + Explicit opt-in for a device whose dealer number is not known yet. The report + is written as BF-unknown-windows-inventory-export.* and carries a prominent + warning: it must not be attached to a migration record until the dealer + number is confirmed and the script is re-run with -DealerId. +.PARAMETER OutputDirectory + Directory that receives the JSON and Markdown export manifest. +.PARAMETER NoSoftware + Skip the installed-software review list. +.PARAMETER Check + Dry run: validate parameters, print the export plan and the resolved output + paths, write nothing, exit 0. +.EXAMPLE + .\BF-WindowsDataExport.ps1 -DealerId 12010193 -OutputDirectory C:\Blueforce\migration +.EXAMPLE + .\BF-WindowsDataExport.ps1 -DealerIdUnknown -OutputDirectory C:\Blueforce\migration -Check +#> +[CmdletBinding(DefaultParameterSetName = 'KnownDealer')] +param( + [Parameter(Mandatory, ParameterSetName = 'KnownDealer')] + [ValidatePattern('^[0-9]{8}$')] + [string]$DealerId, + + [Parameter(Mandatory, ParameterSetName = 'UnknownDealer')] + [switch]$DealerIdUnknown, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [switch]$NoSoftware, + + [switch]$Check +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Get-SafeValue { + param([object]$Value) + if ($null -eq $Value) { return 'unknown' } + $text = ([string]$Value).Trim() + if ([string]::IsNullOrWhiteSpace($text)) { return 'unknown' } + return $text +} + +function ConvertTo-MarkdownCell { + param([object]$Value) + return ((Get-SafeValue $Value) -replace '\|', '\|' -replace "`r?`n", '; ') +} + +function ConvertTo-Gigabytes { + param([object]$Bytes) + if ($null -eq $Bytes) { return 'unknown' } + return [math]::Round(([double]$Bytes) / 1GB, 2) +} + +function ConvertTo-IsoUtc { + param([object]$Value) + if ($null -eq $Value) { return 'unknown' } + try { return ([datetime]$Value).ToUniversalTime().ToString('o') } catch { return 'unknown' } +} + +function Get-ObjectValue { + param([object]$InputObject, [string]$Name) + if ($null -eq $InputObject) { return $null } + $prop = $InputObject.PSObject.Properties | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 + if ($null -eq $prop) { return $null } + return $prop.Value +} + +function Get-FolderMetadata { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force + [ordered]@{ + path = $item.FullName + last_write_utc = $item.LastWriteTimeUtc.ToString('o') + review_required = $true + export_action = 'manual-copy-after-owner-approval' + } +} + +function Get-ProgramFilesRoots { + $roots = @() + if ($env:ProgramFiles) { $roots += $env:ProgramFiles } + if (${env:ProgramFiles(x86)}) { $roots += ${env:ProgramFiles(x86)} } + if ($env:ProgramData) { $roots += $env:ProgramData } + $roots += 'C:\' + return @($roots | Select-Object -Unique) +} + +# Directory metadata only (path + mtime); contents are never enumerated. +function Get-DirectoryReport { + param([string]$Root, [string]$Filter) + if ([string]::IsNullOrWhiteSpace($Root)) { return @() } + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() } + return @(Get-ChildItem -LiteralPath $Root -Directory -Filter $Filter -ErrorAction SilentlyContinue | + ForEach-Object { + [ordered]@{ + path = $_.FullName + last_write_utc = ConvertTo-IsoUtc $_.LastWriteTimeUtc + review_required = $true + } + }) +} + +# --------------------------------------------------------------------------- +# Identity, parameters, dry-run gate +# --------------------------------------------------------------------------- + +$dealerKnown = ($PSCmdlet.ParameterSetName -eq 'KnownDealer') +$dealerIdValue = 'unknown' +if ($dealerKnown) { $dealerIdValue = $DealerId } +$DealerId = $dealerIdValue +$deviceId = "BF-$DealerId" + +$currentHostname = Get-SafeValue $env:COMPUTERNAME +$targetHostname = 'unknown' +if ($dealerKnown) { $targetHostname = "bf-$DealerId" } + +$now = Get-Date +$collectedAtUtc = $now.ToUniversalTime().ToString('o') +$collectedAtLocal = $now.ToString('o') + +$baseName = "BF-$DealerId-windows-inventory-export" +$jsonPath = Join-Path $OutputDirectory "$baseName.json" +$markdownPath = Join-Path $OutputDirectory "$baseName.md" + +$notes = [System.Collections.Generic.List[string]]::new() +if (-not $dealerKnown) { + $notes.Add('WARNING: dealer number unknown (-DealerIdUnknown). Output is BF-unknown-windows-inventory-export.* and must NOT be attached to a migration record until the dealer number is confirmed and the script is re-run with -DealerId.') +} +$notes.Add('Read-only run: this manifest lists metadata for review; it copies, moves and deletes nothing.') + +if ($Check) { + $softwarePlan = 'collected' + if ($NoSoftware) { $softwarePlan = 'skipped (-NoSoftware)' } + Write-Output "CHECK: $baseName.ps1 read-only dry run" + Write-Output " dealer_id : $dealerIdValue (known: $dealerKnown)" + Write-Output " device_id : $deviceId" + Write-Output " hostname : $currentHostname" + Write-Output " target_hostname : $targetHostname" + Write-Output " output_json : $jsonPath" + Write-Output " output_md : $markdownPath" + Write-Output " software_review : $softwarePlan" + Write-Output ' writes : none (dry run)' + exit 0 +} + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + +# --------------------------------------------------------------------------- +# Collection (metadata only) +# --------------------------------------------------------------------------- + +$candidateFolderNames = @('Desktop', 'Documents', 'Downloads', 'Pictures', 'Videos', 'Music', 'Favorites') +$skippedFolderNames = @('AppData', '.ssh', '.gnupg', '.aws', '.azure', '.config', 'Cookies', 'Searches') + +$userFolders = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { -not $_.Special -and $_.LocalPath } | + ForEach-Object { + $root = $_.LocalPath + @($candidateFolderNames | ForEach-Object { Get-FolderMetadata (Join-Path $root $_) } | Where-Object { $null -ne $_ }) + }) + +$removableRoots = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=2' -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + path = "$($_.DeviceID)\" + label = Get-SafeValue $_.VolumeName + file_system = Get-SafeValue $_.FileSystem + size_gb = ConvertTo-Gigabytes $_.Size + free_gb = ConvertTo-Gigabytes $_.FreeSpace + review_required = $true + export_action = 'manual-copy-after-owner-approval' + } +}) + +# Mapped network shares: connection metadata only; nothing is read from them. +$mappedDrives = @(Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + local_name = Get-SafeValue $_.LocalName + remote_name = Get-SafeValue $_.RemoteName + connection_type = Get-SafeValue $_.ConnectionType + status = Get-SafeValue $_.Status + review_required = $true + export_action = 'manual-copy-after-owner-approval' + } +}) + +# Printers matter for field terminals (label/receipt printers); configuration +# is recorded, no print job or driver change is issued. +$printers = @(Get-CimInstance -ClassName Win32_Printer -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.Name + driver_name = Get-SafeValue $_.DriverName + port_name = Get-SafeValue $_.PortName + is_default = [bool]$_.Default + is_network = [bool]$_.Network + is_shared = [bool]$_.Shared + share_name = Get-SafeValue $_.ShareName + } +}) + +# MEG data directories: path metadata only, no enumeration of contents. +$megDataDirectories = @() +foreach ($root in (Get-ProgramFilesRoots)) { + $megDataDirectories += @(Get-DirectoryReport -Root $root -Filter '*MEG*') +} + +$installedSoftwareReview = @() +if (-not $NoSoftware) { + $installedSoftwareReview = @(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName } | + ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.DisplayName + version = Get-SafeValue $_.DisplayVersion + install_location = Get-SafeValue $_.InstallLocation + } + } | Sort-Object -Property { $_['name'] }) +} + +$report = [ordered]@{ + schema_version = 1 + report_type = 'windows-pre-migration-data-export-manifest' + device_id = $deviceId + hostname = $currentHostname.ToLower() + current_hostname = $currentHostname + target_hostname = $targetHostname + dealer_id = $dealerIdValue + dealer_id_known = [bool]$dealerKnown + collected_at = $collectedAtLocal + collected_at_utc = $collectedAtUtc + read_only = $true + copies_performed = 0 + executed_external_tools = @() + notes = @($notes) + excluded_data = @( + 'file contents', 'credentials', 'passwords', 'browser profiles', 'private keys', + 'application databases', 'event logs', 'DPAPI blobs', + 'AppData', '.ssh', '.gnupg', '.aws', '.azure', '.config', + 'Cookies', 'Searches', 'Windows credential vault' + ) + candidate_folder_names = $candidateFolderNames + skipped_folder_names = $skippedFolderNames + candidate_folders = @($userFolders) + removable_media_roots = $removableRoots + mapped_network_drives = $mappedDrives + printers = $printers + meg_data_directories = $megDataDirectories + installed_software_review = $installedSoftwareReview +} + +$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding utf8 + +# --------------------------------------------------------------------------- +# Markdown report +# --------------------------------------------------------------------------- + +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("# Windows Veri Aktarım İnceleme Kaydı — $deviceId") +$lines.Add('') +$lines.Add("- Toplama zamanı (UTC): $collectedAtUtc") +$lines.Add("- Mevcut hostname: $currentHostname") +$lines.Add("- Hedef hostname (geçiş sonrası): $targetHostname") +$lines.Add("- Bayi numarası biliniyor mu: $dealerKnown") +$lines.Add('- Bu rapor yalnız metadata içerir; hiçbir dosya kopyalanmaz, taşınmaz veya silinmez.') +$lines.Add('- Aktarım, iş sahibi onayı ve hedef medya doğrulaması sonrasında elle yapılmalıdır.') +$lines.Add('- Hariç tutulanlar: AppData, tarayıcı profilleri, kimlik bilgileri, private key ve uygulama veritabanları.') +$lines.Add('') +if ($notes.Count -gt 0) { + $lines.Add('## Uyarılar') + foreach ($note in $notes) { $lines.Add("- $note") } + $lines.Add('') +} +$lines.Add('## İncelenecek klasörler') +$lines.Add('') +foreach ($folder in $report.candidate_folders) { + $lines.Add("- $($folder['path']) — son değişiklik: $($folder['last_write_utc'])") +} +$lines.Add('') +$lines.Add('## Çıkarılabilir medya kökleri') +$lines.Add('') +foreach ($drive in $report.removable_media_roots) { + $lines.Add("- $($drive['path']) ($($drive['label'])): $($drive['free_gb'])/$($drive['size_gb']) GB boş") +} +$lines.Add('') +$lines.Add('## Eşlenmiş ağ sürücüleri') +$lines.Add('') +foreach ($drive in $report.mapped_network_drives) { + $lines.Add("- $($drive['local_name']) → $($drive['remote_name']) [$($drive['status'])]") +} +$lines.Add('') +$lines.Add('## Yazıcılar') +$lines.Add('') +foreach ($printer in $report.printers) { + $lines.Add("- $($printer['name']) — sürücü: $($printer['driver_name']), port: $($printer['port_name'])") +} +$lines.Add('') +$lines.Add('## MEG veri dizinleri (metadata)') +$lines.Add('') +foreach ($dir in $report.meg_data_directories) { + $lines.Add("- $($dir['path']) (son yazma: $($dir['last_write_utc']))") +} +$lines.Add('') +if ($NoSoftware) { + $lines.Add('Yazılım incelemesi -NoSoftware nedeniyle alınmadı.') +} else { + $lines.Add('## Yazılım incelemesi') + $lines.Add('') + foreach ($software in $report.installed_software_review) { + $lines.Add("- $($software['name']) — $($software['version'])") + } +} +$lines.Add('') +$lines.Add('---') +$lines.Add('') +$lines.Add('Bu manifest salt-okunur bir inceleme kaydıdır. Donanım/ağ/yazılım tarafı için BF-WindowsPreMigrationInventory.ps1 kullanılır.') +$lines | Set-Content -LiteralPath $markdownPath -Encoding utf8 + +Write-Output "Wrote $jsonPath and $markdownPath" diff --git a/scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1 b/scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1 new file mode 100644 index 0000000..32bd350 --- /dev/null +++ b/scripts/migration/windows/BF-WindowsPreMigrationInventory.ps1 @@ -0,0 +1,1070 @@ +<# +.SYNOPSIS + Read-only Windows pre-migration inventory for one Blueforce dealer. +.DESCRIPTION + Collects the full pre-migration picture of a Windows field terminal: + hardware, Windows version, disks/volumes, network (adapters, IP addresses, + DNS, gateways, routes, network profiles, proxy), serial/COM ports, USB + devices and hardware IDs, drivers, installed software, services, scheduled + tasks, Docker presence, VPN clients (WireGuard/OpenVPN/other), RustDesk and + AnyDesk device IDs, MEG application/service/configuration paths, and the + current Windows hostname. + + READ-ONLY CONTRACT (asserted by tests/check-migration-static.sh): + * No Set-*, New-*, Remove-* cmdlets, no service control, no network + configuration command, no registry write, no download. + * No credential, password, token or private key is collected. + * The registry is only ever read (Get-ItemProperty / Get-ChildItem). + * RustDesk/AnyDesk: only the numeric device ID value is extracted. The + RustDesk id_ed25519 private key and the contents of *.conf, *.ovpn and + *.dpapi files are never read - only their file names are listed. + * No external tool is executed (docker.exe included); all data comes from + CIM, the registry and file/directory metadata. + * The only writes are the JSON and Markdown reports under + -OutputDirectory; with -Check nothing is written at all. + + Output contract: BF--windows-inventory.json and + BF--windows-inventory.md, where is the 8-digit dealer number - not + the hostname of the machine being inventoried. +.PARAMETER DealerId + 8-digit Blueforce dealer number (^[0-9]{8}$). Device ID becomes BF-. +.PARAMETER DealerIdUnknown + Explicit opt-in for a device whose dealer number is not known yet. The report + is written as BF-unknown-windows-inventory.* and carries a prominent warning: + it must not be uploaded to the central inventory until the dealer number is + confirmed and the script is re-run with -DealerId. +.PARAMETER OutputDirectory + Directory that receives BF--windows-inventory.json and .md. +.PARAMETER NoSoftware + Skip the software surface (installed software, services, scheduled tasks, + drivers). Hardware, network, device and MEG-path sections are still collected. +.PARAMETER Check + Dry run: validate parameters, print the collection plan and the resolved + output paths, write nothing, exit 0. +.EXAMPLE + .\BF-WindowsPreMigrationInventory.ps1 -DealerId 12010193 -OutputDirectory C:\Blueforce\migration +.EXAMPLE + .\BF-WindowsPreMigrationInventory.ps1 -DealerIdUnknown -OutputDirectory C:\Blueforce\migration +.EXAMPLE + .\BF-WindowsPreMigrationInventory.ps1 -DealerId 12010193 -OutputDirectory C:\Blueforce\migration -Check +.NOTES + Elevation is not required and nothing is changed. Some CIM sections can be + incomplete when the script runs unelevated; that is reported in "notes". +#> +[CmdletBinding(DefaultParameterSetName = 'KnownDealer')] +param( + [Parameter(Mandatory, ParameterSetName = 'KnownDealer')] + [ValidatePattern('^[0-9]{8}$')] + [string]$DealerId, + + [Parameter(Mandatory, ParameterSetName = 'UnknownDealer')] + [switch]$DealerIdUnknown, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputDirectory, + + [switch]$NoSoftware, + + [switch]$Check +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Get-SafeValue { + param([object]$Value) + if ($null -eq $Value) { return 'unknown' } + $text = ([string]$Value).Trim() + if ([string]::IsNullOrWhiteSpace($text)) { return 'unknown' } + return $text +} + +function ConvertTo-MarkdownCell { + param([object]$Value) + return ((Get-SafeValue $Value) -replace '\|', '\|' -replace "`r?`n", '; ') +} + +function ConvertTo-Gigabytes { + param([object]$Bytes) + if ($null -eq $Bytes) { return 'unknown' } + return [math]::Round(([double]$Bytes) / 1GB, 2) +} + +function ConvertTo-Megabytes { + param([object]$Bytes) + if ($null -eq $Bytes) { return 'unknown' } + return [long][math]::Round(([double]$Bytes) / 1MB) +} + +function ConvertTo-IsoUtc { + param([object]$Value) + if ($null -eq $Value) { return 'unknown' } + try { return ([datetime]$Value).ToUniversalTime().ToString('o') } catch { return 'unknown' } +} + +# Null-safe property read for CIM/WMI objects, so a missing class or a failed +# probe degrades to $null/'unknown' instead of aborting the report. +function Get-ObjectValue { + param([object]$InputObject, [string]$Name) + if ($null -eq $InputObject) { return $null } + $prop = $InputObject.PSObject.Properties | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 + if ($null -eq $prop) { return $null } + return $prop.Value +} + +function Get-ProgramFilesRoots { + $roots = @() + if ($env:ProgramFiles) { $roots += $env:ProgramFiles } + if (${env:ProgramFiles(x86)}) { $roots += ${env:ProgramFiles(x86)} } + if ($env:ProgramData) { $roots += $env:ProgramData } + $roots += 'C:\' + return @($roots | Select-Object -Unique) +} + +# Reads named values from a registry key. Read-only; missing values stay +# 'unknown' and never abort the report. +function Get-RegistryValues { + param([string]$Path, [string[]]$Names) + $result = [ordered]@{} + foreach ($name in $Names) { $result[$name] = 'unknown' } + if (-not (Test-Path -LiteralPath $Path)) { return $result } + $props = Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue + if ($null -eq $props) { return $result } + foreach ($name in $Names) { + $prop = $props.PSObject.Properties | Where-Object { $_.Name -eq $name } | Select-Object -First 1 + if ($null -ne $prop) { $result[$name] = Get-SafeValue $prop.Value } + } + return $result +} + +# Reads only the registry values whose NAME matches an exact allow-list +# pattern. Used to pick up path-like settings without ever touching +# credential/secret values that live in the same key. +function Get-RegistryValuesByNamePattern { + param([string]$Path, [string]$NamePattern) + $result = [ordered]@{} + if (-not (Test-Path -LiteralPath $Path)) { return $result } + $props = Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue + if ($null -eq $props) { return $result } + foreach ($prop in $props.PSObject.Properties) { + if ($prop.Name -match $NamePattern) { $result[$prop.Name] = Get-SafeValue $prop.Value } + } + return $result +} + +# Extracts a single captured value from one line of a configuration file. +# The file content is never returned, stored or reported - only the capture +# group. This helper is NEVER called on key material (RustDesk id_ed25519, +# *.ovpn, *.dpapi). +function Get-ConfigFileValue { + param([string]$Path, [string]$LinePattern, [string]$CapturePattern) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return 'not-found' } + $hit = Select-String -LiteralPath $Path -Pattern $LinePattern -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $hit) { return 'not-found' } + if ($hit.Line -match $CapturePattern) { return (Get-SafeValue $Matches[1]) } + return 'not-found' +} + +function Get-FileVersionInfo { + param([string]$Path) + if (-not $Path) { return 'unknown' } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return 'not-found' } + $item = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue + if ($null -eq $item) { return 'unknown' } + if ($null -eq $item.VersionInfo) { return 'unknown' } + return (Get-SafeValue $item.VersionInfo.FileVersion) +} + +# Directory metadata (path + mtime) only; no file contents. +function Get-DirectoryReport { + param([string]$Root, [string]$Filter) + if ([string]::IsNullOrWhiteSpace($Root)) { return @() } + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { return @() } + return @(Get-ChildItem -LiteralPath $Root -Directory -Filter $Filter -ErrorAction SilentlyContinue | + ForEach-Object { + [ordered]@{ + path = $_.FullName + last_write_utc = ConvertTo-IsoUtc $_.LastWriteTimeUtc + review_required = $true + } + }) +} + +# File NAMES only for review lists such as VPN tunnel configs (contents are +# credentials/keys and are deliberately never read). +function Get-FileNameReport { + param([string[]]$Roots, [string]$Filter) + $out = @() + foreach ($root in $Roots) { + if ([string]::IsNullOrWhiteSpace($root)) { continue } + if (-not (Test-Path -LiteralPath $root -PathType Container)) { continue } + $out += @(Get-ChildItem -LiteralPath $root -File -Filter $Filter -ErrorAction SilentlyContinue | + ForEach-Object { + [ordered]@{ + name = $_.Name + path = $_.FullName + last_write_utc = ConvertTo-IsoUtc $_.LastWriteTimeUtc + contents_read = $false + } + }) + } + return @($out) +} + +function Get-NamesOf { + param([object[]]$Items) + return @($Items | ForEach-Object { $_['name'] }) +} + +function Get-InstalledSoftware { + $uninstallPaths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + $items = Get-ItemProperty -Path $uninstallPaths -ErrorAction SilentlyContinue | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.DisplayName) } | + ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.DisplayName + version = Get-SafeValue $_.DisplayVersion + publisher = Get-SafeValue $_.Publisher + install_date = Get-SafeValue $_.InstallDate + install_location = Get-SafeValue $_.InstallLocation + } + } + return @($items | Sort-Object -Property { $_['name'] }) +} + +# Running services, auto-start services that are NOT running (a migration +# signal), and the subset relevant to remote access / Docker / VPN / MEG. +function Get-ServiceSnapshot { + $watchlist = '(?i)docker|wireguard|openvpn|rustdesk|anydesk|mesh|meg|blueforce|teamviewer|tailscale|zerotier|forti|anyconnect|globalprotect|vpn' + $rows = @(Get-CimInstance -ClassName Win32_Service -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.Name + display_name = Get-SafeValue $_.DisplayName + state = Get-SafeValue $_.State + start_mode = Get-SafeValue $_.StartMode + start_name = Get-SafeValue $_.StartName + path = Get-SafeValue $_.PathName + } + }) + $running = @($rows | Where-Object { $_['state'] -eq 'Running' } | Sort-Object -Property { $_['name'] }) + $stoppedAuto = @($rows | Where-Object { $_['state'] -eq 'Stopped' -and $_['start_mode'] -eq 'Auto' } | Sort-Object -Property { $_['name'] }) + $relevant = @($rows | Where-Object { ($_['name'] + ' ' + $_['display_name']) -match $watchlist } | Sort-Object -Property { $_['name'] }) + return [ordered]@{ + total_services = $rows.Count + selection = 'running + auto-start-but-stopped + migration-relevant; a dump of every stopped/disabled service is intentionally omitted to keep the report bounded' + running = $running + auto_start_but_stopped = $stoppedAuto + relevant_to_migration = $relevant + } +} + +# Non-Microsoft scheduled tasks (vendor tasks) plus the migration-relevant +# subset. Uses the ScheduledTasks module only; no other execution path. +function Get-ScheduledTaskSnapshot { + $watchlist = '(?i)docker|meg|blueforce|vpn|wireguard|openvpn|rustdesk|anydesk|mesh|backup|veeam|acronis' + $tasks = @() + try { + $tasks = @(Get-ScheduledTask -ErrorAction Stop | + Where-Object { $_.TaskPath -notlike '\Microsoft\*' } | + ForEach-Object { + [ordered]@{ + task_path = Get-SafeValue $_.TaskPath + task_name = Get-SafeValue $_.TaskName + state = Get-SafeValue ([string]$_.State) + author = Get-SafeValue $_.Author + run_as_user = Get-SafeValue $_.Principal.UserId + } + }) + } catch { + return [ordered]@{ + available = $false + note = 'Get-ScheduledTask unavailable on this host; scheduled-task enumeration skipped (no other tool is invoked).' + total = 0 + items = @() + relevant_to_migration = @() + } + } + $items = @($tasks | Sort-Object -Property { $_['task_path'] + $_['task_name'] }) + return [ordered]@{ + available = $true + note = 'Non-Microsoft task paths only.' + total = $items.Count + items = $items + relevant_to_migration = @($items | Where-Object { ($_['task_path'] + $_['task_name']) -match $watchlist }) + } +} + +# COM/serial ports from three read-only sources: CIM, the SERIALCOMM device +# map, and PnP entities whose friendly name carries a (COMx) suffix. +function Get-ComPortSnapshot { + $cimPorts = @(Get-CimInstance -ClassName Win32_SerialPort -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + device_id = Get-SafeValue $_.DeviceID + name = Get-SafeValue $_.Name + description = Get-SafeValue $_.Description + provider_type = Get-SafeValue $_.ProviderType + pnp_device_id = Get-SafeValue $_.PNPDeviceID + } + }) + $deviceMap = [ordered]@{} + if (Test-Path -LiteralPath 'HKLM:\HARDWARE\DEVICEMAP\SERIALCOMM') { + $props = Get-ItemProperty -LiteralPath 'HKLM:\HARDWARE\DEVICEMAP\SERIALCOMM' -ErrorAction SilentlyContinue + if ($null -ne $props) { + foreach ($prop in $props.PSObject.Properties) { + if ($prop.Name -like 'PS*') { continue } + $deviceMap[$prop.Name] = Get-SafeValue $prop.Value + } + } + } + $pnpPorts = @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like '*(COM*' } | + ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.Name + device_id = Get-SafeValue $_.DeviceID + status = Get-SafeValue $_.Status + } + }) + return [ordered]@{ + cim_serial_ports = $cimPorts + registry_devicemap = $deviceMap + pnp_com_devices = $pnpPorts + } +} + +# USB devices with their hardware IDs (VID_/PID_ live in DeviceID) plus the +# USB host controllers. +function Get-UsbSnapshot { + $devices = @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction SilentlyContinue | + Where-Object { $_.DeviceID -like 'USB\*' -or $_.PNPClass -eq 'USB' } | + ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.Name + description = Get-SafeValue $_.Description + hardware_id = Get-SafeValue $_.DeviceID + device_class = Get-SafeValue $_.PNPClass + status = Get-SafeValue $_.Status + service = Get-SafeValue $_.Service + } + }) + $controllers = @(Get-CimInstance -ClassName Win32_USBController -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.Name + device_id = Get-SafeValue $_.DeviceID + } + }) + return [ordered]@{ + devices = @($devices | Sort-Object -Property { $_['name'] }) + controllers = $controllers + } +} + +function Get-DriverSnapshot { + $drivers = @(Get-CimInstance -ClassName Win32_PnPSignedDriver -ErrorAction SilentlyContinue | + Where-Object { $_.DeviceName } | + ForEach-Object { + [ordered]@{ + device_name = Get-SafeValue $_.DeviceName + device_class = Get-SafeValue $_.DeviceClass + manufacturer = Get-SafeValue $_.Manufacturer + driver_provider = Get-SafeValue $_.DriverProviderName + driver_version = Get-SafeValue $_.DriverVersion + driver_date = ConvertTo-IsoUtc $_.DriverDate + inf_name = Get-SafeValue $_.InfName + } + }) + return [ordered]@{ + total = $drivers.Count + items = @($drivers | Sort-Object -Property { $_['device_name'] }) + } +} + +# Docker presence from install metadata and service state only. docker.exe is +# never executed: no engine contact, no image/build/container side effect. +function Get-DockerSnapshot { + $roots = Get-ProgramFilesRoots + $dockerCli = 'unknown' + $cmd = Get-Command -Name 'docker.exe' -ErrorAction SilentlyContinue + if ($null -ne $cmd) { $dockerCli = Get-SafeValue (Get-ObjectValue $cmd 'Source') } + + $desktopApp = 'not-found' + foreach ($root in $roots) { + $candidate = Join-Path $root 'Docker\Docker\Docker Desktop.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { $desktopApp = $candidate; break } + } + + $service = $null + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='com.docker.service'" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $svc) { + $service = [ordered]@{ + name = Get-SafeValue (Get-ObjectValue $svc 'Name') + state = Get-SafeValue (Get-ObjectValue $svc 'State') + start_mode = Get-SafeValue (Get-ObjectValue $svc 'StartMode') + } + } + + $wslDistros = @() + if (Test-Path -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss') { + $wslDistros = @(Get-ChildItem -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' -ErrorAction SilentlyContinue | + ForEach-Object { + $props = Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue + if ($null -eq $props) { return } + if (@($props.PSObject.Properties.Name) -contains 'DistributionName') { + Get-SafeValue $props.DistributionName + } + } | Where-Object { $_ }) + } + + $present = ($dockerCli -ne 'unknown') -or ($desktopApp -ne 'not-found') -or ($null -ne $service) + return [ordered]@{ + present = [bool]$present + docker_cli_path = $dockerCli + docker_cli_file_version = Get-FileVersionInfo $dockerCli + desktop_app_path = $desktopApp + desktop_app_file_version = Get-FileVersionInfo $desktopApp + service = $service + wsl_distributions = @($wslDistros) + note = 'Presence and file metadata only; the docker CLI is never executed.' + } +} + +# VPN clients: installed software matches, related services, tunnel/profile +# NAMES (contents of *.conf.dpapi and *.ovpn are credentials and are not read). +function Get-VpnSnapshot { + param([object[]]$Software, [object[]]$Services) + + $softwarePattern = '(?i)(wireguard|openvpn|tailscale|zerotier|anyconnect|forticlient|globalprotect|softether|nordvpn|protonvpn|expressvpn|surfshark|windscribe)' + $softwareMatches = @($Software | Where-Object { ($_['name'] + ' ' + $_['publisher']) -match $softwarePattern }) + $servicePattern = '(?i)wireguard|openvpn|tailscale|zerotier|anyconnect|forticlient|globalprotect|vpnagent' + $serviceMatches = @($Services | Where-Object { ($_['name'] + ' ' + $_['display_name']) -match $servicePattern }) + + $roots = Get-ProgramFilesRoots + $wireguardDirs = @() + $openVpnDirs = @() + foreach ($root in $roots) { + $wireguardDirs += (Join-Path $root 'WireGuard\Data\Configurations') + $openVpnDirs += (Join-Path $root 'OpenVPN\config') + $openVpnDirs += (Join-Path $root 'OpenVPN\config-auto') + } + + $wireguardKeys = @() + if (Test-Path -LiteralPath 'HKLM:\SOFTWARE\WireGuard') { + $wireguardKeys = @(Get-ChildItem -LiteralPath 'HKLM:\SOFTWARE\WireGuard' -ErrorAction SilentlyContinue | + ForEach-Object { Get-SafeValue (Get-ObjectValue $_ 'Name') }) + } + $wireguardTunnels = @(Get-FileNameReport -Roots $wireguardDirs -Filter '*.conf.dpapi') + $openVpnProfiles = @(Get-FileNameReport -Roots $openVpnDirs -Filter '*.ovpn') + + $wireguardPresent = (@($softwareMatches | Where-Object { $_['name'] -match '(?i)wireguard' }).Count -gt 0) -or + ($wireguardKeys.Count -gt 0) -or ($wireguardTunnels.Count -gt 0) + $openVpnPresent = (@($softwareMatches | Where-Object { $_['name'] -match '(?i)openvpn' }).Count -gt 0) -or + ($openVpnProfiles.Count -gt 0) + + return [ordered]@{ + wireguard = [ordered]@{ + present = [bool]$wireguardPresent + registry_tunnels = $wireguardKeys + tunnel_config_files = $wireguardTunnels + contents_note = 'Tunnel configuration contents are never read; only file names are listed.' + } + openvpn = [ordered]@{ + present = [bool]$openVpnPresent + profile_files = $openVpnProfiles + registry_paths = Get-RegistryValuesByNamePattern -Path 'HKLM:\SOFTWARE\OpenVPN' -NamePattern '(?i)^(config_dir|config_auto|config|log_dir|exe_path|install_dir)$' + contents_note = 'Profile contents (certificates/keys) are never read; only file names are listed.' + } + installed_vpn_software = $softwareMatches + vpn_services = $serviceMatches + } +} + +# RustDesk / AnyDesk device IDs. Only the ID value is extracted: for RustDesk +# the registry value literally named 'id' or the id = ... line of +# RustDesk.toml; for AnyDesk the ad.anynet.id value. Private-key files +# (id_ed25519) and password/hash settings are never opened. +function Get-RemoteAccessSnapshot { + param([object[]]$Services) + + $rustdeskId = 'not-found' + foreach ($path in @('HKCU:\Software\RustDesk', 'HKLM:\Software\RustDesk', 'HKLM:\Software\WOW6432Node\RustDesk')) { + if ($rustdeskId -ne 'not-found') { break } + if (-not (Test-Path -LiteralPath $path)) { continue } + $props = Get-ItemProperty -LiteralPath $path -ErrorAction SilentlyContinue + if ($null -eq $props) { continue } + $idProp = $props.PSObject.Properties | Where-Object { $_.Name -eq 'id' } | Select-Object -First 1 + if ($null -ne $idProp) { $rustdeskId = Get-SafeValue $idProp.Value } + } + if ($rustdeskId -eq 'not-found' -and $env:APPDATA) { + foreach ($file in @('RustDesk.toml', 'RustDesk2.toml')) { + $value = Get-ConfigFileValue -Path (Join-Path $env:APPDATA "RustDesk\config\$file") ` + -LinePattern '^\s*id\s*=' -CapturePattern "id\s*=\s*'?([0-9A-Za-z]+)'?" + if ($value -ne 'not-found') { $rustdeskId = $value; break } + } + } + + $anydeskId = 'not-found' + foreach ($path in @('HKLM:\Software\AnyDesk', 'HKCU:\Software\AnyDesk', 'HKLM:\Software\WOW6432Node\AnyDesk')) { + if ($anydeskId -ne 'not-found') { break } + if (-not (Test-Path -LiteralPath $path)) { continue } + $props = Get-ItemProperty -LiteralPath $path -ErrorAction SilentlyContinue + if ($null -eq $props) { continue } + $idProp = $props.PSObject.Properties | + Where-Object { $_.Name -match '(?i)^ad\.anynet\.id$|^anynet[._]?id$|^client[._]?id$' } | + Select-Object -First 1 + if ($null -ne $idProp) { $anydeskId = Get-SafeValue $idProp.Value } + } + if ($anydeskId -eq 'not-found') { + $confFiles = @() + if ($env:ProgramData) { $confFiles += (Join-Path $env:ProgramData 'AnyDesk\system.conf') } + if ($env:APPDATA) { $confFiles += (Join-Path $env:APPDATA 'AnyDesk\user.conf') } + foreach ($file in $confFiles) { + $value = Get-ConfigFileValue -Path $file ` + -LinePattern '^\s*ad\.anynet\.id\s*=' -CapturePattern 'ad\.anynet\.id\s*=\s*([0-9A-Za-z]+)' + if ($value -ne 'not-found') { $anydeskId = $value; break } + } + } + + $servicePattern = '(?i)rustdesk|anydesk|mesh|teamviewer' + return [ordered]@{ + rustdesk_id = $rustdeskId + anydesk_id = $anydeskId + rustdesk_id_source = 'registry value named id, else the id line of RustDesk.toml (the id_ed25519 private key is never read)' + anydesk_id_source = 'registry ad.anynet.id, else the ad.anynet.id line of system.conf/user.conf (password/hash settings are never read)' + remote_access_services = @($Services | Where-Object { ($_['name'] + ' ' + $_['display_name']) -match $servicePattern }) + } +} + +# MEG application/service/configuration paths. Directory and registry key +# NAMES plus path-like registry values only; no directory contents, no +# arbitrary registry values. +function Get-MegSnapshot { + param([object[]]$Software, [object[]]$Services, [string[]]$Roots) + + $megPattern = '(?i)\bmeg\b' + $softwareMatches = @($Software | Where-Object { ($_['name'] + ' ' + $_['publisher']) -match $megPattern }) + $serviceMatches = @($Services | Where-Object { ($_['name'] + ' ' + $_['display_name']) -match $megPattern }) + + $directories = @() + foreach ($root in $Roots) { $directories += @(Get-DirectoryReport -Root $root -Filter '*MEG*') } + + $registryKeys = @() + foreach ($base in @('HKLM:\SOFTWARE', 'HKLM:\SOFTWARE\WOW6432Node', 'HKCU:\Software')) { + if (-not (Test-Path -LiteralPath $base)) { continue } + $registryKeys += @(Get-ChildItem -LiteralPath $base -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -match $megPattern } | + ForEach-Object { Get-SafeValue $_.Name }) + } + $registryKeys = @($registryKeys | Where-Object { $_ -ne 'unknown' } | Select-Object -Unique) + + $registryPaths = [ordered]@{} + foreach ($key in $registryKeys) { + # Registry key names come back as HKEY_LOCAL_MACHINE\...; map them to + # the PowerShell drive form before reading. + $readPath = $key -replace '^HKEY_LOCAL_MACHINE', 'HKLM:' -replace '^HKEY_CURRENT_USER', 'HKCU:' + $registryPaths[$key] = Get-RegistryValuesByNamePattern -Path $readPath ` + -NamePattern '(?i)^(install(path|dir)?|config(path|dir)?|data(path|dir)?|app(path|dir)?|exe(path)?|program(path|dir)?)$' + } + + $installLocations = @($softwareMatches | ForEach-Object { $_['install_location'] } | Where-Object { $_ -ne 'unknown' }) + + return [ordered]@{ + software = @($softwareMatches) + services = @($serviceMatches) + config_directories = @($directories) + registry_keys = @($registryKeys) + registry_paths = $registryPaths + install_locations = @($installLocations) + note = 'Directory and registry key names plus path-like values only; no file contents and no arbitrary registry values.' + } +} + +function Get-NetworkSnapshot { + $configs = @(Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled=True' -ErrorAction SilentlyContinue | + ForEach-Object { + $addresses = @($_.IPAddress | Where-Object { $_ }) + [ordered]@{ + description = Get-SafeValue $_.Description + mac_address = Get-SafeValue $_.MACAddress + dhcp_enabled = [bool]$_.DHCPEnabled + dhcp_server = Get-SafeValue $_.DHCPServer + ipv4_addresses = @($addresses | Where-Object { $_ -match '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' }) + ipv6_addresses = @($addresses | Where-Object { $_ -match ':' }) + subnet_masks = @($_.IPSubnet | Where-Object { $_ }) + default_gateways = @($_.DefaultIPGateway | Where-Object { $_ }) + dns_servers = @($_.DNSServerSearchOrder | Where-Object { $_ }) + dns_domain = Get-SafeValue $_.DNSDomain + dns_hostname = Get-SafeValue $_.DNSHostName + } + }) + + $routes = @(Get-CimInstance -ClassName Win32_IP4RouteTable -ErrorAction SilentlyContinue | + ForEach-Object { + [ordered]@{ + destination = Get-SafeValue $_.Destination + netmask = Get-SafeValue $_.Mask + next_hop = Get-SafeValue $_.NextHop + interface_index = Get-SafeValue $_.InterfaceIndex + metric = Get-SafeValue $_.Metric1 + protocol = Get-SafeValue $_.Protocol + route_type = Get-SafeValue $_.Type + } + } | Sort-Object -Property { $_['destination'] }) + + $profiles = @() + $profileRoot = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + if (Test-Path -LiteralPath $profileRoot) { + $profiles = @(Get-ChildItem -LiteralPath $profileRoot -ErrorAction SilentlyContinue | + ForEach-Object { + $props = Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue + if ($null -eq $props) { return } + [ordered]@{ + name = Get-SafeValue $props.ProfileName + description = Get-SafeValue $props.Description + category = Get-SafeValue $props.Category + } + }) + } + + return [ordered]@{ + interfaces = $configs + macs = @($configs | ForEach-Object { $_['mac_address'] } | Where-Object { $_ -ne 'unknown' } | Select-Object -Unique) + ipv4_addresses = @($configs | ForEach-Object { $_['ipv4_addresses'] } | Where-Object { $_ }) + default_gateways = @($configs | ForEach-Object { $_['default_gateways'] } | Where-Object { $_ } | Select-Object -Unique) + dns_servers = @($configs | ForEach-Object { $_['dns_servers'] } | Where-Object { $_ } | Select-Object -Unique) + routes = $routes + network_profiles = $profiles + profile_source_note = 'Network profile names come from the NetworkList registry hive; no network configuration command is invoked.' + proxy = Get-RegistryValuesByNamePattern -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -NamePattern '(?i)^(ProxyEnable|ProxyServer|ProxyOverride|AutoConfigURL)$' + } +} + +# --------------------------------------------------------------------------- +# Identity, parameters, dry-run gate +# --------------------------------------------------------------------------- + +$dealerKnown = ($PSCmdlet.ParameterSetName -eq 'KnownDealer') +$dealerIdValue = 'unknown' +if ($dealerKnown) { $dealerIdValue = $DealerId } +$DealerId = $dealerIdValue +$deviceId = "BF-$DealerId" +$targetHostname = 'unknown' +if ($dealerKnown) { $targetHostname = "bf-$DealerId" } + +$currentHostname = Get-SafeValue $env:COMPUTERNAME +if ($currentHostname -eq 'unknown') { + $currentHostname = Get-SafeValue (Get-ObjectValue (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue) 'Name') +} +$hostName = $currentHostname.ToLower() + +$now = Get-Date +$collectedAtUtc = $now.ToUniversalTime().ToString('o') +$collectedAtLocal = $now.ToString('o') + +$baseName = "BF-$DealerId-windows-inventory" +$jsonPath = Join-Path $OutputDirectory "$baseName.json" +$markdownPath = Join-Path $OutputDirectory "$baseName.md" + +$isElevated = $false +try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $isElevated = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} catch { $isElevated = $false } + +$notes = [System.Collections.Generic.List[string]]::new() +if (-not $dealerKnown) { + $notes.Add('WARNING: dealer number unknown (-DealerIdUnknown). Output is BF-unknown-windows-inventory.* and must NOT be uploaded to the central inventory until the dealer number is confirmed and the script is re-run with -DealerId.') +} +if (-not $isElevated) { + $notes.Add('Not running elevated: some CIM sections may be incomplete. Nothing is changed either way.') +} +$notes.Add('Read-only run: no configuration, service, registry or network state is modified and no download is performed.') + +if ($Check) { + $softwarePlan = 'collected' + if ($NoSoftware) { $softwarePlan = 'skipped (-NoSoftware)' } + Write-Output "CHECK: $baseName.ps1 read-only dry run" + Write-Output " dealer_id : $dealerIdValue (known: $dealerKnown)" + Write-Output " device_id : $deviceId" + Write-Output " hostname : $hostName" + Write-Output " target_hostname: $targetHostname" + Write-Output " output_json : $jsonPath" + Write-Output " output_md : $markdownPath" + Write-Output " software_surface: $softwarePlan" + Write-Output ' writes : none (dry run)' + exit 0 +} + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + +# --------------------------------------------------------------------------- +# Collection +# --------------------------------------------------------------------------- + +$computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue +$bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue +$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue +$processor = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1 +$videoController = Get-CimInstance -ClassName Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 1 + +$biosSmbiosVersion = Get-SafeValue (Get-ObjectValue $bios 'SMBIOSBIOSVersion') +$biosVersion = $biosSmbiosVersion +if ($biosVersion -eq 'unknown') { $biosVersion = Get-SafeValue (Get-ObjectValue $bios 'Version') } + +$windowsRegistry = Get-RegistryValues -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Names @( + 'ProductName', 'EditionID', 'DisplayVersion', 'ReleaseId', 'CurrentBuild', 'UBR', 'InstallationType' +) +# Windows ProductId is a licence identifier and is deliberately not collected. + +$hotfixes = @(Get-CimInstance -ClassName Win32_QuickFixEngineering -ErrorAction SilentlyContinue | + ForEach-Object { + [ordered]@{ + hotfix_id = Get-SafeValue $_.HotFixID + description = Get-SafeValue $_.Description + installed_on = Get-SafeValue $_.InstalledOn + } + } | Sort-Object -Property { $_['hotfix_id'] }) + +$diskData = @(Get-CimInstance -ClassName Win32_DiskDrive -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + name = Get-SafeValue $_.Model + size_gb = ConvertTo-Gigabytes $_.Size + interface_type = Get-SafeValue $_.InterfaceType + media_type = Get-SafeValue $_.MediaType + serial = Get-SafeValue $_.SerialNumber + partitions = Get-SafeValue $_.Partitions + } +}) +$volumeData = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue | ForEach-Object { + [ordered]@{ + drive = Get-SafeValue $_.DeviceID + label = Get-SafeValue $_.VolumeName + file_system = Get-SafeValue $_.FileSystem + size_gb = ConvertTo-Gigabytes $_.Size + free_gb = ConvertTo-Gigabytes $_.FreeSpace + } +}) +$userProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { -not $_.Special -and $_.LocalPath } | + ForEach-Object { + [ordered]@{ + path = Get-SafeValue $_.LocalPath + loaded = [bool]$_.Loaded + last_use_time = ConvertTo-IsoUtc $_.LastUseTime + } + }) + +$installedSoftware = @() +$serviceSnapshot = $null +$serviceRows = @() +$taskSnapshot = $null +$driverSnapshot = $null +if (-not $NoSoftware) { + $installedSoftware = @(Get-InstalledSoftware) + $serviceSnapshot = Get-ServiceSnapshot + $serviceRows = @($serviceSnapshot['running']) + @($serviceSnapshot['relevant_to_migration']) + $taskSnapshot = Get-ScheduledTaskSnapshot + $driverSnapshot = Get-DriverSnapshot +} + +$megRoots = @(Get-ProgramFilesRoots) + +$inventory = [ordered]@{ + schema_version = 1 + report_type = 'windows-pre-migration-inventory' + device_id = $deviceId + hostname = $hostName + current_hostname = $currentHostname + target_hostname = $targetHostname + dealer_id = $dealerIdValue + dealer_id_known = [bool]$dealerKnown + collected_at = $collectedAtLocal + collected_at_utc = $collectedAtUtc + read_only = $true + collection_scope = [ordered]@{ + read_only = $true + elevated = [bool]$isElevated + software_surface_included = (-not $NoSoftware) + executed_external_tools = @() + excluded = @( + 'credentials', 'passwords', 'private keys', 'tokens', 'file contents', + 'browser profiles', 'event logs', 'DPAPI blobs', 'Windows ProductId', + 'VPN tunnel/profile file contents', 'RustDesk id_ed25519 private key' + ) + } + notes = @($notes) + manufacturer = Get-SafeValue (Get-ObjectValue $computerSystem 'Manufacturer') + model = Get-SafeValue (Get-ObjectValue $computerSystem 'Model') + serial = Get-SafeValue (Get-ObjectValue $bios 'SerialNumber') + bios = [ordered]@{ + vendor = Get-SafeValue (Get-ObjectValue $bios 'Manufacturer') + version = $biosVersion + smbios_version = $biosSmbiosVersion + date = Get-SafeValue (Get-ObjectValue $bios 'ReleaseDate') + } + cpu = [ordered]@{ + model = Get-SafeValue (Get-ObjectValue $processor 'Name') + cores = Get-SafeValue (Get-ObjectValue $processor 'NumberOfCores') + logical_processors = Get-SafeValue (Get-ObjectValue $processor 'NumberOfLogicalProcessors') + arch = Get-SafeValue (Get-ObjectValue $operatingSystem 'OSArchitecture') + address_width = Get-SafeValue (Get-ObjectValue $processor 'AddressWidth') + } + memory_mb = ConvertTo-Megabytes (Get-ObjectValue $computerSystem 'TotalPhysicalMemory') + disks = $diskData + volumes = $volumeData + gpu = [ordered]@{ + model = Get-SafeValue (Get-ObjectValue $videoController 'Name') + driver_version = Get-SafeValue (Get-ObjectValue $videoController 'DriverVersion') + video_processor = Get-SafeValue (Get-ObjectValue $videoController 'VideoProcessor') + adapter_ram_mb = ConvertTo-Megabytes (Get-ObjectValue $videoController 'AdapterRAM') + } + os = [ordered]@{ + distro = Get-SafeValue (Get-ObjectValue $operatingSystem 'Caption') + version = Get-SafeValue (Get-ObjectValue $operatingSystem 'Version') + build = Get-SafeValue (Get-ObjectValue $operatingSystem 'BuildNumber') + } + windows = [ordered]@{ + product_name = $windowsRegistry['ProductName'] + edition_id = $windowsRegistry['EditionID'] + display_version = $windowsRegistry['DisplayVersion'] + release_id = $windowsRegistry['ReleaseId'] + current_build = $windowsRegistry['CurrentBuild'] + ubr = $windowsRegistry['UBR'] + installation_type = $windowsRegistry['InstallationType'] + caption = Get-SafeValue (Get-ObjectValue $operatingSystem 'Caption') + version = Get-SafeValue (Get-ObjectValue $operatingSystem 'Version') + build = Get-SafeValue (Get-ObjectValue $operatingSystem 'BuildNumber') + architecture = Get-SafeValue (Get-ObjectValue $operatingSystem 'OSArchitecture') + install_date = ConvertTo-IsoUtc (Get-ObjectValue $operatingSystem 'InstallDate') + last_boot = ConvertTo-IsoUtc (Get-ObjectValue $operatingSystem 'LastBootUpTime') + product_id_collected = $false + hotfixes = $hotfixes + } + network = Get-NetworkSnapshot + serial_ports = Get-ComPortSnapshot + usb = Get-UsbSnapshot + drivers = $driverSnapshot + installed_software = $installedSoftware + services = $serviceSnapshot + scheduled_tasks = $taskSnapshot + docker = Get-DockerSnapshot + vpn = Get-VpnSnapshot -Software $installedSoftware -Services $serviceRows + remote_access = Get-RemoteAccessSnapshot -Services $serviceRows + meg = Get-MegSnapshot -Software $installedSoftware -Services $serviceRows -Roots $megRoots + user_profiles = $userProfiles +} + +$inventory | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $jsonPath -Encoding utf8 + +# --------------------------------------------------------------------------- +# Markdown report +# --------------------------------------------------------------------------- + +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add("# Windows Ön Geçiş Envanteri — $deviceId") +$lines.Add('') +$lines.Add("- Toplama zamanı (UTC): $collectedAtUtc") +$lines.Add("- Mevcut hostname: $currentHostname") +$lines.Add("- Hedef hostname (geçiş sonrası): $targetHostname") +$lines.Add("- Bayi numarası biliniyor mu: $dealerKnown") +$lines.Add("- Kapsam: yalnız okuma; kimlik bilgisi, parola, private key, dosya içeriği ve olay kaydı toplanmaz.") +$lines.Add('') +if ($notes.Count -gt 0) { + $lines.Add('## Uyarılar') + foreach ($note in $notes) { $lines.Add("- $note") } + $lines.Add('') +} +$lines.Add('## Kimlik ve donanım') +$lines.Add('') +$lines.Add('| Alan | Değer |') +$lines.Add('|---|---|') +foreach ($item in @( + @{ Name = 'Device ID'; Value = $inventory.device_id }, + @{ Name = 'Mevcut hostname'; Value = $inventory.current_hostname }, + @{ Name = 'Hedef hostname'; Value = $inventory.target_hostname }, + @{ Name = 'Üretici'; Value = $inventory.manufacturer }, + @{ Name = 'Model'; Value = $inventory.model }, + @{ Name = 'Seri no'; Value = $inventory.serial }, + @{ Name = 'BIOS'; Value = "$($inventory.bios.version)" }, + @{ Name = 'CPU'; Value = "$($inventory.cpu.model)" }, + @{ Name = 'Çekirdek'; Value = "$($inventory.cpu.cores)/$($inventory.cpu.logical_processors)" }, + @{ Name = 'Mimari'; Value = $inventory.cpu.arch }, + @{ Name = 'RAM (MB)'; Value = $inventory.memory_mb }, + @{ Name = 'GPU'; Value = $inventory.gpu.model } +)) { $lines.Add("| $($item['Name']) | $(ConvertTo-MarkdownCell $item['Value']) |") } +$lines.Add('') +$lines.Add('## Windows sürümü') +$lines.Add('') +$lines.Add('| Alan | Değer |') +$lines.Add('|---|---|') +foreach ($item in @( + @{ Name = 'Ürün'; Value = $inventory.windows.product_name }, + @{ Name = 'Sürüm (DisplayVersion)'; Value = $inventory.windows.display_version }, + @{ Name = 'ReleaseId'; Value = $inventory.windows.release_id }, + @{ Name = 'Build'; Value = "$($inventory.windows.current_build).$($inventory.windows.ubr)" }, + @{ Name = 'Kurulum tipi'; Value = $inventory.windows.installation_type }, + @{ Name = 'Kurulum tarihi'; Value = $inventory.windows.install_date }, + @{ Name = 'Son boot'; Value = $inventory.windows.last_boot }, + @{ Name = 'Hotfix sayısı'; Value = $inventory.windows.hotfixes.Count } +)) { $lines.Add("| $($item['Name']) | $(ConvertTo-MarkdownCell $item['Value']) |") } +$lines.Add('') +$lines.Add('## Diskler ve birimler') +$lines.Add('') +foreach ($disk in $inventory.disks) { + $lines.Add("- $($disk['name']): $($disk['size_gb']) GB ($($disk['interface_type']), $($disk['media_type'])); seri: $($disk['serial'])") +} +foreach ($volume in $inventory.volumes) { + $lines.Add("- Birim $($volume['drive']) ($($volume['label'])): $($volume['free_gb'])/$($volume['size_gb']) GB boş, $($volume['file_system'])") +} +$lines.Add('') +$lines.Add('## Ağ') +$lines.Add('') +$lines.Add('| Bağdaştırıcı | MAC | DHCP | IPv4 | Gateway | DNS |') +$lines.Add('|---|---|---|---|---|---|') +foreach ($adapter in $inventory.network.interfaces) { + $lines.Add("| $(ConvertTo-MarkdownCell $adapter['description']) | $($adapter['mac_address']) | $($adapter['dhcp_enabled']) | $($adapter['ipv4_addresses'] -join ', ') | $($adapter['default_gateways'] -join ', ') | $($adapter['dns_servers'] -join ', ') |") +} +$lines.Add('') +$lines.Add("- Varsayılan gateway'ler: $($inventory.network.default_gateways -join ', ')") +$lines.Add("- DNS sunucuları: $($inventory.network.dns_servers -join ', ')") +$lines.Add('') +$lines.Add('### Yönlendirme tablosu (IPv4)') +$lines.Add('') +$lines.Add('| Hedef | Mask | Next hop | Metric |') +$lines.Add('|---|---|---|---|') +foreach ($route in $inventory.network.routes) { + $lines.Add("| $($route['destination']) | $($route['netmask']) | $($route['next_hop']) | $($route['metric']) |") +} +$lines.Add('') +$lines.Add('### Ağ profilleri, proxy') +$lines.Add('') +foreach ($profile in $inventory.network.network_profiles) { + $lines.Add("- Profil: $($profile['name']) ($($profile['description']))") +} +foreach ($entry in $inventory.network.proxy.GetEnumerator()) { + $lines.Add("- Proxy $($entry.Key): $($entry.Value)") +} +$lines.Add('') +$lines.Add('## Seri / COM portları') +$lines.Add('') +foreach ($port in $inventory.serial_ports.cim_serial_ports) { + $lines.Add("- $($port['device_id']): $($port['name']) ($($port['description']))") +} +foreach ($port in $inventory.serial_ports.pnp_com_devices) { + $lines.Add("- PnP: $($port['name']) — $($port['device_id']) [$($port['status'])]") +} +foreach ($entry in $inventory.serial_ports.registry_devicemap.GetEnumerator()) { + $lines.Add("- SERIALCOMM $($entry.Key) = $($entry.Value)") +} +$lines.Add('') +$lines.Add('## USB ve donanım kimlikleri') +$lines.Add('') +foreach ($device in $inventory.usb.devices) { + $lines.Add("- $($device['name']) — $($device['hardware_id']) [$($device['status'])]") +} +$lines.Add('') +$lines.Add("- USB denetleyici sayısı: $($inventory.usb.controllers.Count)") +$lines.Add('') +$lines.Add('## Docker') +$lines.Add('') +$lines.Add("- Kurulu: $($inventory.docker.present)") +$lines.Add("- CLI: $($inventory.docker.docker_cli_path) (sürüm: $($inventory.docker.docker_cli_file_version))") +$lines.Add("- Docker Desktop: $($inventory.docker.desktop_app_path)") +if ($null -ne $inventory.docker.service) { + $lines.Add("- Servis: $($inventory.docker.service['name']) [$($inventory.docker.service['state'])]") +} +$lines.Add("- WSL dağıtımları: $($inventory.docker.wsl_distributions -join ', ')") +$lines.Add('') +$lines.Add('## VPN istemcileri') +$lines.Add('') +$wireguardNames = @($inventory.vpn.wireguard.registry_tunnels) + @(Get-NamesOf $inventory.vpn.wireguard.tunnel_config_files) +$openVpnNames = @(Get-NamesOf $inventory.vpn.openvpn.profile_files) +$lines.Add("- WireGuard kurulu: $($inventory.vpn.wireguard.present); tüneller: $($wireguardNames -join ', ')") +$lines.Add("- OpenVPN kurulu: $($inventory.vpn.openvpn.present); profiller: $($openVpnNames -join ', ')") +foreach ($app in $inventory.vpn.installed_vpn_software) { + $lines.Add("- VPN yazılımı: $($app['name']) $($app['version'])") +} +foreach ($svc in $inventory.vpn.vpn_services) { + $lines.Add("- VPN servisi: $($svc['name']) [$($svc['state'])]") +} +$lines.Add('') +$lines.Add('## Uzak erişim kimlikleri') +$lines.Add('') +$lines.Add("- RustDesk ID: $($inventory.remote_access.rustdesk_id)") +$lines.Add("- AnyDesk ID: $($inventory.remote_access.anydesk_id)") +foreach ($svc in $inventory.remote_access.remote_access_services) { + $lines.Add("- Uzak erişim servisi: $($svc['name']) [$($svc['state'])]") +} +$lines.Add('') +$lines.Add('## MEG (uygulama / servis / config yolları)') +$lines.Add('') +foreach ($app in $inventory.meg.software) { + $lines.Add("- Uygulama: $($app['name']) $($app['version']) — $($app['install_location'])") +} +foreach ($svc in $inventory.meg.services) { + $lines.Add("- Servis: $($svc['name']) [$($svc['state'])]") +} +foreach ($dir in $inventory.meg.config_directories) { + $lines.Add("- Dizin: $($dir['path']) (son yazma: $($dir['last_write_utc']))") +} +foreach ($key in $inventory.meg.registry_keys) { + $lines.Add("- Registry anahtarı: $key") +} +$lines.Add('') +if ($NoSoftware) { + $lines.Add('## Yazılım yüzeyi') + $lines.Add('') + $lines.Add('Yazılım envanteri (yüklü yazılım, servisler, zamanlanmış görevler, sürücüler) -NoSoftware nedeniyle alınmadı; ilgili JSON alanları boş bırakıldı.') +} else { + $lines.Add('## Yüklü yazılım') + $lines.Add('') + foreach ($software in $inventory.installed_software) { + $lines.Add("- $($software['name']) — $($software['version']) ($($software['publisher']))") + } + $lines.Add('') + $lines.Add('## Servisler (çalışan, göçle ilgili, auto-start ama durmuş)') + $lines.Add('') + foreach ($svc in $inventory.services.running) { + $lines.Add("- [çalışıyor] $($svc['name']) — $($svc['display_name'])") + } + foreach ($svc in $inventory.services.auto_start_but_stopped) { + $lines.Add("- [durmuş, auto] $($svc['name']) — $($svc['display_name'])") + } + $lines.Add('') + $lines.Add('## Zamanlanmış görevler (Microsoft dışı)') + $lines.Add('') + if ($inventory.scheduled_tasks.available) { + foreach ($task in $inventory.scheduled_tasks.items) { + $lines.Add("- $($task['task_path'])$($task['task_name']) [$($task['state'])]") + } + } else { + $lines.Add($inventory.scheduled_tasks.note) + } + $lines.Add('') + $lines.Add("## Sürücüler ($($inventory.drivers.total) kayıt)") + $lines.Add('') + foreach ($driver in $inventory.drivers.items) { + $lines.Add("- $($driver['device_name']) — $($driver['driver_version']) ($($driver['driver_provider']))") + } +} +$lines.Add('') +$lines.Add('## Kullanıcı profilleri') +$lines.Add('') +foreach ($profile in $inventory.user_profiles) { + $lines.Add("- $($profile['path']) (son kullanım: $($profile['last_use_time']))") +} +$lines.Add('') +$lines.Add('---') +$lines.Add('') +$lines.Add('Bu rapor salt-okunur bir taramadır; hiçbir ayar, servis, ağ veya registry durumu değiştirilmedi. Veri aktarımı için BF-WindowsDataExport.ps1 kullanılır.') +$lines | Set-Content -LiteralPath $markdownPath -Encoding utf8 + +Write-Output "Wrote $jsonPath and $markdownPath" diff --git a/scripts/migration/windows/README.md b/scripts/migration/windows/README.md new file mode 100644 index 0000000..076ab2c --- /dev/null +++ b/scripts/migration/windows/README.md @@ -0,0 +1,171 @@ +# Windows → Field OS migration tooling (`scripts/migration/windows/`) + +Read-only inventory and export-manifest tooling that runs **on the Windows +terminal before it is migrated** to Blueforce Field OS (docs/28). It never +installs, never writes to the machine, never touches a service, the registry or +the network configuration, and never collects credentials or secrets. + +## Files + +| File | Report type | Purpose | +|---|---|---| +| `BF-WindowsPreMigrationInventory.ps1` | `windows-pre-migration-inventory` | Hardware, Windows version, disks/volumes, network (IP/DNS/gateway/routes/profiles/proxy), serial-COM ports, USB devices + hardware IDs, drivers, installed software, services, scheduled tasks, Docker presence, VPN clients, RustDesk/AnyDesk IDs, MEG paths, current hostname. | +| `BF-WindowsDataExport.ps1` | `windows-pre-migration-data-export-manifest` | Metadata-only review manifest for the manual data transfer: candidate user folders, removable media, mapped network drives, printers, MEG data directories, installed-software review. Copies nothing. | + +Both scripts emit `BF--windows-inventory*.json` (machine) and +`BF--windows-inventory*.md` (human). + +## Why this directory and not a root `migration/windows/` + +The requested layout was `migration/windows/`; this repository keeps every +operational script under `scripts/` (`scripts/{install,maintenance,diagnostics,recovery,migration}`), +so the tooling lives in `scripts/migration/windows/`. The deviation is +deliberate and documented in: + +- `migration/README.md` (repo root) — pointer file kept at the expected location +- `docs/32-REPOSITORY-AUDIT-AND-CONFLICTS.md` §B-6 — audit entry for the name and location deviation + +Filename deviation from the original request (kept, not "fixed", to preserve +repository consistency). No wrapper scripts are added on purpose: the static +test `tests/check-migration-static.sh` requires **every** `*.ps1` in this +directory to carry the dealer-id/JSON/Markdown/`-NoSoftware` contract, so thin +renamed wrappers would only duplicate the same contract in four files. + +| Requested filename | Repo artifact | Status | +|---|---|---| +| `bf-win-inventory.ps1` | `BF-WindowsPreMigrationInventory.ps1` — identity/hardware/OS/disk blocks | Implemented under the repo name | +| `bf-win-network-export.ps1` | same script, `network` + `serial_ports` + `usb` sections | Merged, no separate file | +| `bf-win-software-export.ps1` | same script, `installed_software` / `services` / `scheduled_tasks` / `drivers` / `docker` / `vpn` / `remote_access` / `meg` sections (skippable with `-NoSoftware`) | Merged, no separate file | +| `bf-win-device-export.ps1` | `BF-WindowsDataExport.ps1` (`BF--windows-inventory-export.json/.md`) | Implemented under the repo name | + +This mapping table is the authoritative name/deviation record: one inventory +report and one export manifest, not four fragmented scripts. + +## How to run + +PowerShell 5.1 (Windows PowerShell) and PowerShell 7+ are both supported. The +scripts are UTF-8 **with BOM** so Windows PowerShell 5.1 reads the Turkish +report headers correctly. + +```powershell +# 1) Dry run first: prints the plan and the output paths, writes nothing. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\BF-WindowsPreMigrationInventory.ps1 ` + -DealerId 12010193 -OutputDirectory C:\Blueforce\migration -Check + +# 2) Real read-only collection (dealer number known). +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\BF-WindowsPreMigrationInventory.ps1 ` + -DealerId 12010193 -OutputDirectory C:\Blueforce\migration + +# 3) Data-export review manifest (metadata only). +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\BF-WindowsDataExport.ps1 ` + -DealerId 12010193 -OutputDirectory C:\Blueforce\migration + +# 4) Privacy-reduced run: skip the software surface. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\BF-WindowsPreMigrationInventory.ps1 ` + -DealerId 12010193 -OutputDirectory C:\Blueforce\migration -NoSoftware +``` + +### Execution policy + +`-ExecutionPolicy Bypass` applies to **that one process only**; it does not +change the machine or user execution policy. If the operator cannot use it, +either unblock the files (`Unblock-File .\BF-*.ps1`) or run from an already +permitted location/policy. Elevation is not required: nothing is changed, and +an unelevated run only reports which CIM sections were incomplete (the `notes` +array). + +### Dealer number unknown + +The report name comes from the **8-digit dealer number** (`^[0-9]{8}$`), never +from the machine hostname. If the dealer number is genuinely not known yet, use +the explicit opt-in: + +```powershell +.\BF-WindowsPreMigrationInventory.ps1 -DealerIdUnknown -OutputDirectory C:\Blueforce\migration +``` + +That writes `BF-unknown-windows-inventory.*`, sets `dealer_id_known: false` and +adds a prominent warning in `notes` and at the top of the Markdown report: the +file **must not** be uploaded to the central inventory until the dealer number +is confirmed and the script is re-run with `-DealerId`. Do not guess a dealer +number to "fix" the filename. + +## Output contract + +| Item | Value | +|---|---| +| JSON | `/BF--windows-inventory.json` | +| Markdown | `/BF--windows-inventory.md` | +| Export JSON/MD | `/BF--windows-inventory-export.{json,md}` | +| Device ID | `BF-` (from the dealer number) | +| `report_type` | `windows-pre-migration-inventory` (export manifest: `windows-pre-migration-data-export-manifest`) | +| `read_only` | `true` — always; the run performs no state change | +| `dealer_id_known` | `true`, or `false` on an explicit `-DealerIdUnknown` run | +| `current_hostname` | the existing Windows hostname (unchanged, recorded) | +| `target_hostname` | `bf-` — the hostname expected after migration (docs/02) | +| `schema_version` | `1` | + +Field names are aligned with `scripts/diagnostics/bf-hardware-inventory.sh` +(docs/02 §9) so central tooling can consume both: + +| `bf-hardware-inventory.sh` (Linux) | `BF-WindowsPreMigrationInventory.ps1` (Windows) | +|---|---| +| `schema_version`, `device_id`, `hostname`, `dealer_id`, `collected_at`, `notes` | same names (`collected_at` + `collected_at_utc`, `notes` is an array) | +| `manufacturer`, `model`, `serial` | same | +| `bios{vendor,version,date}` | same (+ `smbios_version`) | +| `cpu{model,cores,arch}` | same (+ `logical_processors`, `address_width`) | +| `memory_mb` | same | +| `disks` | per-disk objects `{name,size_gb,interface_type,media_type,serial}` (doc 02 §9 shape) | +| `network{interfaces,macs}` | `network.interfaces[]` (per-adapter objects) + `network.macs[]`, plus `ipv4_addresses`, `default_gateways`, `dns_servers`, `routes`, `network_profiles`, `proxy` | +| `gpu{model}` | same (+ `driver_version`, `video_processor`, `adapter_ram_mb`) | +| `usb` | `usb{devices[],controllers[]}` (includes USB hardware IDs `USB\VID_xxxx&PID_xxxx`) | +| `serial_ports` | `serial_ports{cim_serial_ports[],registry_devicemap,pnp_com_devices[]}` (COM ports) | +| `os{distro,kernel}` | `os{distro,version,build}` + a `windows{}` block (edition, DisplayVersion, build/UBR, hotfixes) | +| `smart`, `tpm`, `virt`, `temps` | not applicable on Windows; not emitted | +| — | Windows-only blocks: `windows`, `volumes`, `drivers`, `installed_software`, `services`, `scheduled_tasks`, `docker`, `vpn`, `remote_access`, `meg`, `user_profiles`, `collection_scope` | + +## Read-only guarantee + +Asserted mechanically by `tests/check-migration-static.sh` on every run: + +- **Forbidden primitives** — no `Set-*`/`New-*`/`Remove-*` item or property + cmdlet, no service control cmdlet, no network-configuration command of any + kind, no registry write, no download cmdlet, no external process execution + (the exact forbidden families are enumerated in + `tests/check-migration-static.sh`). The only write is the report pair under + `-OutputDirectory` (and `-Check` writes nothing at all). +- **Never collected** — credentials, passwords, tokens, private keys, DPAPI + blobs, file contents, browser profiles, event logs, `Windows ProductId`. +- **Device IDs only** — RustDesk/AnyDesk: exactly one ID value is extracted + (registry value named `id` / `ad.anynet.id`, else the matching one-line value + in `RustDesk.toml` / `system.conf`). The RustDesk `id_ed25519` private key, + AnyDesk password/hash settings and `*.ovpn`/`*.conf.dpapi` contents are never + read — VPN tunnel/profile files are listed **by name only**. +- **Registry is read-only** — `Get-ItemProperty`/`Get-ChildItem` only, and + path-like values are selected by an exact name allow-list so unrelated + (possibly sensitive) values in the same key are ignored. +- **Software surface is opt-out** — `-NoSoftware` skips installed software, + services, scheduled tasks and drivers; hardware, network, device and MEG-path + sections are still collected. +- **Dry run** — `-Check` prints the plan and output paths, writes nothing, + exits `0`. + +## Verification status (honest note) + +There is no PowerShell interpreter on the Linux build host, so these scripts are +statically verified only: syntax/structure review, the `tests/check-migration-static.sh` +contract (tag names, dealer-id regex, JSON/Markdown output, forbidden-primitive +greps) and brace/quote balance checking. **They have not been executed on a real +Windows terminal yet** — first execution belongs to the LAB/pilot step in +docs/28 §10 (`bf-hardware-inventory.sh` runs after migration and its JSON is +diffed against this report). Treat the first run output as unvalidated until +that LAB step is recorded. + +## Related documentation + +- `docs/28-WINDOWS-TO-LINUX-MIGRATION.md` — migration gate, test plan, rollback +- `docs/02-DEVICE-NAMING-AND-INVENTORY.md` — `BF-` identity + JSON contract +- `docs/17-BACKUP-RECOVERY-AND-REINSTALL.md` — L8 reimage / recovery ladder +- `scripts/diagnostics/bf-live-hw-check` — read-only hardware gate used **after** + migration on the Linux side +- `migration/README.md` (repo root) — location pointer diff --git a/tests/check-ansible-static.sh b/tests/check-ansible-static.sh new file mode 100755 index 0000000..a77d7dc --- /dev/null +++ b/tests/check-ansible-static.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# Ansible playbook statik güvenlik kapısı (read-only; ansible-playbook kurulumu GEREKTİRMEZ). +# Denetledikleri: +# 1) 21 playbook envanteri (5 dalga işlemi + 5 rapor + 11 dokümante edilmiş tek-hedef/legacy) ve +# sınıflandırılamayan yeni playbook'a kapalı kapı (fail-closed). Dalga işlemi ve rapor +# playbook'ları TAM dalga kapısına tabidir ve hosts: blueforce kullanamaz; tek cihaz +# teşhis/bakım playbook'ları (docs/09 hedefleme seviyeleri) hedef sınırlama talimatı +# taşımak zorundadır -- bunları dalga kapısına taşımak ayrı bir değişikliktir, bu kapı onları +# sessizce genişletmeye izin vermez. +# 2) dalga kapsamı kapısı: dinamik hosts dalgası, alt çizgili approved dalga listesi, onay +# değişkeni, ansible_play_hosts_all kesişimi ve AYRI production_override (production için şart), +# 3) rapor playbook'larının read-only kalması (mutasyon modülü yok, shell/raw yok, her command +# changed_when: false) ve operation_wave_gate ön kapısı, +# 4) moving tag (:latest) ve Watchtower yasağı, +# 5) bf-security-updates-check.yml'in report-only kalması, +# 6) tüm playbook + inventory YAML'larının parse ve yapı doğrulaması. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PB="$REPO_ROOT/ansible/playbooks" +INV="$REPO_ROOT/ansible/inventory/hosts.example.yml" +GV="$REPO_ROOT/ansible/inventory/group_vars/all.yml" +FAIL=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } + +# --- 1) Envanter: 16 mevcut + 5 yeni rapor playbook = 21 ------------------------------------- +WAVE_PLAYBOOKS=(bf-collect-logs.yml bf-deploy-update.yml bf-gui-off.yml bf-gui-on.yml bf-run-script.yml) +REPORT_PLAYBOOKS=(bf-fieldos-version.yml bf-provisioning-status.yml bf-enrollment-status.yml bf-remote-channels-check.yml bf-offline-ready-check.yml) +LEGACY_PLAYBOOKS=(bf-ping.yml bf-uptime.yml bf-reboot.yml bf-disk-check.yml bf-package-check.yml bf-security-updates-check.yml bf-docker-status.yml bf-service-restart.yml bf-wireguard-restart.yml bf-rdp-restart.yml bf-rustdesk-restart.yml) +ALL_PLAYBOOKS=("${WAVE_PLAYBOOKS[@]}" "${REPORT_PLAYBOOKS[@]}" "${LEGACY_PLAYBOOKS[@]}") +GATED_PLAYBOOKS=("${WAVE_PLAYBOOKS[@]}" "${REPORT_PLAYBOOKS[@]}") + +if [[ ${#ALL_PLAYBOOKS[@]} -ne 21 ]]; then + fail "playbook envanteri 21 olmalı, tanım ${#ALL_PLAYBOOKS[@]}" +fi +for pb in "${ALL_PLAYBOOKS[@]}"; do + if [[ -f "$PB/$pb" ]]; then pass "present: $pb"; else fail "missing playbook: $pb"; fi +done + +# Sınıflandırılamayan YENİ playbook kabul edilmez: dalga kapısı veya dokümante edilmiş istisna şart. +unclassified=0 +while IFS= read -r -d '' asset; do + base="$(basename "$asset")" + known=0 + for pb in "${ALL_PLAYBOOKS[@]}"; do [[ "$base" == "$pb" ]] && known=1; done + if [[ "$known" -ne 1 ]]; then + fail "unclassified playbook (fail-closed): $base -- ya dalga kapısı (operation_wave/release_wave) taşımalı ya da dokümante edilmiş tek-hedef istisnası olmalı" + unclassified=$((unclassified + 1)) + fi +done < <(find "$PB" -maxdepth 1 -type f -name '*.yml' -print0) +[[ "$unclassified" -eq 0 ]] && pass "playbook sınıflandırması tam: 21/21 (dalga kapılı veya dokümante edilmiş istisna)" + +# --- 2) Dalga kapsamı kapısı + ayrı production_override -------------------------------------- +APPROVED_LITERAL='[lab, pilot_1, pilot_2, wave_1, wave_2, production]' +for group in lab pilot_1 pilot_2 wave_1 wave_2 production; do + grep -q "^[[:space:]]*$group:" "$INV" || fail "inventory dalga grubu eksik: $group" +done +if grep -nE '^[[:space:]]*(lab|pilot|wave|production)-[0-9A-Za-z]' "$INV"; then + fail "inventory dalga grup adları ALT ÇİZGİLİ olmalı; tireli varyant yasak" +else + pass "inventory dalga grupları alt çizgili (lab/pilot_1/pilot_2/wave_1/wave_2/production)" +fi + +for pb in "${GATED_PLAYBOOKS[@]}"; do + file="$PB/$pb" + [[ -f "$file" ]] || continue + if grep -qE '^ hosts: "\{\{ (operation_wave|release_wave) \| default\(' "$file"; then + pass "$pb: dinamik dalga hosts deseni" + else + fail "$pb: hosts dalga desenini ({{ operation_wave|release_wave | default(...) }}) korumalı" + fi + grep -Fq "$APPROVED_LITERAL" "$file" || fail "$pb: approved dalga listesi birebir olmalı: $APPROVED_LITERAL" + if grep -q -- 'operation_confirmed | bool' "$file" || grep -q -- 'wave_gate_confirmed | bool' "$file"; then + pass "$pb: onay kapısı (operation_confirmed/wave_gate_confirmed) mevcut" + else + fail "$pb: onay kapısı eksik (operation_confirmed | bool)" + fi + grep -q 'ansible_play_hosts_all' "$file" || fail "$pb: seçilen hostlar ansible_play_hosts_all ile denetlenmeli" + grep -q 'difference(groups\[' "$file" || fail "$pb: seçilen hostlar dalga grubundan fark olarak denetlenmeli (difference(groups[...]))" + grep -q 'production_override: false' "$file" || fail "$pb: production_override varsayılan false olmalı" + grep -q "'production' or production_override | bool" "$file" || fail "$pb: production dalgası AYRI production_override isteğini denetlemeli" + if grep -qE '^ hosts: blueforce$' "$file"; then + fail "$pb: hosts: blueforce (tüm filo) yasak; dalga kapsamı zorunlu" + else + pass "$pb: tüm-filo hedefi (hosts: blueforce) yok" + fi +done + +if grep -nE '(lab|pilot|wave|production)-[0-9]' "$PB"/*.yml; then + fail "playbook'larda tireli dalga adı bulundu; alt çizgili grup adları zorunlu" +else + pass "playbook'larda tireli dalga adı yok" +fi + +# Rapor playbook'ları: operation_wave_gate ön kapısı (--limit mutabakatı dahil) zorunlu. +for pb in "${REPORT_PLAYBOOKS[@]}"; do + file="$PB/$pb" + [[ -f "$file" ]] || continue + grep -Fq 'operation_wave_gate' "$file" || fail "$pb: operation_wave_gate ön kapısı eksik" + grep -Fq 'hosts: "{{ ansible_limit | default(' "$file" || fail "$pb: dalga ön kapısı --limit ile mutabakat için ansible_limit deseni kullanmalı" + grep -q 'does not intersect the' "$file" || fail "$pb: dalga/limit kesişim reddi (fail-closed mesajı) eksik" +done + +# --- 3) Rapor playbook'ları read-only olmalı ------------------------------------------------- +MUTATING_MODULES='ansible\.builtin\.(shell|raw|script|copy|template|file|lineinfile|replace|blockinfile|user|group|cron|mount|apt|apt_repository|apt_key|apt_repository_key|dpkg_selections|debconf|systemd|systemd_service|service|reboot|package|yum|dnf|pip|get_url|uri|unarchive|archive|git|hostname|sysctl|redhat_subscription|ufw|firewalld|nmcli|command_shell):' +for pb in "${REPORT_PLAYBOOKS[@]}"; do + file="$PB/$pb" + [[ -f "$file" ]] || continue + if grep -nE "$MUTATING_MODULES" "$file"; then + fail "$pb: read-only rapor playbook'unda mutasyon modülü bulundu" + else + pass "$pb: mutasyon modülü yok (stat/slurp/command/debug/assert/set_fact)" + fi + cmd_count="$(grep -c 'ansible\.builtin\.command:' "$file" || true)" + cw_count="$(grep -c 'changed_when: false' "$file" || true)" + if [[ "$cw_count" -ge "$cmd_count" ]]; then + pass "$pb: command görevleri changed_when: false (${cmd_count} command / ${cw_count} changed_when)" + else + fail "$pb: her command görevi changed_when: false taşımalı (${cmd_count} command / ${cw_count} changed_when)" + fi + grep -qi 'read-only' "$file" || fail "$pb: başlıkta read-only sözleşmesi belirtilmeli" + if grep -nE 'changed_when: true|state: (restarted|started|stopped|absent)|--force|git pull|docker pull|rm -rf|mkfs|dd if=' "$file"; then + fail "$pb: read-only sözleşmesini bozan ifade bulundu" + else + pass "$pb: mutasyon/force ifadesi yok" + fi +done + +# --- 4) security-updates-check report-only kalmalı ------------------------------------------ +SU="$PB/bf-security-updates-check.yml" +if [[ -f "$SU" ]]; then + if grep -nE 'state:[[:space:]]*(present|latest)|^[[:space:]]*upgrade:|apt(-get)?[[:space:]].*[[:space:]]install|(apt-get|apt)[[:space:]].*dist-upgrade[[:space:]]*(-y|--assume-yes)|dpkg[[:space:]]+-i|unattended-upgrade[[:space:]]+--' "$SU"; then + fail 'bf-security-updates-check.yml kurulum/yükseltme içeriyor; report-only kalmalı' + else + pass 'bf-security-updates-check.yml report-only (kurulum/yükseltme komutu yok)' + fi + grep -q 'REPORT ONLY' "$SU" && pass 'bf-security-updates-check.yml REPORT ONLY uyarısını koruyor' || fail 'bf-security-updates-check.yml REPORT ONLY uyarısı kaybolmuş' + if grep -qE 'dist-upgrade' "$SU" && ! grep -q 'apt-get -s dist-upgrade' "$SU"; then + fail 'bf-security-updates-check.yml dist-upgrade yalnız simülasyon (-s) olarak koşmalı' + fi +fi + +# Dalga kapısı taşımayan playbook'lar (tek cihaz teşhis/bakım; docs/09 hedefleme seviyeleri) +# en azından hedef sınırlama talimatı veya kendi kapsam assert'ini korumalı. Yeni ve +# sınıflandırılmamış bir playbook bu kapıdan geçemez (bkz. bölüm 1, fail-closed). +for pb in "${LEGACY_PLAYBOOKS[@]}"; do + file="$PB/$pb" + [[ -f "$file" ]] || continue + if grep -q -- '--limit' "$file" || grep -q 'ansible.builtin.assert' "$file"; then + pass "$pb: dalga dışı kullanım hedef sınırlama talimatı taşıyor (--limit veya kapsam assert'i)" + else + fail "$pb: dalga kapısı yok ve hedef sınırlama talimatı da yok (fail-closed)" + fi +done + +# --- 5) Moving tag / Watchtower yasağı ------------------------------------------------------- +if grep -RniE --include='*.yml' --include='*.yaml' --include='*.json' \ + '^[[:space:]]*[^#].*(image:[^#]*:latest([[:space:]#]|$)|watchtower)' \ + "$REPO_ROOT/ansible" "$REPO_ROOT/config" "$REPO_ROOT/monitoring" "$REPO_ROOT/provisioning"; then + fail 'moving tag (:latest) veya Watchtower (otomatik güncelleyici) yapılandırması bulundu' +else + pass 'moving tag (:latest) ve Watchtower yasağı korunuyor' +fi +if grep -RniE --include='*.yml' 'image:[^#]*:latest' "$PB"; then + fail "playbook'larda :latest etiketi bulundu" +else + pass "playbook'larda sabitlenmemiş imaj etiketi yok" +fi + +# --- 6) YAML parse + yapı doğrulaması ------------------------------------------------------- +if python3 - "$REPO_ROOT" <<'PY' +import pathlib +import sys + +import yaml + +root = pathlib.Path(sys.argv[1]) +playbooks = root / "ansible" / "playbooks" +problems = [] + +files = sorted(playbooks.glob("*.yml")) +if len(files) != 21: + problems.append(f"expected 21 playbook YAML files, found {len(files)}") + +for path in files: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 - rapor amaçlı + problems.append(f"{path.name}: YAML parse hatası: {exc}") + continue + if not isinstance(data, list) or not data: + problems.append(f"{path.name}: en üst düzey play listesi olmalı") + continue + for index, play in enumerate(data): + if not isinstance(play, dict): + problems.append(f"{path.name}: play {index} mapping değil") + continue + if "hosts" not in play: + problems.append(f"{path.name}: play {index} hosts anahtarı yok") + if "tasks" not in play and "pre_tasks" not in play: + problems.append(f"{path.name}: play {index} tasks/pre_tasks içermiyor") + if not isinstance(play.get("hosts"), str) or not play.get("hosts", "").strip(): + problems.append(f"{path.name}: play {index} hosts boş veya metin değil") + +for extra in (root / "ansible" / "inventory" / "hosts.example.yml", root / "ansible" / "inventory" / "group_vars" / "all.yml"): + try: + yaml.safe_load(extra.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 - rapor amaçlı + problems.append(f"{extra.name}: YAML parse hatası: {exc}") + +if problems: + print("\n".join(problems)) + sys.exit(1) +PY +then + pass '21 playbook + inventory YAML parse ve yapı doğrulaması' +else + fail 'YAML parse/yapı doğrulaması başarısız (yukarıdaki satırlar)' +fi + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-ansible-static: FAILED\n' >&2 + exit 1 +fi +printf 'check-ansible-static: all passed\n' diff --git a/tests/check-configs.sh b/tests/check-configs.sh new file mode 100755 index 0000000..12af217 --- /dev/null +++ b/tests/check-configs.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Parse fleet configuration and validate executable shell scripts without running them. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL=0 +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } + +# Every Bash script under scripts/ must pass syntax parsing. Non-Bash assets are ignored. +while IFS= read -r -d '' script; do + if head -n 1 "$script" | grep -qE '^#!/usr/bin/env bash|^#!/bin/bash'; then + bash -n "$script" && pass "bash parses: ${script#$REPO_ROOT/}" || fail "bash parse error: ${script#$REPO_ROOT/}" + fi +done < <(find "$REPO_ROOT/scripts" -type f -print0) + +# All Ansible and deployable configuration YAML must parse through PyYAML safe_load. +while IFS= read -r -d '' yaml_file; do + if python3 - "$yaml_file" <<'PY' +import sys +import yaml +with open(sys.argv[1], encoding='utf-8') as handle: + yaml.safe_load(handle) +PY + then + pass "yaml parses: ${yaml_file#$REPO_ROOT/}" + else + fail "yaml parse error: ${yaml_file#$REPO_ROOT/}" + fi +done < <(find "$REPO_ROOT/ansible" "$REPO_ROOT/config" "$REPO_ROOT/monitoring" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + +# JSON configuration must parse as JSON, not merely as JavaScript. +while IFS= read -r -d '' json_file; do + if python3 - "$json_file" <<'PY' +import json +import sys +with open(sys.argv[1], encoding='utf-8') as handle: + json.load(handle) +PY + then + pass "json parses: ${json_file#$REPO_ROOT/}" + else + fail "json parse error: ${json_file#$REPO_ROOT/}" + fi +done < <(find "$REPO_ROOT/config" "$REPO_ROOT/monitoring" -type f -name '*.json' -print0) + +# Required values preserve the naming and wave policy in docs/09 and docs/10. +INV="$REPO_ROOT/ansible/inventory/hosts.example.yml" +GV="$REPO_ROOT/ansible/inventory/group_vars/all.yml" +[[ -f "$INV" ]] || fail "missing inventory example: $INV" +[[ -f "$GV" ]] || fail "missing shared group variables: $GV" +for group in lab pilot_1 pilot_2 wave_1 wave_2 production; do + grep -q "^[[:space:]]*$group:" "$INV" || fail "inventory missing wave group: $group" +done +for key in wg_subnet wg_interface monitoring_endpoint meg_image_tag reboot_grace_seconds support_bundle_dir; do + grep -q "^$key:" "$GV" || fail "group_vars/all.yml missing key: $key" +done +for pb in bf-collect-logs.yml bf-gui-on.yml bf-gui-off.yml bf-run-script.yml; do + grep -q '^ hosts: "{{ operation_wave' "$REPO_ROOT/ansible/playbooks/$pb" || fail "$pb must dynamically target operation_wave" + grep -q 'production_override: false' "$REPO_ROOT/ansible/playbooks/$pb" || fail "$pb must default production_override to false" +done +FIREWALL_POLICY="$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" +bash -n "$FIREWALL_POLICY" && pass 'bash parses: config/firewall/blueforce-docker-user-restore.sh' || fail 'bash parse error: config/firewall/blueforce-docker-user-restore.sh' +grep -q '^After=docker.service$' "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" || fail 'Docker firewall service must wait for Docker' +grep -q '^Wants=blueforce-docker-firewall.service$' "$REPO_ROOT/config/systemd/docker.service.d/blueforce-docker-firewall.conf" || fail 'Docker drop-in must start firewall policy' +if grep -RniE --include='*.yml' --include='*.yaml' --include='*.json' '^[[:space:]]*[^#].*(image:[^#]*:latest([[:space:]#]|$)|watchtower)' "$REPO_ROOT/ansible" "$REPO_ROOT/config" "$REPO_ROOT/monitoring"; then + fail "forbidden moving tag or Watchtower configuration found" +fi + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-configs: FAILED\n' >&2 + exit 1 +fi +printf 'check-configs: all passed\n' diff --git a/tests/check-migration-static.sh b/tests/check-migration-static.sh new file mode 100755 index 0000000..158f99d --- /dev/null +++ b/tests/check-migration-static.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Validate migration tooling safety without PowerShell, QEMU, disks, or network access. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } +require_file() { [[ -f "$1" ]] && pass "present: ${1#$REPO_ROOT/}" || fail "missing: ${1#$REPO_ROOT/}"; } + +WINDOWS_DIR="$REPO_ROOT/scripts/migration/windows" +ANYDESK="$REPO_ROOT/scripts/install/optional/anydesk/install-anydesk.sh" +HW_CHECK="$REPO_ROOT/scripts/diagnostics/bf-live-hw-check" +QEMU_PLAN="$REPO_ROOT/tests/qemu-field-os-test-plan.md" + +require_file "$WINDOWS_DIR/BF-WindowsPreMigrationInventory.ps1" +require_file "$WINDOWS_DIR/BF-WindowsDataExport.ps1" +require_file "$ANYDESK" +require_file "$HW_CHECK" +require_file "$QEMU_PLAN" + +for script in "$WINDOWS_DIR"/*.ps1; do + [[ -f "$script" ]] || continue + grep -Fq "[ValidatePattern('^[0-9]{8}$')]" "$script" || fail "8-digit DealerId validation missing: ${script#$REPO_ROOT/}" + grep -Fq '[string]$OutputDirectory' "$script" || fail "OutputDirectory parameter missing: ${script#$REPO_ROOT/}" + grep -q 'BF-$DealerId-windows-inventory' "$script" || fail "required BF output basename missing: ${script#$REPO_ROOT/}" + grep -qi 'ConvertTo-Json' "$script" || fail "JSON output missing: ${script#$REPO_ROOT/}" + grep -q 'Markdown' "$script" || fail "Markdown output missing: ${script#$REPO_ROOT/}" + # Turkish report headers need a UTF-8 BOM so Windows PowerShell 5.1 parses the file correctly. + [[ "$(head -c 3 "$script" | od -An -tx1 | tr -d ' \n')" == 'efbbbf' ]] || fail "UTF-8 BOM missing (Windows PowerShell 5.1 compatibility): ${script#$REPO_ROOT/}" +done + +grep -q -- '-NoSoftware' "$WINDOWS_DIR/BF-WindowsPreMigrationInventory.ps1" || fail 'inventory must support -NoSoftware' +grep -q -- '-NoSoftware' "$WINDOWS_DIR/BF-WindowsDataExport.ps1" || fail 'export must support -NoSoftware' + +# Migration scripts are inventory/export only: no credential APIs, writes, service/network/registry mutation, or downloads. +if grep -RniE '(Get-Credential|CredentialManager|Windows\.Security\.Credentials|cmdkey|vaultcmd|ConvertTo-SecureString|Read-Host.*password)' "$WINDOWS_DIR"; then + fail 'credential API or password collection found in Windows migration scripts' +else + pass 'no credential API or password collection in Windows scripts' +fi +if grep -RniE '(Set-Service|Start-Service|Stop-Service|Restart-Service|New-Net|Set-Net|Remove-Net|Disable-Net|Enable-Net|netsh|Set-ItemProperty|New-ItemProperty|Remove-ItemProperty|reg(\.exe)?[[:space:]]+(add|delete)|Invoke-WebRequest|curl|wget|Start-BitsTransfer)' "$WINDOWS_DIR"; then + fail 'forbidden mutation or download primitive found in Windows migration scripts' +else + pass 'Windows scripts have no mutation or download primitive' +fi + +# The export manifest must stay metadata-only: no copy, move, archive or delete primitive. +if grep -RniE '(Copy-Item|Move-Item|Remove-Item|Compress-Archive|Expand-Archive|robocopy|xcopy)' "$WINDOWS_DIR"/*.ps1; then + fail 'copy/move/archive/delete primitive found in Windows migration scripts' +else + pass 'Windows scripts have no copy/move/archive/delete primitive' +fi + +# --------------------------------------------------------------------------- +# Migration scope: every section the migration brief requires must be present, +# still read-only, and still named from the dealer number (not the hostname). +# --------------------------------------------------------------------------- +INVENTORY="$WINDOWS_DIR/BF-WindowsPreMigrationInventory.ps1" +EXPORT="$WINDOWS_DIR/BF-WindowsDataExport.ps1" +WINDOWS_README="$WINDOWS_DIR/README.md" +MIGRATION_POINTER="$REPO_ROOT/migration/README.md" + +require_file "$INVENTORY" +require_file "$EXPORT" +require_file "$WINDOWS_README" +require_file "$MIGRATION_POINTER" + +require_marker() { grep -Fq "$2" "$1" || fail "missing '$2' in ${1#$REPO_ROOT/}"; } + +# Network scope: IP config, DNS, gateway(s), routes, profiles, proxy. +for marker in 'Win32_NetworkAdapterConfiguration' 'DNSServerSearchOrder' 'DefaultIPGateway' \ + 'Win32_IP4RouteTable' 'default_gateways' 'network_profiles' 'ProxyServer'; do + require_marker "$INVENTORY" "$marker" +done +# Software scope: installed software, services, scheduled tasks, drivers, Windows version. +for marker in 'installed_software' 'Win32_Service' 'Get-ScheduledTask' 'Win32_PnPSignedDriver' \ + "'DisplayVersion'" 'Win32_QuickFixEngineering' "'UBR'"; do + require_marker "$INVENTORY" "$marker" +done +# Device scope: COM/serial ports, USB hardware IDs. +for marker in 'Win32_SerialPort' 'SERIALCOMM' 'hardware_id' 'Win32_USBController'; do + require_marker "$INVENTORY" "$marker" +done +# Migration-critical applications: Docker, VPN clients, remote-access IDs, MEG paths. +for marker in 'com.docker.service' 'WireGuard' 'OpenVPN' 'conf.dpapi' '.ovpn' \ + 'rustdesk_id' 'anydesk_id' 'Get-MegSnapshot' 'registry_paths'; do + require_marker "$INVENTORY" "$marker" +done +# Identity: dealer-number naming, existing hostname recorded, target hostname. +for marker in 'current_hostname' 'target_hostname' 'dealer_id_known' 'READ-ONLY CONTRACT' \ + 'DealerIdUnknown' 'WARNING: dealer number unknown' 'executed_external_tools'; do + require_marker "$INVENTORY" "$marker" +done +# Secrets stay out of scope, including the RustDesk private key file. +for marker in 'id_ed25519' 'private keys' 'Windows ProductId'; do + require_marker "$INVENTORY" "$marker" +done +grep -Fq -- '-Check' "$INVENTORY" || fail 'inventory must support -Check (read-only dry run)' +grep -Fq -- '-Check' "$EXPORT" || fail 'export must support -Check (read-only dry run)' + +# Export manifest stays metadata-only and review-gated. +for marker in 'windows-pre-migration-data-export-manifest' 'candidate_folders' 'removable_media_roots' \ + 'mapped_network_drives' 'Win32_NetworkConnection' 'Win32_Printer' 'meg_data_directories' \ + 'copies_performed' 'manual-copy-after-owner-approval' 'READ-ONLY CONTRACT' 'DealerIdUnknown'; do + require_marker "$EXPORT" "$marker" +done + +# Requested-name -> repo-name deviation must stay documented (docs/32 B-6). +for marker in 'bf-win-inventory.ps1' 'bf-win-network-export.ps1' 'bf-win-software-export.ps1' \ + 'bf-win-device-export.ps1' 'BF-WindowsPreMigrationInventory.ps1' 'BF-WindowsDataExport.ps1' \ + 'ExecutionPolicy' 'scripts/migration/windows' 'read-only'; do + require_marker "$WINDOWS_README" "$marker" +done +grep -Fq 'scripts/migration/windows' "$MIGRATION_POINTER" || fail 'migration/README.md must point at scripts/migration/windows' +grep -Fq 'docs/32' "$MIGRATION_POINTER" || fail 'migration/README.md must reference the docs/32 audit entry' +grep -Fiq 'migration/windows' "$MIGRATION_POINTER" || fail 'migration/README.md must name the requested migration/windows path' +pass 'migration scope, naming and location deviations are documented and asserted' + +[[ "$(head -n 1 "$ANYDESK")" == '#!/usr/bin/env bash' ]] || fail 'AnyDesk skeleton requires Bash shebang' +grep -q '^set -euo pipefail$' "$ANYDESK" || fail 'AnyDesk skeleton requires strict mode' +bash -n "$ANYDESK" && pass 'AnyDesk skeleton parses' || fail 'AnyDesk skeleton parse error' +grep -q 'ANYDESK_LICENSE_CONFIRMED=true' "$ANYDESK" || fail 'AnyDesk license confirmation gate missing' +if grep -niE '(curl|wget|apt(-get)?[[:space:]].*install|dnf[[:space:]].*install|zypper[[:space:]].*install|license(_|[[:space:]])?(key|file)|[A-Z0-9]{4}(-[A-Z0-9]{4}){3,})' "$ANYDESK"; then + fail 'AnyDesk skeleton must not download, install, or embed a license' +else + pass 'AnyDesk skeleton has no download, install, or embedded license' +fi + +grep -q '^#!/usr/bin/env bash' "$HW_CHECK" || fail 'hardware check requires Bash shebang' +grep -q '^set -euo pipefail$' "$HW_CHECK" || fail 'hardware check requires strict mode' +bash -n "$HW_CHECK" && pass 'hardware check parses' || fail 'hardware check parse error' +grep -q -- '--json' "$HW_CHECK" || fail 'hardware check --json missing' +grep -q -- '--markdown' "$HW_CHECK" || fail 'hardware check --markdown missing' +for verdict in SUPPORTED WARNING BLOCKED; do grep -q "$verdict" "$HW_CHECK" || fail "hardware verdict missing: $verdict"; done +# Every check the migration gate relies on must be present in the JSON payload. +for hwcheck in architecture ram disk disk_type smart nic nic_link serial usb boot_mode secure_boot tpm virt cpu_virt; do + grep -Fq "\"name\":\"$hwcheck\"" "$HW_CHECK" || fail "hardware check missing: $hwcheck" +done +# Verdict thresholds must be documented in the output, not just implied by code. +grep -Fq '| Kontrol | BLOCKED | WARNING | SUPPORTED |' "$HW_CHECK" || fail 'hardware check threshold table missing from markdown output' +for threshold in '68719476736' '2048' '4096' '64 GiB' '4096 MiB' 'vmx/svm'; do + grep -Fq -- "$threshold" "$HW_CHECK" || fail "hardware threshold not documented: $threshold" +done +pass 'hardware check covers arch/ram/disk/media/smart/nic/link/serial/usb/boot/secure-boot/tpm/virt and documents thresholds' +if grep -niE '(apt(-get)?[[:space:]].*install|dnf[[:space:]].*install|zypper[[:space:]].*install|mkfs|wipefs|dd[[:space:]].*of=|rm[[:space:]]+-rf|systemctl[[:space:]]+(start|stop|restart|enable|disable)|ip[[:space:]]+(link|addr|route)[[:space:]]+(add|del|set)|nmcli[[:space:]].*(up|down|modify))' "$HW_CHECK"; then + fail 'hardware check must not install, wipe, alter services, or alter networking' +else + pass 'hardware check is non-mutating by static pattern' +fi + +for test_id in $(seq 1 18); do + grep -q "T${test_id}" "$QEMU_PLAN" || fail "QEMU/LAB plan missing T${test_id}" +done +grep -qiE 'CI.*statik|statik.*CI' "$QEMU_PLAN" || fail 'QEMU/LAB plan must distinguish CI/static coverage' +grep -qiE 'QEMU|KVM' "$QEMU_PLAN" || fail 'QEMU/LAB plan must cover QEMU/KVM' + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-migration-static: FAILED\n' >&2 + exit 1 +fi +printf 'check-migration-static: all passed\n' diff --git a/tests/check-offline-repo-static.sh b/tests/check-offline-repo-static.sh new file mode 100755 index 0000000..b224e19 --- /dev/null +++ b/tests/check-offline-repo-static.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# Validate the offline APT repository contract without building a bundle: +# manifest pin semantics, SBOM schema shape, secret hygiene, and the coverage +# guarantees of build-offline-repo.sh --dry-run-plan. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OFFLINE_REPO="$REPO_ROOT/provisioning/offline-repo" +MANIFEST_DIR="$OFFLINE_REPO/manifests" +BUILD="$OFFLINE_REPO/build-offline-repo.sh" +INSTALLER="$REPO_ROOT/scripts/install/blueforce-install.sh" +REQUIREMENTS="$MANIFEST_DIR/requirements.tsv" +LOCK_SCHEMA="$MANIFEST_DIR/packages.lock.schema.tsv" +MANIFESTS=(base-packages.txt remote-access.txt docker.txt) +LOCK_HEADER="$(printf 'package\tversion\tarchitecture\tsha256\tsource')" +FAIL=0 +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } +require_file() { [[ -f "$1" ]] && pass "present: ${1#$REPO_ROOT/}" || fail "missing: ${1#$REPO_ROOT/}"; } + +require_file "$BUILD" +require_file "$OFFLINE_REPO/README.md" +require_file "$REQUIREMENTS" +require_file "$LOCK_SCHEMA" +for manifest in "${MANIFESTS[@]}"; do require_file "$MANIFEST_DIR/$manifest"; done + +# 1. The build script is strict-mode Bash and parses. +[[ "$(head -n 1 "$BUILD")" == '#!/usr/bin/env bash' ]] || fail 'build-offline-repo.sh requires a Bash shebang' +grep -q '^set -euo pipefail$' "$BUILD" || fail 'build-offline-repo.sh requires strict mode' +bash -n "$BUILD" && pass 'build-offline-repo.sh parses' || fail 'build-offline-repo.sh parse error' +grep -q -- '--dry-run-plan' "$BUILD" || fail 'build-offline-repo.sh must offer --dry-run-plan' +grep -q -- '--check' "$BUILD" || fail 'build-offline-repo.sh must keep --check' +grep -q 'apt-ftparchive' "$BUILD" || fail 'the real build must still use apt-ftparchive' +grep -q 'pin still awaits its LAB version' "$BUILD" || fail 'the build must refuse an UNPINNED pin' + +# 2. The plan path must not need the build tooling, which lives only on the LAB host. +plan_body="$(awk '/^plan_report\(\) \{/,/^\}/' "$BUILD")" +[[ -n "$plan_body" ]] || fail 'plan_report function not found in build-offline-repo.sh' +if grep -qE 'dpkg-deb[[:space:]]+-' <<<"$plan_body"; then fail 'dry-run-plan must not invoke dpkg-deb'; else pass 'dry-run-plan never invokes dpkg-deb'; fi +if grep -qE 'apt-ftparchive[[:space:]]+(packages|release)' <<<"$plan_body"; then fail 'dry-run-plan must not invoke apt-ftparchive'; else pass 'dry-run-plan never invokes apt-ftparchive'; fi +plan_dispatch="$(grep -n 'PLAN_ONLY" -eq 1' "$BUILD" | head -n 1 | cut -d: -f1)" +tool_guard="$(grep -n "apt-ftparchive is not installed" "$BUILD" | head -n 1 | cut -d: -f1)" +[[ -n "$plan_dispatch" && -n "$tool_guard" && "$plan_dispatch" -lt "$tool_guard" ]] \ + && pass 'plan mode is dispatched before the build tool requirements' \ + || fail 'plan mode must be dispatched before the build tool requirements' + +# 3. The contract tooling acquires nothing: it is a local-file-only builder. +if grep -nE '(^|[^A-Za-z])(curl|wget)[[:space:]]+(-|http)|apt(-get)?[[:space:]]+download|https?://' "$BUILD"; then + fail 'build-offline-repo.sh must not download anything' +else + pass 'build-offline-repo.sh has no download primitive' +fi + +# 4. manifests/*.txt is the offline installer contract: exactly three pin files. +mapfile -t manifest_txt < <(find "$MANIFEST_DIR" -maxdepth 1 -type f -name '*.txt' -printf '%f\n' | sort) +[[ ${#manifest_txt[@]} -eq 3 ]] || fail "manifests/ must hold exactly 3 *.txt pin files, found ${#manifest_txt[@]}" +for manifest in "${MANIFESTS[@]}"; do + found=0 + for actual in "${manifest_txt[@]}"; do [[ "$actual" == "$manifest" ]] && found=1; done + [[ "$found" -eq 1 ]] || fail "manifests/$manifest is missing from the installer contract" +done +[[ -z "$(grep -L 'package=version' "$MANIFEST_DIR"/*.txt || true)" ]] && pass 'every manifest documents the package=version pin format' || fail 'a manifest does not document the package=version pin format' +[[ -z "$(grep -L 'Ubuntu 26.04' "$MANIFEST_DIR"/*.txt || true)" ]] && pass 'every manifest names its target Ubuntu release' || fail 'a manifest does not name its target Ubuntu release' +[[ -z "$(grep -L 'UNPINNED' "$MANIFEST_DIR"/*.txt || true)" ]] && pass 'every manifest explains the UNPINNED sentinel' || fail 'a manifest does not explain the UNPINNED sentinel' + +# 5. Pin semantics, independently of the build script. +awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + { + if ($0 ~ /[[:space:]]/ || $0 !~ /^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$/) { printf "INVALID\t%s\t%d\t%s\n", FILENAME, FNR, $0; next } + split($0, kv, "=") + pkg = kv[1]; ver = substr($0, length(pkg) + 2) + if (seen[pkg] != "") printf "DUPLICATE\t%s\t%s\n", pkg, seen[pkg] + else seen[pkg] = FILENAME ":" FNR + pins++ + if (ver == "UNPINNED") awaiting++ + else filled++ + } + END { printf "COUNT\t%d\t%d\t%d\n", pins, filled, awaiting } +' "$MANIFEST_DIR/base-packages.txt" "$MANIFEST_DIR/remote-access.txt" "$MANIFEST_DIR/docker.txt" > "$TMP/pins.txt" + +invalid="$(grep -c '^INVALID' "$TMP/pins.txt" || true)" +duplicates="$(grep -c '^DUPLICATE' "$TMP/pins.txt" || true)" +read -r _ pin_count filled_count awaiting_count < <(grep '^COUNT' "$TMP/pins.txt" || printf 'COUNT 0 0 0\n') +[[ "$invalid" -eq 0 ]] && pass 'every manifest line is an exact package=version pin' || { fail "invalid pin lines: $invalid"; grep '^INVALID' "$TMP/pins.txt" >&2; } +[[ "$duplicates" -eq 0 ]] && pass 'no package is pinned twice' || { fail "duplicate pins: $duplicates"; grep '^DUPLICATE' "$TMP/pins.txt" >&2; } +[[ "$pin_count" -gt 0 ]] && pass "manifests declare $pin_count concrete pins" || fail 'manifests declare no pins' + +# The audit table and the manifests must describe the same contract. +python_rc=0 +python3 - "$MANIFEST_DIR" "$pin_count" <<'PY' || python_rc=$? +import os +import sys + +manifest_dir, pin_count = sys.argv[1], int(sys.argv[2]) +pins = {} +for name in ('base-packages.txt', 'remote-access.txt', 'docker.txt'): + with open(os.path.join(manifest_dir, name), encoding='utf-8') as handle: + for lineno, raw in enumerate(handle, 1): + line = raw.rstrip('\n') + if not line or line.startswith('#'): + continue + package, _, version = line.partition('=') + pins.setdefault(package, []).append(name) + assert version == 'UNPINNED' or version[0].isdigit() or ':' in version, line +rows = [] +with open(os.path.join(manifest_dir, 'requirements.tsv'), encoding='utf-8') as handle: + for lineno, raw in enumerate(handle, 1): + line = raw.rstrip('\n') + if not line or line.startswith('#'): + continue + fields = line.split('\t') + if len(fields) != 4 or not all(fields): + raise SystemExit(f'requirements.tsv:{lineno}: expected four TAB-separated fields') + rows.append((fields[0], fields[1])) +missing = [f'{m}: {p}' for m, p in rows if p not in pins or m not in pins[p]] +rogue = sorted(f'{n}: {p}' for p, names in pins.items() for n in names if (n, p) not in rows) +if len(pins) != pin_count: + raise SystemExit(f'pin parsing disagreement: script={pin_count} python={len(pins)}') +if missing or rogue: + for item in missing: + print(f'MISSING PACKAGE (in requirements.tsv, not in its manifest): {item}') + for item in rogue: + print(f'ROGUE PIN (in a manifest, not in requirements.tsv): {item}') + raise SystemExit(1) +print(f'CHECK ok: {len(rows)} requirement rows, {len(pins)} pinned packages, coverage is complete') +PY +if [[ "$python_rc" -eq 0 ]]; then pass 'requirements.tsv and manifests/*.txt describe the same contract'; else fail 'requirements audit table drifted from the manifests'; fi + +# 6. The SBOM schema keeps the exact header the offline installer validates. +schema_rows="$(grep -vcE '^[[:space:]]*(#|$)' "$LOCK_SCHEMA" || true)" +[[ "$schema_rows" -eq 1 ]] || fail "packages.lock.schema.tsv must carry only the header row ($schema_rows uncommented lines)" +[[ "$(grep -vE '^[[:space:]]*(#|$)' "$LOCK_SCHEMA")" == "$LOCK_HEADER" ]] && pass 'lock schema header matches the documented layout' || fail 'lock schema header is wrong' +grep -q 'package version architecture sha256 source' "$LOCK_SCHEMA" || fail 'lock schema header is not TAB-separated' +installer_header="$(grep -o 'package\\tversion\\tarchitecture\\tsha256\\tsource' "$INSTALLER" | head -n 1)" +[[ "${installer_header//\\t/$'\t'}" == "$LOCK_HEADER" ]] \ + && pass 'lock header matches the header blueforce-install.sh validates' \ + || fail 'lock header drifted from blueforce-install.sh' +grep -q 'blueforce-install.sh' "$LOCK_SCHEMA" && pass 'lock schema names its consumer' || fail 'lock schema must name its consumer' +for column in package version architecture sha256 source; do + grep -q "\\b$column\\b" "$LOCK_SCHEMA" || fail "lock schema does not document the $column column" +done + +# 7. No secrets, keys, or device identities in the offline repository assets. +if grep -RniE --exclude-dir=.git \ + '(BEGIN [A-Z ]*PRIVATE KEY|(password|passwd|token|api[_-]?key|secret)[[:space:]]*[:=]|AKIA[0-9A-Z]{16}|BF-[0-9]{8})' \ + "$OFFLINE_REPO"; then + fail 'secret-like or device-specific value found in provisioning/offline-repo' +else + pass 'no secret-like or device-specific value in provisioning/offline-repo' +fi +if grep -RniE --exclude-dir=.git 'https?://[^[:space:]]*:[^[:space:]]*@' "$OFFLINE_REPO"; then + fail 'credential-bearing URL found in provisioning/offline-repo' +else + pass 'no credential-bearing URL in provisioning/offline-repo' +fi + +# 8. The plan is read-only and its verdict matches the fill state of the manifests. +manifest_state_before="$(find "$MANIFEST_DIR" -type f -exec sha256sum {} + | sort)" +plan_rc=0 +plan_out="$(bash "$BUILD" --dry-run-plan 2>&1)" || plan_rc=$? +manifest_state_after="$(find "$MANIFEST_DIR" -type f -exec sha256sum {} + | sort)" +[[ "$manifest_state_before" == "$manifest_state_after" ]] && pass 'dry-run-plan leaves the manifests untouched' || fail 'dry-run-plan modified the manifests' +grep -q 'apt-ftparchive is not installed' <<<"$plan_out" && fail 'dry-run-plan must not require apt-ftparchive' || pass 'dry-run-plan runs without the LAB build tooling' +grep -q "^SUMMARY: pins=$pin_count filled=$filled_count awaiting=$awaiting_count missing_packages=0 errors=0$" <<<"$plan_out" \ + && pass "dry-run-plan summary matches the manifests (pins=$pin_count awaiting=$awaiting_count)" \ + || { fail 'dry-run-plan summary disagrees with the manifests'; grep '^SUMMARY:' <<<"$plan_out" >&2; } +if [[ "$awaiting_count" -gt 0 ]]; then + [[ "$plan_rc" -eq 1 ]] && pass 'dry-run-plan reports NOT READY while pins are unfilled' || fail "expected exit 1 while $awaiting_count pins await a version, got $plan_rc" + grep -q '^PLAN NOT READY' <<<"$plan_out" && pass 'dry-run-plan states PLAN NOT READY' || fail 'dry-run-plan must state PLAN NOT READY' + grep -q '^MISSING VERSIONS' <<<"$plan_out" && pass 'dry-run-plan lists the missing versions' || fail 'dry-run-plan must list the missing versions' + listed="$(grep -c '=UNPINNED$' <<<"$plan_out" || true)" + [[ "$listed" -eq "$awaiting_count" ]] && pass "dry-run-plan lists all $awaiting_count unfilled pins" || fail "dry-run-plan listed $listed of $awaiting_count unfilled pins" + printf 'INFO: %d pin(s) still await a LAB version; the repo ships no .deb and no bundle.\n' "$awaiting_count" +else + [[ "$plan_rc" -eq 0 ]] && pass 'dry-run-plan reports READY when every pin is filled' || fail "expected exit 0 with all pins filled, got $plan_rc" + grep -q '^PLAN READY' <<<"$plan_out" || fail 'dry-run-plan must state PLAN READY' +fi +plan_rc=0 +bash "$BUILD" --dry-run-plan --check >/dev/null 2>&1 || plan_rc=$? +[[ "$plan_rc" -eq 2 ]] && pass 'dry-run-plan refuses to combine with --check' || fail "dry-run-plan --check must exit 2, got $plan_rc" + +# 9. Fixtures: the validator must catch each contract violation it advertises. +new_fixture() { + rm -rf "$TMP/m" + mkdir -p "$TMP/m" + for manifest in "${MANIFESTS[@]}"; do cp "$MANIFEST_DIR/$manifest" "$TMP/m/$manifest"; done + cp "$REQUIREMENTS" "$TMP/m/requirements.tsv" + cp "$LOCK_SCHEMA" "$TMP/m/packages.lock.schema.tsv" +} +# Synthetic fixture versions: obviously non-release values used only to exercise +# the ready path. They are never copied into the repository or into a bundle. +fill_fixture() { + local manifest src dst line + for manifest in "${MANIFESTS[@]}"; do + src="$TMP/m/$manifest" + dst="$TMP/m/$manifest.filled" + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == *=UNPINNED ]]; then + printf '%s\n' "${line%=UNPINNED}=9.9.9-lab-fixture" + else + printf '%s\n' "$line" + fi + done < "$src" > "$dst" + mv "$dst" "$src" + done +} +drop_fixture_line() { + local file="$1" prefix="$2" tmp="$TMP/drop.tmp" line + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "$prefix"* ]]; then continue; fi + printf '%s\n' "$line" + done < "$file" > "$tmp" + mv "$tmp" "$file" +} +plan_fixture() { + PLAN_RC=0 + PLAN_OUT="$(BF_MANIFEST_DIR="$TMP/m" bash "$BUILD" --dry-run-plan 2>&1)" || PLAN_RC=$? +} +expect_violation() { + local label="$1" pattern="$2" + if [[ "$PLAN_RC" -eq 0 ]]; then fail "$label: validator accepted an invalid contract"; return; fi + if grep -qE "$pattern" <<<"$PLAN_OUT"; then pass "$label: rejected ($(grep -oE "$pattern" <<<"$PLAN_OUT" | head -n 1))"; else + fail "$label: expected diagnostic /$pattern/ not reported"; printf '%s\n' "$PLAN_OUT" >&2 + fi +} + +new_fixture; fill_fixture +plan_fixture +if [[ "$PLAN_RC" -eq 0 ]] && grep -q '^PLAN READY' <<<"$PLAN_OUT"; then + pass 'fixture: a fully filled contract reports PLAN READY' +else + fail "fixture: a fully filled contract must report PLAN READY (exit $PLAN_RC)" + printf '%s\n' "$PLAN_OUT" >&2 +fi + +new_fixture; fill_fixture +printf 'curl=1.0-lab-fixture\n' >> "$TMP/m/base-packages.txt" +plan_fixture; expect_violation 'fixture: duplicate pin' 'duplicate pin for curl' + +new_fixture; fill_fixture +printf 'openssh-server=1.0-lab-fixture inline note\n' >> "$TMP/m/base-packages.txt" +plan_fixture; expect_violation 'fixture: whitespace in a pin' 'whitespace in a pin' + +new_fixture; fill_fixture +printf 'Curl=1.0-lab-fixture\n' >> "$TMP/m/base-packages.txt" +plan_fixture; expect_violation 'fixture: malformed pin' 'not an exact pin' + +new_fixture; fill_fixture +printf 'wget=placeholder-1.0\n' >> "$TMP/m/base-packages.txt" +plan_fixture; expect_violation 'fixture: placeholder token' 'placeholder token left in pin' + +new_fixture; fill_fixture +printf 'ncdu=1.0-lab-fixture\n' >> "$TMP/m/base-packages.txt" +plan_fixture; expect_violation 'fixture: rogue pin without an audit row' 'absent from requirements.tsv' + +new_fixture; fill_fixture +drop_fixture_line "$TMP/m/remote-access.txt" 'rustdesk=' +plan_fixture +expect_violation 'fixture: required package missing' 'MISSING PACKAGES' +grep -q 'rustdesk' <<<"$PLAN_OUT" || fail 'fixture: the missing package must be named in the report' + +new_fixture; fill_fixture +printf 'ncdu=1.0-lab-fixture\n' > "$TMP/m/extra.txt" +plan_fixture; expect_violation 'fixture: unexpected manifests/*.txt' 'unexpected manifests' + +new_fixture; fill_fixture +printf 'curl\t1.0-lab-fixture\tamd64\t%s\tcurl_1.0-lab-fixture_amd64.deb\n' "$(printf 'a%.0s' {1..64})" >> "$TMP/m/packages.lock.schema.tsv" +plan_fixture; expect_violation 'fixture: data row in the lock schema' 'must not contain data rows' + +# 10. Real builds and --check stay fail-closed without a reviewed pool. +mkdir -p "$TMP/pool" +build_rc=0 +build_out="$(bash "$BUILD" --packages "$TMP/pool" --output "$TMP/out" --check 2>&1)" || build_rc=$? +if [[ "$build_rc" -ne 0 ]]; then pass 'build --check fails closed without a reviewed pool'; else fail 'build --check must not pass without a reviewed pool'; fi +grep -qE 'apt-ftparchive is not installed|no local \.deb files supplied' <<<"$build_out" \ + && pass 'build --check reports why it refused' \ + || { fail 'build --check refusal message is unclear'; printf '%s\n' "$build_out" >&2; } +[[ ! -e "$TMP/out" ]] && pass 'a refused build creates no output directory' || fail 'a refused build must not create output' +build_rc=0 +bash "$BUILD" --packages "$TMP/absent-pool" --output "$TMP/out" >/dev/null 2>&1 || build_rc=$? +[[ "$build_rc" -eq 2 ]] && pass 'a missing package directory is a usage error' || fail "missing package directory must exit 2, got $build_rc" + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-offline-repo-static: FAILED\n' >&2 + exit 1 +fi +printf 'check-offline-repo-static: all passed\n' diff --git a/tests/check-provisioning-static.sh b/tests/check-provisioning-static.sh new file mode 100755 index 0000000..9821291 --- /dev/null +++ b/tests/check-provisioning-static.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# Validate provisioning assets, the bootable ISO builder contract, and the safety rules +# of the autoinstall payload. Nothing is built, no disk is touched, no network is used. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROVISIONING="$REPO_ROOT/provisioning" +ISO_DIR="$PROVISIONING/iso" +BUILDER="$ISO_DIR/build-blueforce-iso.sh" +AUTOINSTALL="$PROVISIONING/autoinstall/autoinstall.yaml" +USER_DATA="$PROVISIONING/autoinstall/user-data" +FAIL=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } +require_file() { [[ -f "$1" ]] && pass "present: ${1#"$REPO_ROOT"/}" || fail "missing: ${1#"$REPO_ROOT"/}"; } + +# require_in +require_in() { + if grep -qE -- "$2" "$1"; then + pass "$3" + else + fail "$3 (pattern not found in ${1#"$REPO_ROOT"/}: $2)" + fi +} + +# forbid_in +forbid_in() { + if grep -qE -- "$2" "$1"; then + fail "$3 (forbidden pattern in ${1#"$REPO_ROOT"/}: $2)" + else + pass "$3" + fi +} + +required_files=( + provisioning/README.md + provisioning/autoinstall/autoinstall.yaml + provisioning/autoinstall/user-data + provisioning/autoinstall/meta-data + provisioning/autoinstall/README.md + provisioning/iso/build-blueforce-iso.sh + provisioning/iso/verify-upstream-iso.sh + provisioning/iso/test-iso.sh + provisioning/iso/README.md + provisioning/iso/field-os-version + provisioning/iso/ubuntu-26.04.1-SHA256SUMS + provisioning/offline-repo/build-offline-repo.sh + provisioning/offline-repo/packages/.gitkeep + provisioning/offline-repo/manifests/base-packages.txt + provisioning/offline-repo/manifests/remote-access.txt + provisioning/offline-repo/manifests/docker.txt + provisioning/offline-repo/README.md + provisioning/firstboot/bf-firstboot + provisioning/firstboot/blueforce-firstboot.service + provisioning/firstboot/README.md + provisioning/enrollment/schemas/enrollment-request.schema.json + provisioning/enrollment/schemas/enrollment-response.schema.json + provisioning/enrollment/README.md + provisioning/release/manifest.yaml + provisioning/release/README.md + tests/run-all.sh +) +for relative in "${required_files[@]}"; do require_file "$REPO_ROOT/$relative"; done + +if [[ ! -d "$PROVISIONING" ]]; then + printf 'check-provisioning-static: FAILED\n' >&2 + exit 1 +fi + +# Provisioning scripts must be Bash, strict-mode, and parse without execution. +while IFS= read -r -d '' script; do + first_line="$(head -n 1 "$script")" + [[ "$first_line" == '#!/usr/bin/env bash' ]] || fail "Bash shebang required: ${script#"$REPO_ROOT"/}" + grep -q '^set -euo pipefail$' "$script" || fail "strict mode required: ${script#"$REPO_ROOT"/}" + bash -n "$script" && pass "bash parses: ${script#"$REPO_ROOT"/}" || fail "bash parse error: ${script#"$REPO_ROOT"/}" +done < <(find "$PROVISIONING" -type f \( -name '*.sh' -o -name 'bf-firstboot' \) -print0) + +# JSON schemas and YAML assets must be structurally parseable. The checks need python3 +# (plus PyYAML for the YAML side); when the interpreter or module is missing they are +# reported as skipped instead of failing, so the suite stays usable in minimal containers. +PYTHON_JSON=0 +if command -v python3 >/dev/null 2>&1 && python3 -c 'import json' >/dev/null 2>&1; then + PYTHON_JSON=1 +fi +PYTHON_YAML=0 +if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then + PYTHON_YAML=1 +fi + +if [[ "$PYTHON_JSON" -eq 1 ]]; then + while IFS= read -r -d '' json_file; do + if python3 -c 'import json,sys; json.load(open(sys.argv[1], encoding="utf-8"))' "$json_file"; then + pass "json parses: ${json_file#"$REPO_ROOT"/}" + else + fail "json parse error: ${json_file#"$REPO_ROOT"/}" + fi + done < <(find "$PROVISIONING" -type f -name '*.json' -print0) +else + printf 'NOTE: json parse checks skipped (python3 with the json module is unavailable)\n' +fi + +if [[ "$PYTHON_YAML" -eq 1 ]]; then + while IFS= read -r -d '' yaml_file; do + if python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1], encoding="utf-8"))' "$yaml_file"; then + pass "yaml parses: ${yaml_file#"$REPO_ROOT"/}" + else + fail "yaml parse error: ${yaml_file#"$REPO_ROOT"/}" + fi + done < <(find "$PROVISIONING" -type f \( -name '*.yaml' -o -name '*.yml' \) -print0) +else + printf 'NOTE: yaml parse checks skipped (python3 with PyYAML is unavailable)\n' +fi + +# ---------------------------------------------------------------- autoinstall -- +if [[ -f "$AUTOINSTALL" && -f "$USER_DATA" ]]; then + grep -q '^autoinstall:$' "$AUTOINSTALL" || fail 'autoinstall.yaml must have an autoinstall root' + grep -qE '^[[:space:]]*interactive-sections:[[:space:]]*\[storage\][[:space:]]*$' "$AUTOINSTALL" || fail 'autoinstall.yaml must require interactive storage' + if grep -qE '^[[:space:]]*identity:' "$AUTOINSTALL" "$USER_DATA"; then + fail 'autoinstall must not embed an identity' + else + pass 'autoinstall contains no identity block' + fi + + # Storage directives are checked on the effective YAML (comments stripped) so that + # documentation comments cannot be mistaken for real directives. + for payload in "$AUTOINSTALL" "$USER_DATA"; do + if sed -E 's/(^|[[:space:]])#.*$//' "$payload" \ + | grep -qE '(^|[[:space:]])(layout|match|wipe|grub_device|device|path):[[:space:]]*[^[:space:]]'; then + fail "autoinstall must not directly select or wipe a disk: ${payload#"$REPO_ROOT"/}" + else + pass "no direct disk selection or wipe directive: ${payload#"$REPO_ROOT"/}" + fi + done + + require_in "$AUTOINSTALL" '^[[:space:]]*fallback:[[:space:]]*offline-install[[:space:]]*$' \ + 'autoinstall declares the offline-install apt fallback' + require_in "$USER_DATA" '^[[:space:]]*fallback:[[:space:]]*offline-install[[:space:]]*$' \ + 'seed user-data declares the offline-install apt fallback' + require_in "$AUTOINSTALL" '/opt/blueforce/offline-repo' \ + 'late-commands place the offline repository at /opt/blueforce/offline-repo' + require_in "$AUTOINSTALL" 'cdrom/blueforce-provisioning/firstboot/bf-firstboot' \ + 'late-commands install the firstboot payload from the fixed media path' + require_in "$AUTOINSTALL" 'systemctl enable blueforce-firstboot\.service' \ + 'late-commands enable blueforce-firstboot.service' + require_in "$AUTOINSTALL" '/etc/blueforce-release' \ + 'late-commands place /etc/blueforce-release on the target' + + # The seed copy and the media copy must stay in sync. + AUTO_LATE="$(sed -n '/late-commands:/,$p' "$AUTOINSTALL" | grep -E '^[[:space:]]*-' | sed -E 's/^[[:space:]]*-[[:space:]]*//' || true)" + SEED_LATE="$(sed -n '/late-commands:/,$p' "$USER_DATA" | grep -E '^[[:space:]]*-' | sed -E 's/^[[:space:]]*-[[:space:]]*//' || true)" + if [[ -n "$AUTO_LATE" && "$AUTO_LATE" == "$SEED_LATE" ]]; then + pass 'seed user-data late-commands match the media autoinstall' + else + fail 'seed user-data late-commands differ from autoinstall.yaml' + fi +fi + +# ------------------------------------------------------------- ISO builder ---- +if [[ -f "$BUILDER" ]]; then + # Assertions about executable behaviour run against a comment-stripped view so that a + # documentation comment can neither satisfy nor trip a code contract check. + CODE_VIEW="$(mktemp)" + trap 'rm -rf "${TMP_TEST:-}" "$CODE_VIEW"' EXIT + sed -E 's/(^|[[:space:]])#.*$//' "$BUILDER" > "$CODE_VIEW" + require_in "$BUILDER" 'Blueforce-Field-OS-\$\{VERSION\}-\$\{ARCH\}\.iso' \ + 'builder names the ISO after the released version contract' + require_in "$BUILDER" '"\$\{OUTPUT\}\.sha256"' 'builder writes .sha256' + require_in "$BUILDER" '\$\{VERSION\}-manifest\.yaml' 'builder writes -manifest.yaml' + require_in "$BUILDER" '\-\-checksum-file' 'builder accepts an explicit checksum record' + forbid_in "$BUILDER" 'Refusing remaster without --allow-remaster' \ + 'real build is no longer gated behind --allow-remaster' + require_in "$BUILDER" '\-\-check\)' 'builder provides a dry-run mode' + + # Media layout and automatic autoinstall start. + require_in "$BUILDER" '\$MEDIA/autoinstall\.yaml' 'autoinstall.yaml is written to the media root' + require_in "$BUILDER" 'FIRSTBOOT_PATH="\$MEDIA_PREFIX/firstboot"' 'firstboot path is fixed at /blueforce-provisioning/firstboot' + require_in "$BUILDER" 'inject_autoinstall_arg' 'builder injects the autoinstall kernel argument itself' + require_in "$BUILDER" 'blueforce-orig' 'builder keeps a pristine copy of every boot file it patches' + require_in "$BUILDER" 'boot/grub/grub\.cfg' 'GRUB configuration is patched' + require_in "$BUILDER" 'isolinux' 'isolinux configuration is patched when the base ISO has one' + # The boot structure is rebuilt from the base ISO's own layout recipe: a replay cannot + # re-create the GRUB2 MBR (first 16 sectors of the source image) or the appended EFI + # system partition. Those options are read at build time, never hard-coded. + require_in "$BUILDER" 'report_el_torito as_mkisofs' 'boot layout is read from the base ISO at build time' + require_in "$BUILDER" '\-as mkisofs' 'the medium is written with the base ISO boot recipe' + require_in "$BUILDER" 'append_partition' 'builder fails closed when the base layout has no appended EFI partition' + require_in "$BUILDER" 'modification-date' 'reproducible builds pin the mkisofs modification date' + forbid_in "$CODE_VIEW" '\-boot_image any replay' 'boot layout is no longer replayed' + forbid_in "$BUILDER" '28732ac11ff8d211ba4b00a0c93ec93b|appended_partition_2_start_|local_fs:[0-9]' \ + 'boot layout values are not hard-coded in the builder' + require_in "$BUILDER" 'El Torito boot img.*BIOS' 'builder asserts the BIOS El Torito entry survives' + require_in "$BUILDER" 'El Torito boot img.*\(UEFI\|EFI\)' 'builder asserts the UEFI El Torito entry survives' + require_in "$BUILDER" 'interactive-sections: \\\[storage\\\]' 'builder re-checks interactive storage inside the produced media' + require_in "$BUILDER" 'rm -f "\$OUTPUT"' 'builder removes a broken output instead of leaving a half-good ISO' + + # Offline repository embedding. + require_in "$BUILDER" 'Packages\.gz' 'builder requires a complete offline repository before embedding it' + require_in "$BUILDER" 'offline-repo/built' 'built offline repository is placed on the media' + + # Manifest contract. + require_in "$BUILDER" 'field_os_version' 'manifest records field_os_version' + require_in "$BUILDER" 'base_flavor' 'manifest records the detected base_flavor' + require_in "$BUILDER" 'base_iso_filename' 'manifest records the base ISO file name' + require_in "$BUILDER" 'ubuntu_iso_sha256' 'manifest records ubuntu_iso_sha256' + require_in "$BUILDER" 'git_commit' 'manifest records the git commit' + require_in "$BUILDER" 'build_date' 'manifest records the build date' + require_in "$BUILDER" 'meg_package_version' 'manifest records the package versions' + require_in "$BUILDER" 'blueforce_installer_version' 'manifest records the installer version' + require_in "$BUILDER" 'baseline_deviation' 'manifest records the documented-baseline deviation' + forbid_in "$BUILDER" '[0-9a-f]{64}' 'builder embeds no hard-coded SHA256 digest' + + # No download and no device identity in the builder. + forbid_in "$BUILDER" '\b(curl|wget|apt-get download|apt download)\b' 'builder downloads nothing' + forbid_in "$BUILDER" 'BF-[0-9]{8}' 'builder embeds no device identity' + + # The version file must be usable as an artifact name component. + if [[ -f "$ISO_DIR/field-os-version" ]] \ + && grep -qE '^[0-9]+(\.[0-9]+){1,3}([-.+][0-9A-Za-z.-]+)?$' "$ISO_DIR/field-os-version"; then + pass 'field-os-version holds a valid version string' + else + fail 'field-os-version must hold a version such as 1.0.0' + fi + + # The upstream checksum record must not be a hand-written digest store: it must carry a + # SHA256SUMS-style entry for a real Ubuntu image name. + if [[ -f "$ISO_DIR/ubuntu-26.04.1-SHA256SUMS" ]] \ + && grep -qE '^[0-9a-f]{64} \*ubuntu-[0-9.]+-(live-server|desktop)-amd64\.iso$' "$ISO_DIR/ubuntu-26.04.1-SHA256SUMS"; then + pass 'upstream checksum record is a Canonical SHA256SUMS file' + else + fail 'upstream checksum record is missing or malformed' + fi + + # --check and failure paths stay non-destructive. + TMP_TEST="$(mktemp -d)" + printf 'not an iso\n' > "$TMP_TEST/junk-amd64.iso" + printf '%s *junk-amd64.iso\n' "$(sha256sum "$TMP_TEST/junk-amd64.iso" | awk '{print $1}')" > "$TMP_TEST/SHA256SUMS" + if bash "$BUILDER" --upstream-iso "$TMP_TEST/missing-amd64.iso" --checksum-file "$TMP_TEST/SHA256SUMS" \ + --output "$TMP_TEST/out.iso" --check > "$TMP_TEST/missing.out" 2>&1; then + fail 'builder must reject a missing upstream ISO' + elif grep -q 'Upstream ISO not found' "$TMP_TEST/missing.out"; then + pass 'builder rejects a missing upstream ISO with a clear message' + else + fail "builder failed without explaining the missing upstream ISO: $(head -n 1 "$TMP_TEST/missing.out")" + fi + + # A real build must refuse a base medium that carries no ISO 9660 boot catalog, and it + # must not leave an artifact behind when it does. + if bash "$BUILDER" --upstream-iso "$TMP_TEST/junk-amd64.iso" --checksum-file "$TMP_TEST/SHA256SUMS" \ + --output "$TMP_TEST/out2.iso" > "$TMP_TEST/junkbuild.out" 2>&1; then + fail 'a real build must refuse a base medium that carries no ISO 9660 boot catalog' + else + pass 'real build refuses a non-bootable base medium' + fi + grep -qE 'ERROR: ' "$TMP_TEST/junkbuild.out" \ + && pass 'the refusal is explained with an explicit error' \ + || fail 'real build refused a non-bootable base medium without an explicit error' + [[ ! -e "$TMP_TEST/out2.iso" && ! -e "$TMP_TEST/out2.iso.sha256" ]] \ + && pass 'refused real build wrote no output artifacts' \ + || fail 'refused real build left output artifacts behind' + + # Dry-run behaviour on a medium that is not an ISO image: xorriso exits 0 and just says + # "No ISO 9660 image at LBA 0", so this is reported rather than asserted until the + # preflight probe checks for a boot catalog line itself. + if bash "$BUILDER" --upstream-iso "$TMP_TEST/junk-amd64.iso" --checksum-file "$TMP_TEST/SHA256SUMS" \ + --output "$TMP_TEST/out.iso" --check > "$TMP_TEST/junk.out" 2>&1; then + printf 'NOTE: --check accepted a file with no El Torito catalog (xorriso exits 0 on it); tighten the preflight boot-metadata probe when convenient\n' + else + pass 'dry run rejects a medium that carries no El Torito catalog' + fi + [[ ! -e "$TMP_TEST/out.iso" && ! -e "$TMP_TEST/out.iso.sha256" ]] \ + && pass 'dry run wrote no output artifacts' \ + || fail 'dry run left output artifacts behind' + + # Optional: when a real verified base ISO and xorriso are available, the dry run must pass. + if [[ -n "${BF_TEST_UPSTREAM_ISO:-}" && -f "${BF_TEST_UPSTREAM_ISO:-}" && -f "$ISO_DIR/ubuntu-26.04.1-SHA256SUMS" ]] \ + && command -v xorriso >/dev/null 2>&1; then + if bash "$BUILDER" --upstream-iso "$BF_TEST_UPSTREAM_ISO" --checksum-file "$ISO_DIR/ubuntu-26.04.1-SHA256SUMS" \ + --output "$TMP_TEST/real.iso" --check > "$TMP_TEST/real.out" 2>&1; then + pass 'builder dry run passes against the real verified base ISO' + else + fail "builder dry run failed against ${BF_TEST_UPSTREAM_ISO}: $(tail -n 1 "$TMP_TEST/real.out")" + fi + [[ ! -e "$TMP_TEST/real.iso" ]] && pass 'dry run wrote no ISO' || fail 'dry run wrote an ISO' + else + pass 'real-ISO dry run skipped (set BF_TEST_UPSTREAM_ISO and install xorriso to enable it)' + fi +fi + +# ------------------------------------------------------- secrets and identity -- +# No credentials, private keys, device IDs, or machine identities belong in the skeleton. +# Two tiers: +# 1. hard indicators (key blocks, WireGuard keys, RustDesk server, device identity) are +# scanned across the whole provisioning tree, +# 2. the heuristic "secret-looking assignment" scan covers the directories whose files +# are actually copied into the installation medium, which is the same set the builder +# re-scans before it writes one. +# The heuristic requires an alphanumeric character in the value, so an empty placeholder +# such as `token = ""` is not reported while any concrete value still is. (A source file +# that assigns a bare identifier named `token` still trips tier 2 by design; rename such a +# variable rather than widening the rule.) +if grep -RniE --exclude='check-provisioning-static.sh' --exclude='*.md' \ + '(BEGIN (OPENSSH|RSA|EC|DSA|PGP) PRIVATE KEY|WG_(PRIVATE|PEER)_|RUSTDESK_SERVER[[:space:]]*=[[:space:]]*[^<[:space:]#]*[A-Za-z0-9])' \ + "$PROVISIONING"; then + fail 'secret-like value found in provisioning assets' +elif grep -RniE --exclude='check-provisioning-static.sh' --exclude='*.md' \ + '(^|[^A-Za-z])(password|passwd|token|api[_-]?key|secret)[[:space:]]*[:=][[:space:]]*[^<[:space:]#]*[A-Za-z0-9]' \ + "$PROVISIONING/autoinstall" "$PROVISIONING/firstboot" "$PROVISIONING/release" "$PROVISIONING/offline-repo"; then + fail 'secret-like value found in assets that are copied into the medium' +else + pass 'no secret-like value found in provisioning assets' +fi +if grep -RniE --exclude='check-provisioning-static.sh' 'BF-[0-9]{8}|bf-[0-9]{8}|dealer[-_ ]?id[[:space:]]*[:=][[:space:]]*[0-9]{8}' "$PROVISIONING"; then + fail 'device-specific identity found in provisioning assets' +else + pass 'no embedded device identity found' +fi + +# Firstboot may invoke only the explicit offline installer after a dealer ID check. +if grep -q 'blueforce-install\.sh' "$PROVISIONING/firstboot/bf-firstboot" && grep -q -- '--offline' "$PROVISIONING/firstboot/bf-firstboot"; then + pass 'firstboot installer path is explicit offline-only' +else + fail 'firstboot must invoke the installer only with --offline' +fi +grep -q 'BF_FIRSTBOOT_TEST_MODE' "$PROVISIONING/firstboot/bf-firstboot" && pass 'firstboot has mutation-free test mode' || fail 'firstboot test mode missing' + +grep -q 'interactive-sections: \[storage\]' "$AUTOINSTALL" && pass 'assisted storage is explicit' || fail 'assisted storage declaration missing' +grep -q 'apt-ftparchive' "$PROVISIONING/offline-repo/build-offline-repo.sh" && pass 'offline repository uses apt-ftparchive' || fail 'offline repository must use apt-ftparchive' +grep -q 'sha256sum' "$PROVISIONING/iso/verify-upstream-iso.sh" && pass 'upstream SHA256 verification present' || fail 'upstream SHA256 verification missing' + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-provisioning-static: FAILED\n' >&2 + exit 1 +fi +printf 'check-provisioning-static: all passed\n' diff --git a/tests/check-specs.sh b/tests/check-specs.sh new file mode 100755 index 0000000..87fc816 --- /dev/null +++ b/tests/check-specs.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Validate repository specifications and Ansible safety contracts. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PB="$REPO_ROOT/ansible/playbooks" +ROLES="$REPO_ROOT/ansible/roles" +FAIL=0 +fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; } +pass() { printf 'OK: %s\n' "$*"; } + +# The canonical documentation set is docs/00..NN. Adding a document is expected +# over time, so the set is derived from the tree instead of pinned to a literal: +# what must hold is that the numbering starts at 00 and has no gaps. +mapfile -t DOCS < <(printf '%s\n' "$REPO_ROOT"/docs/[0-9][0-9]-*.md | sort) +if [[ ${#DOCS[@]} -eq 0 ]]; then + fail "no numbered docs found under docs/" +else + highest=0 + for doc in "${DOCS[@]}"; do + n="$(basename "$doc" | cut -c1-2)" + n=$((10#$n)) + [[ $n -gt $highest ]] && highest=$n + done + for ((i = 0; i <= highest; i++)); do + printf -v number '%02d' "$i" + matches=("$REPO_ROOT"/docs/"$number"-*.md) + [[ -f ${matches[0]} ]] || fail "missing numbered doc: $number (numbering must be gapless)" + done + pass "${#DOCS[@]} numbered docs present (00..$(printf '%02d' "$highest"), gapless)" +fi + +# Docusaurus yalnız numaralı kanonik dokümanları yayımlamalıdır; şablon site içeriği değildir. +SYNC_SCRIPT="$REPO_ROOT/docs-site/sync-docs.sh" +SITE_DOCS="$REPO_ROOT/docs-site/docs" +if [[ ! -x "$SYNC_SCRIPT" ]]; then + fail "docs-site sync script missing or not executable" +elif ! grep -Fq '[0-9][0-9]-*.md' "$SYNC_SCRIPT"; then + fail "docs-site sync script must select only numbered Markdown files" +elif ! grep -Fq '_TEMPLATE.md is never site content' "$SYNC_SCRIPT"; then + fail "docs-site sync script must explicitly exclude the template" +else + pass "docs-site sync contract selects numbered docs and excludes the template" +fi +if [[ -f "$SITE_DOCS/_TEMPLATE.md" ]]; then + fail "docs-site must not contain docs/_TEMPLATE.md" +else + pass "docs-site template is absent" +fi + +# Each numbered doc must retain ordered sections 1 through 13 and at least one diagram. +mermaid_total=0 +for doc in "${DOCS[@]}"; do + heading_list="$(grep -E '^## [0-9]+\.' "$doc" | sed -E 's/^## ([0-9]+)\..*/\1/' | paste -sd, -)" + if [[ "$heading_list" != '1,2,3,4,5,6,7,8,9,10,11,12,13' ]]; then + fail "non-sequential numbered headings in ${doc#$REPO_ROOT/}: ${heading_list:-none}" + fi + count="$(grep -c '^```mermaid$' "$doc" || true)" + if [[ "$count" -lt 1 ]]; then + fail "missing Mermaid block in ${doc#$REPO_ROOT/}" + fi + mermaid_total=$((mermaid_total + count)) +done +# Every numbered doc carries at least one diagram. A fixed global total is +# deliberately not asserted: documents are added over time and a pinned sum turns +# a legitimate addition into a red test. +if [[ "$mermaid_total" -lt "${#DOCS[@]}" ]]; then + fail "Mermaid count ($mermaid_total) is below the document count (${#DOCS[@]}); every doc needs a diagram" +else + pass "${#DOCS[@]}/${#DOCS[@]} docs carry a diagram ($mermaid_total Mermaid blocks total)" +fi + +EXPECTED_PLAYBOOKS=(bf-ping.yml bf-uptime.yml bf-reboot.yml bf-docker-status.yml bf-service-restart.yml bf-wireguard-restart.yml bf-rustdesk-restart.yml bf-rdp-restart.yml bf-collect-logs.yml bf-disk-check.yml bf-package-check.yml bf-security-updates-check.yml bf-deploy-update.yml bf-run-script.yml bf-gui-on.yml bf-gui-off.yml bf-fieldos-version.yml bf-provisioning-status.yml bf-enrollment-status.yml bf-remote-channels-check.yml bf-offline-ready-check.yml) +for pb in "${EXPECTED_PLAYBOOKS[@]}"; do + [[ -f "$PB/$pb" ]] && pass "playbook present: $pb" || fail "missing playbook: $pb" +done + +for role in common wireguard docker monitoring; do + for file in tasks/main.yml defaults/main.yml README.md; do + [[ -f "$ROLES/$role/$file" ]] && pass "role file present: $role/$file" || fail "missing role file: $role/$file" + done +done + +# Safety contracts from docs/09 and docs/10. +grep -q 'serial' "$PB/bf-reboot.yml" || fail 'bf-reboot.yml must batch hosts with serial' +grep -q 'any_errors_fatal: true' "$PB/bf-reboot.yml" || fail 'bf-reboot.yml must halt on errors' +grep -q 'release_wave' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must require a release wave' +grep -q 'wave_gate_confirmed' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must require a wave gate' +grep -q 'halt_on_fail' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must enforce halt_on_fail' +grep -q 'max_fail_percentage: 0' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must stop the wave on failure' +grep -q 'serial: 5' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must use the fixed batch size' +grep -q 'production_override' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must require a separate production override' +grep -q 'ansible_play_hosts_all' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must verify selected hosts against the release wave' +grep -q 'Run mandatory post-batch health gate' "$PB/bf-deploy-update.yml" || fail 'bf-deploy-update.yml must run a post-batch health gate' +for pb in bf-collect-logs.yml bf-gui-on.yml bf-run-script.yml bf-gui-off.yml; do + grep -q 'operation_wave' "$PB/$pb" || fail "$pb must require operation_wave" + grep -q 'operation_confirmed' "$PB/$pb" || fail "$pb must require operation_confirmed" + grep -q 'ansible_play_hosts_all' "$PB/$pb" || fail "$pb must verify its selected hosts" + grep -q 'production_override' "$PB/$pb" || fail "$pb must require a separate production override" + grep -q "operation_wave != 'production' or production_override | bool" "$PB/$pb" || fail "$pb must refuse production without production_override=true" +done +if grep -q '^ hosts: blueforce$' "$PB/bf-gui-off.yml"; then + fail 'bf-gui-off.yml must not target the blueforce group by default' +fi +grep -q '^operation_wave:' "$REPO_ROOT/ansible/inventory/group_vars/all.yml" || fail 'shared operation_wave variable missing' +grep -q '^operation_confirmed:' "$REPO_ROOT/ansible/inventory/group_vars/all.yml" || fail 'shared operation_confirmed variable missing' +for asset in config/firewall/blueforce-docker-user-restore.sh config/systemd/blueforce-docker-firewall.service config/systemd/docker.service.d/blueforce-docker-firewall.conf; do + [[ -f "$REPO_ROOT/$asset" ]] || fail "missing persistent Docker firewall asset: $asset" +done +grep -q 'systemctl enable "$DOCKER_POLICY_UNIT"' "$REPO_ROOT/scripts/install/installer/modules/13-firewall.sh" || fail 'firewall module must enable persistent Docker firewall unit' +grep -q 'docker.service.d/blueforce-docker-firewall.conf' "$REPO_ROOT/scripts/install/installer/modules/13-firewall.sh" || fail 'firewall module must install Docker restart coupling' +grep -q 'DOCKER_POLICY_BIN" --check' "$REPO_ROOT/scripts/install/installer/modules/13-firewall.sh" || fail 'firewall module check must verify Docker policy rules' +grep -q '13-firewall' "$REPO_ROOT/scripts/install/installer/modules/18-final-check.sh" || fail 'final check must include the firewall rule verification gate' +grep -q 'bash "$SCRIPT_DIR/$g.sh" --check' "$REPO_ROOT/scripts/install/installer/modules/18-final-check.sh" || fail 'final check must execute every gate in check mode' +grep -q 'ctorigdstport' "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" || fail 'Docker firewall must consume published-port allowlist rules' +grep -q 'iptables -C DOCKER-USER' "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" || fail 'Docker firewall check must verify every installed allowlist rule' +grep -q -- '-i br+ -j RETURN' "$REPO_ROOT/config/firewall/blueforce-docker-user-restore.sh" || fail 'Docker firewall must permit egress from user-defined bridge interfaces' +grep -q '^BindsTo=docker.service$' "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" || fail 'Docker firewall service must bind to Docker lifecycle' +grep -q '^PartOf=docker.service$' "$REPO_ROOT/config/systemd/blueforce-docker-firewall.service" || fail 'Docker firewall service must restart with Docker' +grep -q '^Wants=blueforce-docker-firewall.service$' "$REPO_ROOT/config/systemd/docker.service.d/blueforce-docker-firewall.conf" || fail 'Docker must start the firewall service on every start' +grep -q 'BF_WG_HANDSHAKE_MAX_AGE' "$REPO_ROOT/scripts/install/installer/modules/06-wireguard.sh" || fail 'WireGuard module must enforce handshake freshness' +grep -q 'configured ||' "$REPO_ROOT/scripts/install/installer/modules/09-rustdesk.sh" || fail 'RustDesk module must prove client configuration' +grep -q 'service_allowlist' "$PB/bf-service-restart.yml" || fail 'bf-service-restart.yml must enforce an allowlist' +grep -q 'script_allowed_dirs' "$PB/bf-run-script.yml" || fail 'bf-run-script.yml must enforce allowed script paths' +grep -Fq "collect_logs_dir is match('^/var/lib/blueforce/support-bundles" "$PB/bf-collect-logs.yml" || fail 'bf-collect-logs.yml must restrict bundles to the approved support directory' +if [[ "$(grep -Fc '| quote' "$PB/bf-collect-logs.yml")" -lt 2 ]]; then + fail 'bf-collect-logs.yml must shell-quote every generated bundle path' +fi + +if grep -RniE --include='*.yml' '^[[:space:]]*[^#].*(image:[^#]*:latest([[:space:]#]|$)|watchtower)' "$PB" "$ROLES/docker"; then + fail "forbidden moving tag or Watchtower configuration found in deploy paths" +else + pass 'no moving tags or auto-updaters in deploy paths' +fi + +if grep -qniE 'state:[[:space:]]*(present|latest)|apt-get .*(upgrade|dist-upgrade)[[:space:]]+-y|apt:.*name:' "$PB/bf-security-updates-check.yml"; then + fail 'bf-security-updates-check.yml must remain report-only' +else + pass 'security update check is report-only' +fi + +# Field OS lifecycle and enrollment contracts must remain explicit and safe. +for asset in scripts/diagnostics/bf-enroll scripts/diagnostics/bf-enroll-now scripts/diagnostics/bf-enrollment-status scripts/diagnostics/bf-release scripts/diagnostics/bf-remote-status scripts/diagnostics/bf-check-local scripts/diagnostics/bf-check-enrollment scripts/diagnostics/bf-check-ready config/systemd/blueforce-enroll.service config/systemd/blueforce-enroll.timer; do + [[ -f "$REPO_ROOT/$asset" ]] && pass "Field OS asset present: $asset" || fail "missing Field OS asset: $asset" +done +grep -q -- '--offline' "$REPO_ROOT/scripts/install/blueforce-install.sh" || fail 'installer must support --offline' +grep -q 'PROVISIONED_OFFLINE' "$REPO_ROOT/scripts/install/blueforce-install.sh" || fail 'installer must record PROVISIONED_OFFLINE' +grep -q 'BF_ADMIN_PUBLIC_KEY' "$REPO_ROOT/scripts/install/installer/modules/07-ssh.sh" || fail 'SSH module must accept an explicit admin public key only before hardening' +grep -q 'systemctl is-active --quiet xrdp' "$REPO_ROOT/scripts/install/installer/modules/08-rdp.sh" || fail 'RDP check must require active xrdp' +if grep -Eq 'systemctl (stop|disable) (xrdp|xrdp-sesman)' "$REPO_ROOT/scripts/maintenance/bf-gui-off"; then fail 'bf-gui-off must never stop or disable xRDP'; else pass 'bf-gui-off preserves xRDP'; fi +grep -q -- '--data-binary "@\$REQUEST_FILE"' "$REPO_ROOT/scripts/diagnostics/bf-enroll" || fail 'enrollment curl must consume the request file' +grep -q -- '-H @<(printf' "$REPO_ROOT/scripts/diagnostics/bf-enroll" || fail 'enrollment Authorization header must use process substitution' +if grep -q 'Authorization: Bearer \*\*\*' "$REPO_ROOT/scripts/diagnostics/bf-enroll"; then fail 'enrollment must not send a redacted literal Authorization value'; fi +grep -q 'central_verification' "$REPO_ROOT/scripts/diagnostics/bf-remote-status" || fail 'remote status must require central verification evidence' +if grep -q 'bf-enrollment-status\|phase="' "$REPO_ROOT/scripts/diagnostics/bf-remote-status"; then fail 'remote status must not infer proof from persisted lifecycle phase'; fi +grep -q 'UNAPPROVED_SKELETON' "$REPO_ROOT/scripts/diagnostics/bf-release" || fail 'release report must reject skeleton manifests' +"$REPO_ROOT/tests/test-field-os-lifecycle.sh" && pass 'Field OS lifecycle shell tests pass' || fail 'Field OS lifecycle shell tests failed' +grep -q 'PROVISIONED_OFFLINE.*ENROLLED.*READY' "$REPO_ROOT/scripts/diagnostics/bf-enrollment-status" || fail 'status must define canonical lifecycle states' +grep -q 'blueforce-install.sh' "$REPO_ROOT/provisioning/firstboot/bf-firstboot" && grep -q -- '--offline' "$REPO_ROOT/provisioning/firstboot/bf-firstboot" || fail 'firstboot must invoke offline installer after dealer ID validation' +grep -q 'BF_FIRSTBOOT_TEST_MODE' "$REPO_ROOT/provisioning/firstboot/bf-firstboot" || fail 'firstboot must provide mutation-free test mode' + +if [[ "$FAIL" -ne 0 ]]; then + printf 'check-specs: FAILED\n' >&2 + exit 1 +fi +printf 'check-specs: all passed\n' diff --git a/tests/qemu-field-os-test-plan.md b/tests/qemu-field-os-test-plan.md new file mode 100644 index 0000000..806e40f --- /dev/null +++ b/tests/qemu-field-os-test-plan.md @@ -0,0 +1,41 @@ +# Field OS QEMU/KVM ve LAB Test Planı + +Bu plan, mevcut `provisioning/iso/test-iso.sh` aracını değiştirmez. Araç checksum ve El Torito metadata doğrulaması yapar; QEMU/KVM önyükleme ve gerçek çevre birimi kabulü ayrı kanıttır. + +## Çalıştırma sınırları + +- CI yalnız statik doğrulama çalıştırır: `tests/check-provisioning-static.sh` ve `tests/check-migration-static.sh`. CI disk yazmaz, ISO boot etmez, QEMU/KVM veya PowerShell gerektirmez. +- QEMU/KVM testleri imzalı artefact ile izole LAB hostunda çalışır. Kurulum hedefi yalnız geçici QCOW2 disktir; fiziksel disk veya üretim ağı kullanılmaz. +- Fiziksel LAB testleri iki temsilci cihazda yapılır. USB/seri/MEG, Secure Boot ve Windows image restore yalnız burada kabul edilir. +- Her T1–T18 sonucu release manifesti, ISO/seed checksum ve kullanılan test ortamıyla kaydedilir. Başarısız test artefact'ın saha dağıtımını engeller. + +## Test matrisi + +| ID | Doğrulama | Ortam | Beklenen kanıt | +|---|---|---|---| +| T1 | Upstream ISO SHA256 doğrulaması | CI statik + LAB | `verify-upstream-iso.sh --check` başarılı | +| T2 | El Torito boot metadata | CI (xorriso varsa) + LAB | `test-iso.sh` başarılı; yoksa fail-closed | +| T3 | Seed yapısal doğrulaması | CI statik | NoCloud `user-data`/`meta-data` ve YAML parse başarılı | +| T4 | Secret ve bayi kimliği taraması | CI statik | provisioning ve migration asset'lerinde secret/kimlik yok | +| T5 | Autoinstall depolama kapısı | CI statik | storage seçimi interaktif, doğrudan wipe yok | +| T6 | UEFI QEMU boot | QEMU/KVM | OVMF ile kurulum menüsü ve seed algılandı | +| T7 | Legacy BIOS QEMU boot | QEMU/KVM | SeaBIOS ile kurulum menüsü ve seed algılandı | +| T8 | Secure Boot | Fiziksel LAB | desteklenen cihazda imzalı upstream yol boot eder | +| T9 | Geçici QCOW2 temiz kurulum | QEMU/KVM | yalnız QCOW2 değişti; sistem `PROVISIONED_OFFLINE` | +| T10 | Air-gapped kurulum | QEMU/KVM | WAN/DNS kapalıyken dış indirme yok | +| T11 | Offline APT manifesti | QEMU/KVM | paketler onaylı snapshot'tan çözülür | +| T12 | İlk açılış durum kapısı | QEMU/KVM | `READY` değil, `PROVISIONED_OFFLINE` görünür | +| T13 | NIC sürücüsü ve bağlantı | QEMU/KVM + fiziksel LAB | arayüz bulunur; fiziksel cihazda link/DHCP kabulü | +| T14 | Disk eşik ve yanlış-disk koruması | QEMU/KVM | 64 GiB altı disk BLOCKED; hedef onayı görünür | +| T15 | RAM ve mimari eşikleri | QEMU/KVM | x86_64/4 GiB temel kabul; alt eşik verdict'i doğru | +| T16 | USB çevre birimi | Fiziksel LAB | USB cihaz envanteri ve MEG kabul senaryosu başarılı | +| T17 | Seri çevre birimi | Fiziksel LAB | tty cihazı ve MEG seri iş akışı başarılı | +| T18 | Windows geri dönüş provası | Fiziksel LAB | hash doğrulanmış image restore sonrası iş akışı gelir | + +## QEMU/KVM örnek disiplin + +QEMU çağrısı release kaydında saklanır; yalnız geçici dosya kullanılır. Örnek hedef: `mktemp -d` altında QCOW2, OVMF değişken kopyası ve host-only ağ. Komut çalıştırılmadan önce ISO checksum, seed checksum ve disk yolunun geçici dizin altında olduğu doğrulanır. Test bitince QCOW2 yalnız LAB saklama politikasına göre imha edilir. + +## Çıkış kriteri + +T1–T5 CI statik olarak yeşil olmadan QEMU/KVM başlatılmaz. T6–T15 tamamlanmadan custom ISO saha artefact'ı olmaz. T16–T18 fiziksel LAB kanıtı olmadan cihaz profili veya Windows geçişi kabul edilmez. diff --git a/tests/run-all.sh b/tests/run-all.sh new file mode 100755 index 0000000..4d5be2e --- /dev/null +++ b/tests/run-all.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Single entry point for the repository test suite. +# +# Runs every tests/*.sh (except this file) in sorted order, reports each result, and +# exits with the sum of the individual exit codes so that a single non-zero test can +# never be mistaken for a clean run. +# +# Usage: +# bash tests/run-all.sh # run everything +# bash tests/run-all.sh offline firstboot # run only tests whose name contains a term +# +# -e is deliberately not set: a failing test must not abort the rest of the run, it has +# to be reported and counted. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SELF="$(basename "${BASH_SOURCE[0]}")" + +filters=("$@") +tests=() +while IFS= read -r script; do + tests+=("$script") +done < <(find "$SCRIPT_DIR" -maxdepth 1 -type f -name '*.sh' ! -name "$SELF" | sort) + +if [[ ${#tests[@]} -eq 0 ]]; then + printf 'run-all: no tests found in %s\n' "$SCRIPT_DIR" >&2 + exit 1 +fi + +selected=() +for script in "${tests[@]}"; do + if [[ ${#filters[@]} -eq 0 ]]; then + selected+=("$script") + continue + fi + for filter in "${filters[@]}"; do + if [[ "$(basename "$script")" == *"$filter"* ]]; then + selected+=("$script") + break + fi + done +done + +if [[ ${#selected[@]} -eq 0 ]]; then + printf 'run-all: no test matched: %s\n' "${filters[*]}" >&2 + exit 1 +fi + +printf 'run-all: %d test file(s) from %s\n' "${#selected[@]}" "$REPO_ROOT" +exit_code_sum=0 +failures=() + +for script in "${selected[@]}"; do + name="$(basename "$script")" + printf '\n=== %s ===\n' "$name" + bash "$script" + status=$? + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "--- $name: PASS" + else + printf '%s\n' "--- $name: FAIL (exit $status)" + failures+=("$name") + fi + exit_code_sum=$((exit_code_sum + status)) +done + +printf '\n=== run-all summary ===\n' +printf 'tests run : %d\n' "${#selected[@]}" +# Build the failing-name suffix explicitly: inlining this command substitution in +# the printf argument list makes the empty-array case drop an argument and printf +# then reports "usage: printf ...". +failure_suffix="" +if [[ ${#failures[@]} -gt 0 ]]; then + failure_suffix=" (${failures[*]})" +fi +printf 'failed : %d%s\n' "${#failures[@]}" "$failure_suffix" +printf 'exit code : %d (sum of individual exit codes)\n' "$exit_code_sum" + +if [[ "$exit_code_sum" -gt 255 ]]; then + exit 255 +fi +exit "$exit_code_sum" diff --git a/tests/semaphore-community-limits.md b/tests/semaphore-community-limits.md new file mode 100644 index 0000000..361d86b --- /dev/null +++ b/tests/semaphore-community-limits.md @@ -0,0 +1,27 @@ +# Semaphore Community limits (working rules for the Blueforce fleet) + +Source: `docs/09-FLEET-MANAGEMENT.md` section 9, "Semaphore Community sinirlari". +This file restates the limits in English as an executable checklist for +playbook authors and Semaphore operators. No Pro/Enterprise feature may be +used anywhere in `ansible/` or in any Semaphore task template. + +## Limits + +1. No OIDC/SSO: Semaphore users are local accounts; access only behind WireGuard. +2. No 2FA: password policy + mandatory VPN compensates; admin accounts are inventoried. +3. No external Vault integration: secrets live in the Semaphore built-in encrypted + Key Store; production secrets are never embedded in playbooks. +4. Concurrency limits (runners/forks): load-test against 700 nodes first, then tune + runner count + Ansible `serial`/`forks`/batch settings. Destructive playbooks + (`bf-reboot.yml`, `bf-deploy-update.yml`) already enforce `serial` + + `max_fail_percentage: 0` + `any_errors_fatal: true`. +5. No workflow may depend on a Pro-only feature. Anything that does not run on + Community is rejected in review (`tests/check-specs.sh` greps for it). + +## Operator checklist + +- [ ] Semaphore reachable only via WireGuard. +- [ ] Every destructive template runs `--check` dry run + `--limit ` first. +- [ ] Waves follow docs/10: lab(2) -> pilot-1(5) -> pilot-2(20) -> wave-1(50) -> wave-2(100) -> production. +- [ ] Secrets referenced from Key Store, never committed to Git. +- [ ] `tests/check-specs.sh` and `tests/check-configs.sh` pass before merge. diff --git a/tests/test-field-os-lifecycle.sh b/tests/test-field-os-lifecycle.sh new file mode 100755 index 0000000..f6d8df3 --- /dev/null +++ b/tests/test-field-os-lifecycle.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# Exercise the Field OS lifecycle, enrollment and readiness contracts with mocked local services. +# +# This host has no systemd, no apt and no internet, so every external dependency +# (systemctl, wg, ip, dpkg-query, curl) is a mock in $TMP/bin. Scenarios: +# +# 1 secret-safe enrollment request/header/response path +# 2 offline installer fails closed without a local repository +# 3 failed WireGuard activation rolls back and keeps the prior phase +# 4 ROLLBACK/READY: independent proof, fail-closed without central evidence +# 5 skeleton release manifest is never a production release +# 6 state machine: forward-only, no stage skipping, contract validation +# 7 bf-check-local passes without internet and fails closed on key hygiene +# 8 bf-check-enrollment requires central evidence, peer config and handshake +# 9 replay/backwards protection and atomic 0600 state writes +# 10 systemd attempt: PENDING without a token, bounded retry with one staged +# 11 --check dry run never mutates state or sends a request +# 12 enrollment schemas match the real client behaviour +# 13 boot unit cannot loop, carries no credential and stays bounded +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="$REPO_ROOT/scripts/diagnostics" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +pass() { printf 'OK: %s\n' "$*"; } + +PUBLIC_KEY='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +SERVER_KEY='BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' +DEALER='12345678' +ENDPOINT='https://control.blueforce.test/enroll' + +make_mock_bin() { + local d="$1" + mkdir -p "$d" + cat > "$d/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$MOCK_CALLS" +case "${1:-}" in + is-active) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; + is-enabled) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; + enable|restart) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; + get-default) printf 'multi-user.target\n' ;; + *) exit 0 ;; +esac +EOF + cat > "$d/curl" <<'EOF' +#!/usr/bin/env bash +# Mock curl: records argv, captures the request file and the header fd, then +# either writes the canned response or fails like a transport/protocol error. +set -euo pipefail +request='' output='' header='' code='' +while [[ $# -gt 0 ]]; do + case "$1" in + --data-binary) request="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + --write-out) code="$2"; shift 2 ;; + -H|--header) header="$2"; shift 2 ;; + *) printf 'argv=%s\n' "$1" >> "$MOCK_CURL_LOG"; shift ;; + esac +done +[[ "$request" == @* ]] || exit 91 +cat "${request#@}" > "$MOCK_REQUEST_CAPTURE" +[[ "$header" == @* ]] || exit 92 +cat "${header#@}" > "$MOCK_HEADER_CAPTURE" +if [[ "${MOCK_CURL_FAIL:-0}" == 1 ]]; then + printf '%s' "${MOCK_HTTP_CODE:-000}" + exit "${MOCK_CURL_RC:-7}" +fi +printf '%s' "$MOCK_RESPONSE" > "$output" +[[ -z "$code" ]] || printf '%s' "${MOCK_HTTP_CODE:-200}" +EOF + cat > "$d/wg" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + show) printf 'peer %s\n' "${MOCK_HANDSHAKE:-0}" ;; + pubkey) IFS= read -r _key || true; printf '%s\n' "${MOCK_PUBLIC_KEY:-}" ;; + *) exit 0 ;; +esac +EOF + cat > "$d/ip" <<'EOF' +#!/usr/bin/env bash +[[ "${MOCK_IP_FAIL:-0}" == 1 ]] && exit 1 +exit 0 +EOF + cat > "$d/dpkg-query" <<'EOF' +#!/usr/bin/env bash +printf 'install ok installed' +EOF + chmod +x "$d"/* +} + +MOCK_BIN="$TMP/bin"; make_mock_bin "$MOCK_BIN" +export PATH="$MOCK_BIN:$PATH" MOCK_CALLS="$TMP/calls" MOCK_CURL_LOG="$TMP/curl-argv" +export MOCK_REQUEST_CAPTURE="$TMP/request" MOCK_HEADER_CAPTURE="$TMP/header" +export MOCK_PUBLIC_KEY="$PUBLIC_KEY" +STATE_DIR="$TMP/state"; WG_DIR="$TMP/wg"; mkdir -p "$STATE_DIR" "$WG_DIR" +printf '%s\n' "$DEALER" > "$STATE_DIR/dealer-id" +printf '%s\n' 'private-test-key' > "$WG_DIR/privatekey" +printf '%s\n' "$PUBLIC_KEY" > "$WG_DIR/publickey" +chmod 600 "$WG_DIR/privatekey" "$WG_DIR/publickey" "$STATE_DIR/dealer-id" +export STATE_DIR WG_DIR BF_STATE_FILE="$STATE_DIR/state.json" +IDEM="$(python3 -c "import hashlib,sys;print(hashlib.sha256(('p-1|BF-'+sys.argv[1]+'|'+sys.argv[2]).encode()).hexdigest())" "$DEALER" "$PUBLIC_KEY")" + +write_state() { printf '%s\n' "$1" > "$BF_STATE_FILE"; chmod 600 "$BF_STATE_FILE"; } +state_field() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2]))" "$BF_STATE_FILE" "$1"; } +central_pending='{"status":"pending","verification_id":"cv-1","monitoring":{"status":"pending","verification_id":"mon-1"},"remote":{"status":"pending","verification_id":"rem-1"},"management":{"status":"pending","verification_id":"mgmt-1"}}' +central_verified='{"status":"verified","verification_id":"cv-1","monitoring":{"status":"verified","verification_id":"mon-1"},"remote":{"status":"verified","verification_id":"rem-1"},"management":{"status":"verified","verification_id":"mgmt-1"}}' +pending_state="{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" +enrolled_state() { printf '{"phase":"ENROLLED","enrollment_status":"ENROLLED","fleet_status":"PENDING","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; } +ready_state() { printf '{"phase":"READY","enrollment_status":"ENROLLED","fleet_status":"READY","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; } +mocked_response() { printf '{"status":"accepted","device_id":"BF-%s","idempotency_key":"%s","enrollment_id":"enr-1","wireguard":{"address":"10.20.0.17/32","endpoint":"wg.blueforce.test:51820","server_public_key":"%s","allowed_ips":"10.20.0.0/24"},"central_verification":%s}' "$DEALER" "$IDEM" "$SERVER_KEY" "$1"; } + +# --- scenario 1: the secret-safe enrollment path ---------------------------- +write_state "$pending_state" +export MOCK_RESPONSE="$(mocked_response "$central_verified")" +printf '%s\n' 'super-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'successful enrollment must commit ENROLLED' +[[ -s "$WG_DIR/wg0.conf" ]] || fail 'successful enrollment must install wg0.conf' +[[ "$(stat -c '%a' "$WG_DIR/wg0.conf")" == '600' ]] || fail 'wg0.conf must be mode 600' +[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state.json must be mode 600' +grep -q "BF-$DEALER" "$MOCK_REQUEST_CAPTURE" || fail 'curl must consume the generated request file' +grep -Fqx 'Authorization: Bearer super-secret-token' "$MOCK_HEADER_CAPTURE" || fail 'curl must receive the exact supplied token through process substitution' +! grep -R -F 'super-secret-token' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'token persisted outside the transient curl fixture' +! grep -R -F 'super-secret-token' "$MOCK_CURL_LOG" "$STATE_DIR" 2>/dev/null || fail 'token leaked to curl argv, output, or state' +pass 'curl request/header/response path is secret-safe and functional' + +# --- scenario 12 (early check): request body carries public facts only ------- +python3 - "$MOCK_REQUEST_CAPTURE" "$PUBLIC_KEY" <<'PY' +import json, sys +request = json.load(open(sys.argv[1], encoding='utf-8')) +if set(request) != {'device_id', 'wireguard_public_key', 'idempotency_key', 'hardware', 'release'}: + raise SystemExit('unexpected request field set: %s' % sorted(request)) +if request['wireguard_public_key'] != sys.argv[2]: + raise SystemExit('request must carry the local public key') +blob = json.dumps(request).lower() +for banned in ('private', 'secret', 'password', 'passwd', 'bearer', 'authorization'): + if banned in blob: + raise SystemExit('request carries a secret-like field: ' + banned) +PY +! grep -q 'private-test-key' "$MOCK_REQUEST_CAPTURE" || fail 'the WireGuard private key must never be sent' +! grep -q 'super-secret-token' "$MOCK_REQUEST_CAPTURE" || fail 'the one-time token must never be sent in the request body' +pass 'request carries public facts only: no private key, no token, no credential' + +# --- scenario 2: offline installer fails closed ----------------------------- +cat > "$MOCK_BIN/apt-get" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$MOCK_APT_CALLS" +exit 90 +EOF +chmod +x "$MOCK_BIN/apt-get" +export MOCK_APT_CALLS="$TMP/apt-calls" +if STATE_DIR="$TMP/offline-state" LOG_FILE="$TMP/offline.log" BF_OFFLINE_REPO="$TMP/missing-offline-repo" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/offline.out" 2>&1; then fail 'offline installer must reject a missing local repository before modules'; fi +[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'offline repository rejection must not invoke apt-get' +! grep -Eqi '(curl|wget|https?://|apt[[:space:]]+download)' "$TMP/offline.out" "$TMP/offline.log" 2>/dev/null || fail 'offline rejection must not run or report network package acquisition' +pass 'offline installer fails closed before package modules when local repository is absent' + +# --- scenario 3: activation failure rolls back, phase is preserved ---------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" +export MOCK_SYSTEMCTL_FAIL=1 +if printf '%s\n' 'another-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin; then fail 'activation failure must fail enrollment'; fi +[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'activation failure must not commit ENROLLED' +[[ "$(state_field enrollment_status)" == 'PENDING' ]] || fail 'activation failure must leave enrollment PENDING' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'activation failure must roll back a new config' +[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic state writes must leave no temporary files' +unset MOCK_SYSTEMCTL_FAIL +pass 'state transition rolls back on WireGuard activation failure and stays PENDING' + +# --- scenario 4: READY proof is independent and fails closed ---------------- +printf '%s\n' 'third-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +[[ -s "$WG_DIR/wg0.conf" ]] || fail 're-enrollment after a rolled-back attempt must install wg0.conf' +RUST_SYSTEM="$TMP/RustDesk-system.toml"; RUST_USER="$TMP/RustDesk-user.toml" +printf "rendezvous-server = 'rustdesk.blueforce.test'\nrelay-server = 'rustdesk.blueforce.test'\n" > "$RUST_SYSTEM" +cp "$RUST_SYSTEM" "$RUST_USER" +export MOCK_HANDSHAKE="$(date +%s)" BF_RUSTDESK_SYSTEM_CONFIG="$RUST_SYSTEM" BF_RUSTDESK_USER_CONFIG="$RUST_USER" BF_MESH_CONFIG="$TMP/no-mesh" +python3 - "$BF_STATE_FILE" "$central_verified" <<'PY' +import json, sys +path, verified = sys.argv[1], json.loads(sys.argv[2]) +state = json.load(open(path, encoding='utf-8')) +state['central_verification'] = verified +json.dump(state, open(path, 'w', encoding='utf-8')) +PY +"$BIN/bf-check-ready" >/dev/null || fail 'ENROLLED state with independent local and central evidence must become ready-check eligible' +python3 - "$BF_STATE_FILE" <<'PY' +import json, sys +path = sys.argv[1] +state = json.load(open(path, encoding='utf-8')) +del state['central_verification'] +json.dump(state, open(path, 'w', encoding='utf-8')) +PY +if "$BIN/bf-check-ready" > "$TMP/ready-failclosed.out" 2>&1; then fail 'missing central verification evidence must fail closed'; fi +if "$BIN/bf-check-enrollment" > "$TMP/enroll-failclosed.out" 2>&1; then fail 'the ENROLLED gate must fail closed without central evidence'; fi +grep -q 'central_verification is missing' "$TMP/enroll-failclosed.out" || fail 'the ENROLLED gate must name the missing central evidence' +pass 'READY proof is independent of persisted READY and fails closed without central evidence' + +# --- scenario 5: skeleton release manifest ---------------------------------- +RELEASE="$TMP/release.yaml" +printf 'release:\n name: blueforce-provisioning-skeleton\n version: 0.1.0\n status: skeleton\n commit: deadbeef\n' > "$RELEASE" +if BF_RELEASE_MANIFEST="$RELEASE" "$BIN/bf-release" > "$TMP/release.out"; then fail 'skeleton release must be nonzero'; fi +grep -q 'UNAPPROVED_SKELETON' "$TMP/release.out" || fail 'skeleton release must be labelled unapproved' +pass 'nested skeleton manifest is not represented as a production release' + +# --- scenario 6: state machine contract ------------------------------------- +write_state "$pending_state" +[[ "$("$BIN/bf-enrollment-status" --field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'canonical reader must report the provisioned phase' +write_state "$(enrolled_state "$central_pending")" +"$BIN/bf-enrollment-status" | grep -qx 'Enrollment: ENROLLED' || fail 'ENROLLED stage must be visible before READY' +write_state "$(ready_state "$central_verified")" +"$BIN/bf-enrollment-status" > "$TMP/ready-status.out" || fail 'READY with verified central evidence must be contract-valid' +grep -qx 'Lifecycle: READY' "$TMP/ready-status.out" || fail 'READY stage must be reported' +grep -qx 'Enrollment: COMPLETE' "$TMP/ready-status.out" || fail 'READY implies a complete enrollment' +grep -qx 'Fleet: READY' "$TMP/ready-status.out" || fail 'READY implies fleet READY' +write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" +if "$BIN/bf-enrollment-status" > "$TMP/skip.out" 2>/dev/null; then fail 'a skipped stage (PROVISIONED_OFFLINE -> READY) must be INVALID'; fi +grep -qx 'Lifecycle: INVALID' "$TMP/skip.out" || fail 'skipped stage must be reported as INVALID' +write_state "$(ready_state "$central_pending")" +if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'READY without verified channels must be INVALID'; fi +write_state "{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\"}" +if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'a state without provisioning_id must be INVALID'; fi +if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-enrollment-status" > "$TMP/absent.out" 2>/dev/null; then fail 'a missing state file must exit non-zero'; fi +grep -qx 'Lifecycle: PENDING' "$TMP/absent.out" || fail 'a missing state file must report PENDING' +pass 'state machine is forward-only, never skips a stage and validates its contract' + +# --- scenario 6b: bf-status lifecycle block --------------------------------- +write_state "$(enrolled_state "$central_pending")" +BF_STATE_FILE="$BF_STATE_FILE" "$BIN/bf-status" > "$TMP/status.out" 2>/dev/null || fail 'bf-status must always exit 0' +grep -qx 'Provisioning : COMPLETE' "$TMP/status.out" || fail 'bf-status must show Provisioning : COMPLETE once central accepted the device' +grep -qx 'Enrollment : ENROLLED' "$TMP/status.out" || fail 'bf-status must show Enrollment : ENROLLED' +grep -qx 'Fleet State : NOT READY' "$TMP/status.out" || fail 'bf-status must show Fleet State : NOT READY before READY' +write_state "$pending_state" +"$BIN/bf-status" > "$TMP/status-pending.out" 2>/dev/null +grep -qx 'Provisioning : PROVISIONED_OFFLINE' "$TMP/status-pending.out" || fail 'bf-status must show the offline provisioning stage' +grep -qx 'Enrollment : PENDING' "$TMP/status-pending.out" || fail 'bf-status must show a pending enrollment' +write_state "$(ready_state "$central_verified")" +"$BIN/bf-status" > "$TMP/status-ready.out" 2>/dev/null +grep -qx 'Fleet State : READY' "$TMP/status-ready.out" || fail 'bf-status must show Fleet State : READY' +write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" +"$BIN/bf-status" > "$TMP/status-invalid.out" 2>/dev/null +grep -q 'lifecycle state invalid' "$TMP/status-invalid.out" || fail 'bf-status must raise a critical alert for an invalid lifecycle state' +pass 'bf-status prints the Provisioning/Enrollment/Fleet State lifecycle block' + +# --- scenario 7: local gate is offline-safe --------------------------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" +"$BIN/bf-check-local" > "$TMP/local.out" 2>&1 || fail 'bf-check-local must pass offline with no handshake and no wg0.conf' +grep -q 'local gates PASS' "$TMP/local.out" || fail 'bf-check-local must report a passing local gate' +! grep -q '^FAIL' "$TMP/local.out" || fail 'bf-check-local must not fail any local gate offline' +"$BIN/bf-check-local" --check >/dev/null 2>&1 || fail 'bf-check-local --check must behave like the default run' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'bf-check-local must not create enrollment artifacts' +! grep -Eq '(^|/)(curl|wget|apt|apt-get)( |$)' "$TMP/calls" 2>/dev/null || fail 'bf-check-local must not call network tools' +chmod 644 "$WG_DIR/privatekey" +if "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a world-readable WireGuard private key must fail the local gate'; fi +chmod 600 "$WG_DIR/privatekey" +MOCK_PUBLIC_KEY='CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=' "$BIN/bf-check-local" >/dev/null 2>&1 && fail 'a mismatched WireGuard keypair must fail the local gate' +if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a missing state file must fail the local gate'; fi +pass 'bf-check-local passes offline and fails closed on key hygiene' + +# --- scenario 8: ENROLLED gate needs evidence and a live tunnel ------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" +printf '%s\n' 'enrolled-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +export MOCK_HANDSHAKE="$(date +%s)" +[[ -s "$WG_DIR/wg0.conf" ]] || fail 'a successful enrollment must install the assigned peer configuration' +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'a successful enrollment must commit ENROLLED' +"$BIN/bf-check-enrollment" >/dev/null || fail 'ENROLLED gates must pass with peer config, live tunnel and a fresh handshake' +MOCK_HANDSHAKE=0 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing WireGuard handshake must fail the ENROLLED gate' +export MOCK_HANDSHAKE="$(date +%s)" +MOCK_IP_FAIL=1 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the ENROLLED gate' +MOCK_IP_FAIL=1 "$BIN/bf-check-ready" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the READY gate' +mv "$WG_DIR/wg0.conf" "$TMP/wg0.conf.keep" +"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing peer configuration must fail the ENROLLED gate' +mv "$TMP/wg0.conf.keep" "$WG_DIR/wg0.conf" +write_state "$pending_state" +"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'PROVISIONED_OFFLINE must never pass the ENROLLED gate' +write_state "$(enrolled_state "$central_pending")" +"$BIN/bf-check-enrollment" >/dev/null || fail 'a device with pending central channels is still ENROLLED' +if "$BIN/bf-check-ready" >/dev/null 2>&1; then fail 'pending central channels must never produce READY'; fi +pass 'bf-check-enrollment requires central evidence, peer configuration and a fresh handshake' + +# --- scenario 9: replay and backwards protection ---------------------------- +write_state "$pending_state" +printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +REPLAY_BEFORE="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")" +if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin > "$TMP/replay.out" 2>&1; then fail 'a second enrollment without --reenroll must be refused'; fi +grep -q 'already ENROLLED' "$TMP/replay.out" || fail 'the replay refusal must explain the ENROLLED state' +REPLAY_AFTER="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")" +[[ "$REPLAY_BEFORE" == "$REPLAY_AFTER" ]] || fail 'a refused replay must not touch the state file' +printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll >/dev/null || fail '--reenroll must re-apply enrollment' +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail '--reenroll must never move the phase backwards' +write_state "$(ready_state "$central_verified")" +if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll > "$TMP/ready-refusal.out" 2>&1; then fail 'a READY device must refuse enrollment even with --reenroll'; fi +grep -q 'never move backwards' "$TMP/ready-refusal.out" || fail 'the READY refusal must explain the forward-only rule' +[[ "$(state_field phase)" == 'READY' ]] || fail 'the READY refusal must not change the phase' +[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state writes must stay at mode 600' +[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic writes must not leave temporary files' +pass 'replay and backwards transitions are refused, state writes are atomic and 0600' + +# --- scenario 10: systemd attempt and staged token -------------------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/pending.out" 2>&1 || fail 'the boot attempt must exit 0 while nothing is staged' +grep -q 'PENDING' "$TMP/pending.out" || fail 'the boot attempt must report PENDING' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a pending boot attempt must not install a config' +BF_STATE_FILE="$TMP/absent.json" BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/unprovisioned.out" 2>&1 || fail 'an unprovisioned device must exit 0 so the unit never loops' +grep -q 'PENDING' "$TMP/unprovisioned.out" || fail 'an unprovisioned device must report PENDING' +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 || fail 'a boot attempt without central configuration must exit 0' +printf '%s\n' 'staged-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null || fail '--stage must store the token' +[[ "$(stat -c '%a' "$STATE_DIR/enrollment.token")" == '600' ]] || fail 'the staged token must be mode 600' +MOCK_CURL_FAIL=1 MOCK_CURL_RC=7 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/offline-attempt.out" 2>&1 && fail 'an unreachable endpoint must fail the attempt so the bounded retry can run' +[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'a transient failure must keep the staged token for the bounded retry' +[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'a failed attempt must keep the lifecycle at PROVISIONED_OFFLINE' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a failed attempt must not leave a WireGuard config behind' +unset MOCK_CURL_FAIL MOCK_CURL_RC +MOCK_CURL_FAIL=1 MOCK_CURL_RC=22 MOCK_HTTP_CODE=403 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 && fail 'a definitive rejection must fail the attempt' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'a definitive rejection must discard the consumed token' +unset MOCK_CURL_FAIL MOCK_CURL_RC MOCK_HTTP_CODE +printf '%s\n' 'staged-secret-token-two' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null +touch -d '2 hours ago' "$STATE_DIR/enrollment.token" +BF_ENROLLMENT_TOKEN_MAX_AGE=1800 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'an expired staged token must exit 0 as PENDING' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'an expired staged token must be discarded' +printf '%s\n' 'staged-secret-token-three' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll-now" --stage >/dev/null || fail 'the technician wrapper must stage a token' +[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'bf-enroll-now --stage must leave the token staged' +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'the boot attempt must enroll when a valid token is staged and central is reachable' +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'the boot attempt must commit ENROLLED' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'the staged token must be deleted after use' +! grep -R -F 'staged-secret-token-three' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'the staged token must not leak into state, logs or argv' +pass 'the boot attempt stays PENDING without a token and joins automatically with one' + +# --- scenario 11: --check dry run ------------------------------------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" +BEFORE="$(cat "$BF_STATE_FILE")" +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --check > "$TMP/check.out" 2>&1 || fail '--check must succeed on a valid pre-enrollment device' +grep -q 'would enroll' "$TMP/check.out" || fail '--check must describe the planned transition' +[[ "$BEFORE" == "$(cat "$BF_STATE_FILE")" ]] || fail '--check must not touch the state file' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail '--check must not write a WireGuard config' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail '--check must not stage a token' +"$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1 && fail '--check must fail without an endpoint' +if BF_ENROLLMENT_ENDPOINT='https://example.invalid/enroll' "$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1; then fail '--check must reject placeholder endpoints'; fi +pass '--check validates without mutating or sending anything' + +# --- scenario 12: schemas match the client behaviour ------------------------ +python3 - "$REPO_ROOT" "$MOCK_REQUEST_CAPTURE" "$MOCK_RESPONSE" <<'PY' +import json, sys +root, request_capture, response_sample = sys.argv[1:4] +request_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-request.schema.json', encoding='utf-8')) +response_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-response.schema.json', encoding='utf-8')) +request = json.load(open(request_capture, encoding='utf-8')) +response = json.loads(response_sample) +if set(request_schema['properties']) != set(request_schema['required']): + raise SystemExit('request schema must close every property it requires') +if set(request) != set(request_schema['required']): + raise SystemExit('captured request does not match the request schema') +for section, keys in (('hardware', ('machine_id', 'product_uuid', 'sys_vendor')), ('release', ('os_release', 'kernel'))): + if set(request_schema['properties'][section]['required']) != set(keys): + raise SystemExit('%s schema fields drifted' % section) + if set(request[section]) != set(keys): + raise SystemExit('captured %s does not match the schema' % section) +required_response = set(response_schema['required']) +if set(response) - set(response_schema['properties']): + raise SystemExit('canned response carries fields the schema forbids') +if not required_response <= set(response): + raise SystemExit('canned response is missing required fields') +if set(response['wireguard']) != set(response_schema['properties']['wireguard']['required']): + raise SystemExit('WireGuard response fields drifted') +if set(response['central_verification']) != set(response_schema['properties']['central_verification']['required']): + raise SystemExit('central verification fields drifted') +blob = json.dumps(response_schema).lower() +for banned in ('private_key', 'privkey', 'password', 'bearer'): + if banned in blob: + raise SystemExit('response schema must not define a secret field: ' + banned) +PY +! grep -q 'BF-12345678' "$REPO_ROOT/provisioning/enrollment/README.md" || fail 'provisioning docs must not embed a device identity' +pass 'enrollment schemas match the implemented request and response contract' + +# --- scenario 13: boot unit cannot loop and carries no credential ----------- +UNIT="$REPO_ROOT/config/systemd/blueforce-enroll.service" +TIMER="$REPO_ROOT/config/systemd/blueforce-enroll.timer" +grep -q '^Restart=on-failure$' "$UNIT" || fail 'the boot unit must retry only failures' +grep -q '^Restart=always' "$UNIT" && fail 'the boot unit must never restart unconditionally' +grep -q '^RestartSec=' "$UNIT" || fail 'the boot unit must delay its bounded retries' +grep -q '^StartLimitBurst=' "$UNIT" || fail 'the boot unit must bound its retries' +grep -q '^StartLimitIntervalSec=' "$UNIT" || fail 'the boot unit must bound its retry window' +grep -q 'ExecStart=/usr/local/bin/bf-enroll --systemd' "$UNIT" || fail 'the boot unit must run the controlled attempt' +grep -q 'Unit=blueforce-enroll.service' "$TIMER" || fail 'the timer must trigger the enrollment attempt' +grep -q 'OnBootSec=' "$TIMER" || fail 'the timer must attempt shortly after boot' +! grep -Eqi '(token|password|secret|bearer)[[:space:]]*=' "$UNIT" "$TIMER" || fail 'the units must not embed a credential' +exec_lines="$(grep -E '^Exec[A-Za-z]*=' "$UNIT" || true)" +[[ -n "$exec_lines" ]] || fail 'the boot unit must define its attempt command' +! printf '%s\n' "$exec_lines" | grep -Eqi 'reboot|shutdown' || fail 'the boot unit must never reboot or shut down the device' +grep -q 'never reboots' "$UNIT" || fail 'the boot unit must document that it never reboots the device' +pass 'the enrollment units retry in a bounded way and carry no credential' + +printf 'test-field-os-lifecycle: all scenarios passed\n' diff --git a/tests/test-firstboot-static.sh b/tests/test-firstboot-static.sh new file mode 100755 index 0000000..96fab84 --- /dev/null +++ b/tests/test-firstboot-static.sh @@ -0,0 +1,516 @@ +#!/usr/bin/env bash +# Static and sandboxed behavior test for the first-boot terminal wizard. +# +# It never runs systemd, never touches apt, and never touches the real /var state: every +# scenario works inside a throwaway sandbox with a fake installer. Scenarios that require +# root run in a separate user namespace (unshare -r) and are reported as SKIP when the +# host does not permit that. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +BF="$REPO_ROOT/provisioning/firstboot/bf-firstboot" +UNIT="$REPO_ROOT/provisioning/firstboot/blueforce-firstboot.service" +README_FILE="$REPO_ROOT/provisioning/firstboot/README.md" + +WORK="$(mktemp -d)" +PASSED=0 +FAILED=0 +SKIPPED=0 + +trap 'rm -rf "$WORK"' EXIT + +ok() { + if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + printf 'PASS\t%s\n' "$*" >>"$BF_TEST_RESULTS" + else + printf 'OK %s\n' "$*" + PASSED=$((PASSED + 1)) + fi +} +bad() { + if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + printf 'FAIL\t%s\n' "$*" >>"$BF_TEST_RESULTS" + else + printf 'FAIL %s\n' "$*" >&2 + FAILED=$((FAILED + 1)) + fi +} +skip() { + if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + printf 'SKIP\t%s\n' "$*" >>"$BF_TEST_RESULTS" + else + printf 'SKIP %s\n' "$*" + SKIPPED=$((SKIPPED + 1)) + fi +} +section() { [[ -n "${BF_TEST_RESULTS:-}" ]] || printf '\n== %s ==\n' "$*"; } + +assert_rc() { + if [[ "$1" == "$2" ]]; then ok "$3"; else bad "$3 (expected exit $1, got $2)"; fi +} +assert_output() { + if [[ "$2" == *"$1"* ]]; then ok "$3"; else bad "$3 (output lacks: $1)"; fi +} +assert_no_output() { + if [[ "$2" != *"$1"* ]]; then ok "$3"; else bad "$3 (output unexpectedly contains: $1)"; fi +} +assert_file() { + if [[ -f "$1" ]]; then ok "$2"; else bad "$2 (missing: $1)"; fi +} +assert_no_file() { + if [[ ! -e "$1" ]]; then ok "$2"; else bad "$2 (unexpected path: $1)"; fi +} +assert_eq() { + if [[ "$1" == "$2" ]]; then ok "$3"; else bad "$3 (expected '$1', got '$2')"; fi +} +assert_grep() { + if grep -qF -- "$1" "$2" 2>/dev/null; then ok "$3"; else bad "$3 (pattern not found in $2)"; fi +} +assert_no_grep() { + if grep -qF -- "$1" "$2" 2>/dev/null; then bad "$3 (forbidden pattern found in $2)"; else ok "$3"; fi +} +assert_no_directive() { + if grep -qE "^[[:space:]]*$1=" "$2" 2>/dev/null; then bad "$3 (forbidden unit directive in $2)"; else ok "$3"; fi +} + +ENV_ARGS=() +SB='' +OUT='' +RC=0 + +new_sandbox() { + SB="$(mktemp -d "$WORK/sb.XXXXXX")" + { + printf '#!/usr/bin/env bash\n' + printf 'printf "%%s\\n" "$*" >> "%s/calls.log"\n' "$SB" + } >"$SB/fake-install.sh" + chmod 755 "$SB/fake-install.sh" + printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplePublicKeyForStaticFirstbootTest field@blueforce\n' >"$SB/key.pub" + chmod 600 "$SB/key.pub" + ENV_ARGS=( + "BF_FIRSTBOOT_MARKER=$SB/marker" + "STATE_DIR=$SB/state" + "BF_INSTALLER_PATH=$SB/fake-install.sh" + "BF_ADMIN_PUBLIC_KEY_FILE=$SB/key.pub" + "BF_REIDENTITY_AUDIT_LOG=$SB/audit.log" + "BF_FIRSTBOOT_INPUT_TIMEOUT=10" + ) +} + +installer_calls() { + if [[ -f "$SB/calls.log" ]]; then wc -l <"$SB/calls.log" | tr -d ' '; else printf '0'; fi +} +installer_args() { + if [[ -f "$SB/calls.log" ]]; then cat "$SB/calls.log"; else printf ''; fi +} +sandbox_listing() { + (cd "$SB" && ls -1A | sort | tr '\n' ',') +} +write_marker() { + printf '%s\n' "$1" >"$SB/marker" + chmod 600 "$SB/marker" +} +marker_content() { + if [[ -f "$SB/marker" ]]; then tr -d '[:space:]' <"$SB/marker"; else printf ''; fi +} +file_mode() { + if [[ -e "$1" ]]; then stat -c '%a' "$1"; else printf ''; fi +} + +# Non-interactive invocation: stdin is /dev/null, so the wizard can never prompt. +run_bf() { + set +e + OUT="$(timeout 60 env "${ENV_ARGS[@]}" "$@" 2>&1 &1)" + RC=$? + set -e +} + +# Same, but with no bytes written to the pseudo terminal at all (timeout path). +run_bf_tty_silent() { + local inner + inner="$(pty_command "$@")" + set +e + OUT="$(printf '' | env SHELL=/bin/bash "${ENV_ARGS[@]}" timeout 60 script -qec "$inner" /dev/null 2>&1)" + RC=$? + set -e +} + +# --------------------------------------------------------------------------- +# Source, unit and documentation checks (no execution). +# --------------------------------------------------------------------------- +check_sources() { + section 'static: script, unit, README' + + [[ -x "$BF" ]] && ok 'bf-firstboot is executable' || bad 'bf-firstboot is executable' + assert_eq '#!/usr/bin/env bash' "$(head -n 1 "$BF")" 'bf-firstboot has a bash shebang' + if bash -n "$BF" 2>/dev/null; then ok 'bf-firstboot parses (bash -n)'; else bad 'bf-firstboot parses (bash -n)'; fi + assert_grep 'set -euo pipefail' "$BF" 'bf-firstboot uses strict mode' + assert_grep '^[0-9]{8}$' "$BF" 'bf-firstboot validates the dealer number with the shared regex' + assert_grep '# BLUEFORCE FIELD OS' "$BF" 'bf-firstboot prints the field console banner' + assert_grep "wizard_write '> '" "$BF" 'bf-firstboot prompts with the wizard input marker' + assert_grep '/dev/tty' "$BF" 'bf-firstboot opens /dev/tty when stdin is not a terminal' + assert_grep '/etc/blueforce/dealer-id' "$BF" 'bf-firstboot supports the seeded dealer id file' + assert_grep '--offline' "$BF" 'bf-firstboot calls the installer offline-only' + assert_grep 'blueforce-install.sh' "$BF" 'bf-firstboot targets the local installer' + assert_grep 'BF_FIRSTBOOT_TEST_MODE' "$BF" 'bf-firstboot keeps the mutation-free test mode' + assert_grep '--resume' "$BF" 'bf-firstboot resumes a partial install' + assert_grep '--force-reidentity' "$BF" 'bf-firstboot exposes the admin re-identity path' + assert_grep 'BF_ALLOW_REIDENTITY' "$BF" 'the re-identity path is gated by an operator switch' + assert_grep 'already completed' "$BF" 'bf-firstboot short-circuits on the completion marker' + + assert_no_grep 'curl' "$BF" 'bf-firstboot fetches nothing from the network' + assert_no_grep 'wget' "$BF" 'bf-firstboot does not download via wget' + assert_no_grep 'set -x' "$BF" 'bf-firstboot never traces (no accidental key dump)' + if grep -nE '(^|[^A-Za-z_])(cat|tee|head|tail|awk|printf|echo|cp|scp)[[:space:]]+[^|]*\$KEY_FILE' "$BF" >/dev/null 2>&1; then + bad 'the administrator key file is never read out into a message or a copy' + else + ok 'the administrator key file is never read out into a message or a copy' + fi + + assert_file "$UNIT" 'the first-boot unit is present' + assert_grep 'ConditionPathExists=/etc/blueforce/admin-authorized-key.pub' "$UNIT" 'the unit requires the administrator public key' + assert_grep 'ConditionPathExists=!/var/lib/blueforce/firstboot-complete' "$UNIT" 'the unit is skipped once the marker exists' + assert_grep 'TimeoutStartSec=' "$UNIT" 'the unit bounds its runtime' + assert_grep 'StandardInput=tty-force' "$UNIT" 'the unit attaches the wizard to the console' + assert_grep 'Type=oneshot' "$UNIT" 'the unit is a one-shot service' + assert_grep 'RemainAfterExit=yes' "$UNIT" 'the unit records its result' + assert_grep 'WantedBy=multi-user.target' "$UNIT" 'the unit runs once at boot' + assert_no_directive 'ProtectSystem' "$UNIT" 'the unit does not sandbox paths the offline installer must write' + assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps /home/blueforce writable for the user module' + + assert_file "$README_FILE" 'the first-boot README is present' + assert_grep 'BLUEFORCE FIELD OS' "$README_FILE" 'the README documents the wizard screen' + assert_grep 'force-reidentity' "$README_FILE" 'the README documents the admin identity change procedure' + assert_grep 'BF_ALLOW_REIDENTITY' "$README_FILE" 'the README documents that identity change is off by default' + assert_grep 'public SSH key' "$README_FILE" 'the README states the administrator key rule' + assert_grep '/var/log/blueforce-reidentity.log' "$README_FILE" 'the README documents the identity change audit record' + assert_grep 'ConditionPathExists' "$README_FILE" 'the README explains the unit conditions' +} + +# --------------------------------------------------------------------------- +# Command line behavior: check mode, validation, automation path, no terminal. +# --------------------------------------------------------------------------- +check_cli() { + section 'cli: validation, check mode, no-terminal fallback' + + new_sandbox + run_bf bash "$BF" --check --dealer-id 12010193 + assert_rc 0 "$RC" 'check mode accepts a valid dealer number' + assert_output 'no changes were made' "$OUT" 'check mode reports that it made no changes' + assert_no_file "$SB/marker" 'check mode writes no marker' + assert_no_file "$SB/state" 'check mode creates no state directory' + assert_eq '0' "$(installer_calls)" 'check mode never runs the installer' + assert_eq 'fake-install.sh,key.pub,' "$(sandbox_listing)" 'check mode leaves the sandbox untouched' + + for candidate in 1234567 123456789 1234abcd '' '12 34 5678'; do + new_sandbox + run_bf bash "$BF" --check --dealer-id "$candidate" + assert_rc 2 "$RC" "invalid dealer number is rejected: '${candidate:-}'" + assert_output '8 digits' "$OUT" "rejection of '${candidate:-}' explains the expected format" + assert_no_file "$SB/marker" "rejection of '${candidate:-}' writes no marker" + done + + new_sandbox + run_bf bash "$BF" --check + assert_rc 2 "$RC" 'check mode without any dealer number fails with a clear error' + assert_no_file "$SB/marker" 'a failed check mode writes no marker' + + new_sandbox + run_bf bash "$BF" --not-a-flag + assert_rc 2 "$RC" 'an unknown argument is rejected' + assert_output 'unknown argument' "$OUT" 'an unknown argument is reported explicitly' + + new_sandbox + run_bf bash "$BF" --dealer-id 1234567 + assert_rc 2 "$RC" 'a 7-digit dealer number never reaches the installer' + assert_no_file "$SB/marker" 'a rejected dealer number writes no marker' + assert_eq '0' "$(installer_calls)" 'a rejected dealer number never runs the installer' + + # Non-interactive mode with no seeded file and no flag must fail closed, without hanging. + new_sandbox + run_bf bash "$BF" --non-interactive + assert_rc 2 "$RC" 'non-interactive mode without any dealer number fails' + if [[ "$OUT" == *'dealer id file is missing or unreadable'* || "$OUT" == *'invalid dealer number'* ]]; then + ok 'non-interactive mode names the unusable dealer id input' + else + bad 'non-interactive mode names the unusable dealer id input' + fi + assert_no_file "$SB/marker" 'the no-input path writes no marker' + + # Explicit file path (automation) is honoured and never opens the wizard. + new_sandbox + printf '12010193\n' >"$SB/dealer-id" + run_bf bash "$BF" --dealer-id-file "$SB/dealer-id" + assert_no_output '# BLUEFORCE FIELD OS' "$OUT" 'the automation path never opens the wizard' + assert_no_output '8 Haneli Bayi Numarası' "$OUT" 'the automation path asks nothing at the console' + assert_eq '0' "$(installer_calls)" 'the automation path runs no installer when it cannot install' + if [[ "$(id -u)" -eq 0 ]]; then + assert_rc 0 "$RC" 'the dealer id file path completes when the test runs as root' + else + assert_rc 1 "$RC" 'the dealer id file path fails closed without root' + assert_output 'must run as root' "$OUT" 'the automation path reports the missing privilege' + assert_no_file "$SB/marker" 'the automation path writes no marker when it cannot install' + fi + + new_sandbox + printf 'nope\n' >"$SB/dealer-id" + run_bf bash "$BF" --dealer-id-file "$SB/dealer-id" + assert_rc 2 "$RC" 'an invalid value in the dealer id file is rejected' + assert_output 'invalid dealer number' "$OUT" 'an invalid file value names the format rule' + + new_sandbox + printf '12010193\n' >"$SB/dealer-id" + run_bf BF_FIRSTBOOT_NONINTERACTIVE=1 bash "$BF" --dealer-id-file "$SB/dealer-id" + assert_no_output '# BLUEFORCE FIELD OS' "$OUT" 'explicit non-interactive mode never opens the wizard' + assert_eq '0' "$(installer_calls)" 'explicit non-interactive mode runs no installer when it cannot install' +} + +# --------------------------------------------------------------------------- +# Wizard behavior through a pseudo terminal. +# --------------------------------------------------------------------------- +check_wizard() { + section 'wizard: pty input, confirmation, attempt limit' + + new_sandbox + run_bf_tty '1234567 +abcd +nope' bash "$BF" + assert_rc 2 "$RC" 'three malformed entries abort the wizard' + assert_output '# BLUEFORCE FIELD OS' "$OUT" 'the wizard shows the field console banner' + assert_output '8 Haneli Bayi Numarası' "$OUT" 'the wizard shows the Turkish dealer number prompt' + assert_output 'deneme 3/3' "$OUT" 'the wizard counts attempts and never accepts malformed input' + assert_output 'not accepted after 3 attempts' "$OUT" 'the wizard aborts with an explicit error after the limit' + assert_no_file "$SB/marker" 'the aborted wizard writes no marker' + assert_eq '0' "$(installer_calls)" 'the aborted wizard never runs the installer' + + new_sandbox + run_bf_tty '1201 0193 +12010193 +e' bash "$BF" + assert_output 'Geçersiz giriş' "$OUT" 'the wizard rejects a malformed entry that contains a space' + assert_output 'deneme 1/3' "$OUT" 'the wizard re-asks once a malformed entry is rejected' + assert_output 'BF-12010193' "$OUT" 'the wizard accepts the corrected entry after a rejection' + + new_sandbox + run_bf_tty '12010193 +e' bash "$BF" + assert_output 'BF-12010193' "$OUT" 'the confirmed wizard shows Device ID BF-' + assert_output 'bf-12010193' "$OUT" 'the confirmed wizard shows hostname bf-' + assert_eq '0' "$(installer_calls)" 'the wizard run does not reach the installer without root' + if [[ "$(id -u)" -eq 0 ]]; then + assert_rc 0 "$RC" 'the confirmed wizard completes when the test runs as root' + else + assert_rc 1 "$RC" 'the confirmed wizard stops at the privilege gate without root' + assert_output 'must run as root' "$OUT" 'the confirmed wizard proceeds past identity validation' + fi + + new_sandbox + run_bf_tty '12010193 +n +12010193 +n +12010193 +n' bash "$BF" + assert_rc 2 "$RC" 'refusing the confirmation three times aborts the wizard' + assert_output 'Onay verilmedi' "$OUT" 'the wizard re-asks when the identity is not confirmed' + assert_eq '0' "$(installer_calls)" 'a refused confirmation never runs the installer' + + new_sandbox + run_bf_tty_silent bash "$BF" + assert_rc 2 "$RC" 'a silent console aborts the wizard instead of waiting forever' + assert_output 'timed out' "$OUT" 'the silent console path explains the timeout' + assert_eq '0' "$(installer_calls)" 'the silent console path never runs the installer' +} + +# --------------------------------------------------------------------------- +# Root-only behavior in a separate user namespace. +# --------------------------------------------------------------------------- +check_root_paths() { + section 'root (namespace): install run, marker, key gate, re-identity' + + new_sandbox + run_bf BF_FIRSTBOOT_TEST_MODE=1 bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'test mode completes successfully as root' + assert_output "TEST: would run $SB/fake-install.sh --offline --dealer-id 12010193 --yes" "$OUT" 'test mode builds the exact offline installer command' + assert_no_file "$SB/marker" 'test mode is mutation-free (no marker)' + assert_eq '0' "$(installer_calls)" 'test mode never runs the installer' + + new_sandbox + mkdir -p "$SB/state" + printf '01-precheck=OK\n' >"$SB/state/install-state" + run_bf BF_FIRSTBOOT_TEST_MODE=1 bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'a partial install state is accepted' + assert_output '--resume' "$OUT" 'a partial install is resumed instead of restarted' + + new_sandbox + run_bf bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'first boot completes with the (fake) installer' + assert_file "$SB/marker" 'the completion marker is written' + assert_eq '600' "$(file_mode "$SB/marker")" 'the marker is mode 600' + assert_eq '12010193' "$(marker_content)" 'the marker records the provisioned dealer number' + assert_eq '--offline --dealer-id 12010193 --yes' "$(installer_args)" 'the installer is called offline with the validated dealer number' + + run_bf bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'a second run exits successfully' + assert_output 'already completed' "$OUT" 'a provisioned device reports that first boot is done' + assert_eq '1' "$(installer_calls)" 'a provisioned device never runs the installer again' + + run_bf bash "$BF" --dealer-id 87654321 + assert_rc 0 "$RC" 'a different dealer number does not fail the run' + assert_output 'identity changes use the admin procedure' "$OUT" 'a different dealer number points at the admin procedure' + assert_eq '12010193' "$(marker_content)" 'the provisioned identity is unchanged' + assert_eq '1' "$(installer_calls)" 'the installer is not re-run for a different dealer number' + + run_bf_tty '87654321 +e' bash "$BF" + assert_eq '12010193' "$(marker_content)" 'the wizard cannot change a provisioned identity' + assert_eq '1' "$(installer_calls)" 'the wizard does not re-run the installer on a provisioned device' + + new_sandbox + printf -- '-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAB3NzaC1yc2EFIRSTBOOTSECRETBODY\n-----END OPENSSH PRIVATE KEY-----\n' >"$SB/priv.key" + chmod 600 "$SB/priv.key" + run_bf BF_ADMIN_PUBLIC_KEY_FILE="$SB/priv.key" bash "$BF" --dealer-id 12010193 + assert_rc 1 "$RC" 'private key material is refused as an administrator key' + assert_output 'must contain only a public SSH key' "$OUT" 'the refusal states the public-key rule' + assert_no_output 'FIRSTBOOTSECRETBODY' "$OUT" 'no key material is echoed in the error output' + assert_no_output 'PRIVATE KEY' "$OUT" 'the private key header is never echoed' + assert_no_file "$SB/marker" 'a refused key writes no marker' + assert_eq '0' "$(installer_calls)" 'a refused key never runs the installer' + + new_sandbox + chmod 644 "$SB/key.pub" + run_bf bash "$BF" --dealer-id 12010193 + assert_rc 1 "$RC" 'a group/world readable administrator key file is refused' + assert_output 'root-owned' "$OUT" 'the refusal states the ownership and mode rule' + assert_no_file "$SB/marker" 'a refused key mode writes no marker' + + new_sandbox + run_bf BF_ADMIN_PUBLIC_KEY_FILE="$SB/missing.pub" bash "$BF" --dealer-id 12010193 + assert_rc 1 "$RC" 'a missing administrator key file blocks the offline install' + assert_output 'OFFLINE BLOCKED' "$OUT" 'a missing administrator key is reported as an offline block' + + new_sandbox + run_bf bash "$BF" --check --dealer-id 12010193 + assert_rc 0 "$RC" 'check mode succeeds as root' + assert_no_file "$SB/marker" 'check mode writes no marker as root' + assert_no_file "$SB/state" 'check mode creates no state directory as root' + assert_eq '0' "$(installer_calls)" 'check mode runs no installer as root' + assert_eq 'fake-install.sh,key.pub,' "$(sandbox_listing)" 'check mode leaves the sandbox untouched as root' + + new_sandbox + write_marker 12010193 + run_bf bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 1 "$RC" 're-identity is refused without the operator gate' + assert_output 'disabled by default' "$OUT" 'the refusal explains that identity change is off by default' + assert_eq '12010193' "$(marker_content)" 'a refused re-identity leaves the marker unchanged' + assert_eq '0' "$(installer_calls)" 'a refused re-identity runs no installer' + assert_no_file "$SB/audit.log" 'a refused re-identity writes no audit record' + + new_sandbox + write_marker 12010193 + run_bf_tty '99999999' BF_ALLOW_REIDENTITY=1 bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 2 "$RC" 'a mismatching confirmation aborts the identity change' + assert_output 'confirmation did not match' "$OUT" 'the aborted identity change says why' + assert_eq '12010193' "$(marker_content)" 'the aborted identity change leaves the marker unchanged' + assert_eq '0' "$(installer_calls)" 'the aborted identity change runs no installer' + + new_sandbox + write_marker 12010193 + printf 'not a directory\n' >"$SB/blocker" + run_bf_tty '55555555' BF_ALLOW_REIDENTITY=1 BF_REIDENTITY_AUDIT_LOG="$SB/blocker/audit.log" bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 1 "$RC" 'an unwritable audit log fails the identity change closed' + assert_output 'audit log is not writable' "$OUT" 'the failure names the unwritable audit log' + assert_eq '12010193' "$(marker_content)" 'a failed audit leaves the marker unchanged' + assert_eq '0' "$(installer_calls)" 'a failed audit runs no installer' + + new_sandbox + write_marker 12010193 + run_bf_tty '55555555' BF_ALLOW_REIDENTITY=1 bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 0 "$RC" 'an admitted identity change completes' + assert_eq '55555555' "$(marker_content)" 'the marker carries the new dealer number' + assert_eq '--offline --dealer-id 55555555 --yes' "$(installer_args)" 'the installer is re-run with the new dealer number' + if compgen -G "$SB/marker.reidentified-*" >/dev/null; then + ok 'the previous identity marker is archived' + else + bad 'the previous identity marker is archived' + fi + assert_output 'AUDIT: identity-change applied' "$OUT" 'the identity change is announced in the run log' + assert_file "$SB/audit.log" 'the identity change writes an audit record' + assert_eq '600' "$(file_mode "$SB/audit.log")" 'the audit record is mode 600' + assert_grep 'identity-change applied old=12010193 new=BF-55555555' "$SB/audit.log" 'the audit record names the old and the new identity' + + new_sandbox + run_bf_tty '12010193 +e' bash "$BF" + assert_rc 0 "$RC" 'the wizard completes the first boot as root' + assert_output '# BLUEFORCE FIELD OS' "$OUT" 'the interactive run shows the banner' + assert_output 'BF-12010193' "$OUT" 'the interactive run shows the Device ID' + assert_eq '12010193' "$(marker_content)" 'the interactive run writes the marker for the confirmed identity' + assert_eq '--offline --dealer-id 12010193 --yes' "$(installer_args)" 'the interactive run calls the installer offline' +} + +if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + # Child mode: only the root-required scenarios run; results go to the parent's file. + cd "$REPO_ROOT" + check_root_paths + exit 0 +fi + +check_sources +check_cli +check_wizard + +section 'root (namespace): install run, marker, key gate, re-identity' +RESULTS_FILE="$WORK/root-results.tsv" +: >"$RESULTS_FILE" +if unshare -r true >/dev/null 2>&1 && [[ "$(unshare -r id -u 2>/dev/null)" == 0 ]]; then + CHILD_LOG="$WORK/child.log" + if unshare -r env BF_TEST_RESULTS="$RESULTS_FILE" bash "$TEST_SCRIPT" >"$CHILD_LOG" 2>&1; then + : + else + printf 'FAIL the privileged (namespace) section did not complete; see the log below\n' >&2 + sed -n '1,20p' "$CHILD_LOG" >&2 + FAILED=$((FAILED + 1)) + fi + if [[ -s "$RESULTS_FILE" ]]; then + while IFS=$'\t' read -r status name; do + case "$status" in + PASS) ok "$name";; + SKIP) skip "$name";; + *) bad "$name";; + esac + done <"$RESULTS_FILE" + else + skip 'the privileged (namespace) section produced no results' + fi +else + skip 'unshare -r is unavailable here: root-only first-boot scenarios were not run' +fi + +printf '\ntest-firstboot-static: %s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED" +if [[ "$FAILED" -ne 0 ]]; then + printf 'test-firstboot-static: FAILED\n' >&2 + exit 1 +fi +printf 'test-firstboot-static: all passed\n'