feat(admin): operator console, git-based install, optional credential delivery

Simplifies the primary path from "build a custom ISO" to "install from git and
run one menu", which is what the operator actually asked for. The ISO tooling
stays in the tree as a secondary/optional route and is not removed.

admin/bf-bootstrap.sh -- one command on Ubuntu:
- clones the repo to /opt/blueforce-linux or fast-forwards an existing checkout
  (a dirty tree is reported and skipped, never reset or stashed)
- installs git/curl/ansible-core, links admin/bf-* and scripts/*/bf-* into
  /usr/local/bin, and finishes with the exact next command
- --check is read-only, works without root, and mutates nothing; the piped form
  (curl ... | sudo -S -p '' bash) is guarded against eating the script

admin/bf-menu -- numbered operator console (categories 1-5):
- 1 installs THIS device locally; 2 and 5 drive the fleet through the existing
  21 Ansible playbooks; 3 prepares and verifies central integration without
  installing a central server; 4 holds local tools
- every operation picks one target: a dealer number or an approved wave group.
  Scope is derived from the playbook file itself, so a playbook whose declared
  scope disagrees with the console's table is refused rather than run
- the command is always shown before it runs; release-gated operations require
  a typed wave token, production requires a second token, and "all" runs
  wave-by-wave, stopping at the first failure
- audit log at /var/log/blueforce-console.log (mode 600, falls back to
  $XDG_STATE_HOME when not writable), recording sanitized target/command/exit
  only: no playbook output, no secrets

admin/bf-creds -- optional credential record, DEFAULT OFF:
- a bare run generates nothing and reports what already exists (device id,
  WireGuard public key, private key path only, RustDesk id)
- --generate writes /root/blueforce-credentials.txt (0600) and --fetch delivers
  it to admin/secrets/<dealer>.txt (0700/0600, git-ignored). The secret never
  reaches stdout, stderr or argv
- this does not change the SSH design: password auth stays off and the
  blueforce account stays locked, per docs/06. It is a local delivery aid only

docs/33-OPERATOR-CONSOLE.md -- step-by-step guide for an operator new to Linux
and DevOps: which command to type where, what the screen will ask, both
end-to-end sample sessions, the five most common errors, and the security rules.

Tests: run-all.sh -> 9/9 pass, exit 0 (adds check-admin-static.sh, 159 checks,
with negative controls and mutation-proven non-vacuity).
This commit is contained in:
emirhanmamak-blueforce 2026-09-16 15:26:17 +03:00
parent ef102b9657
commit f8cdd1ed01
12 changed files with 4870 additions and 5 deletions

5
.gitignore vendored
View file

@ -41,3 +41,8 @@ id_ed25519*
*credentials*
*token*
*.secret
# Credential files delivered by `admin/bf-creds --fetch` (<dealer-number>.txt).
# They live only in the checkout that runs the delivery, are root-owned on the
# device (mode 0600 inside a mode 0700 directory), and are never committed.
admin/secrets/

View file

@ -10,6 +10,32 @@
%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.
**Kurulum git tabanlıdır:** depo klonlanır, `admin/bf-bootstrap.sh` operatör araçlarını kurar ve sahada tek giriş noktası olan `sudo bf-menu` kategori menüsü açılır. ISO/firstboot provisioning yolu (bkz. [`docs/26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md), [`docs/27`](docs/27-OFFLINE-PROVISIONING.md)) **ikincil/opsiyoneldir**, silinmez. Sıfırdan başlayan için adım adım rehber: [`docs/33-OPERATOR-CONSOLE.md`](docs/33-OPERATOR-CONSOLE.md).
## Hızlı Başlangıç (git ile kurulum)
Kurulumun birincil yolu **git deposudur**; ISO/provisioning yolu (aşağıdaki §Kurulum Akışı, [`docs/26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md)) ikincil ve opsiyoneldir. Sahadaki tek giriş noktası `sudo bf-menu` **operatör konsoludur**: kategoriler numarayla seçilir, komut ezberlenmez.
```bash
# 1) Depoyu indir
git clone https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git
cd Blueforce-Linux
# 2) Bootstrap — operatör araçlarını ve bf-* komutlarını kurar
# Cihaz kimliği (bayi no) İSTEMEZ; disk bölmez, veri silmez, onaysız güncelleme yapmaz.
sudo admin/bf-bootstrap.sh
# Depoyu klonlamadan tek satır alternatifi:
# curl -fsSL https://raw.githubusercontent.com/emirhanmamak-blueforce/Blueforce-Linux/main/admin/bf-bootstrap.sh | sudo bash
# 3) Operatör konsolunu aç
sudo bf-menu
```
Menüden **1) Installation → 2) Install this device** seçilir ve 8 haneli bayi no girilir (ör. `12010193` → cihaz `BF-12010193`, hostname `bf-12010193`). Kurulum sonunda cihaz `PROVISIONED_OFFLINE` olur; `READY` yalnız merkez kanalları doğrulanınca alınır.
- Sıfırdan başlayan için adım adım rehber (hangi ekranda ne yazılır): [`docs/33-OPERATOR-CONSOLE.md`](docs/33-OPERATOR-CONSOLE.md)
- Menü ↔ komut eşlemesi ve kategori haritası aynı dokümanın Ek'lerindedir; özet soru-cevap: [`SUMMARY.md`](SUMMARY.md).
## Field OS Nedir?
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:
@ -50,6 +76,8 @@ flowchart TB
## Kurulum Akışı (özet)
> **Birincil yol git'tir.** Cihazı kurmanın standart yolu §[Hızlı Başlangıç](#hızlı-başlangıç-git-ile-kurulum) adımlarıdır (depo + `admin/bf-bootstrap.sh` + `sudo bf-menu`, bkz. [`docs/33`](docs/33-OPERATOR-CONSOLE.md)). Aşağıdaki ISO/firstboot akışı **ikincil/opsiyonel** yoldur; silinmemiştir ve offline/air-gapped saha için korunur.
```mermaid
flowchart LR
ISO["Field OS ISO (tek USB)<br/>veya upstream ISO + seed"] --> FB["firstboot<br/>BLUEFORCE FIELD OS<br/>8 haneli bayi no"]
@ -97,7 +125,7 @@ Boot kabulü (UEFI/Legacy/Secure Boot) ve air-gapped kurulum henüz doğrulanmad
| 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 |
## Doküman Haritası (33 numaralı doküman + şablon)
## Doküman Haritası (34 numaralı doküman + şablon)
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).
@ -136,6 +164,7 @@ Tüm numaralı dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonun
| 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 |
| 33 | `33-OPERATOR-CONSOLE.md` | Operatör konsolu (`bf-menu`) — sıfırdan başlayan için git kurulumu + menü rehberi |
Şablon: `docs/_TEMPLATE.md` (siteye alınmaz). Karar günlüğü: `docs/24-DECISION-LOG.md`. Özet ve soru-cevap: `SUMMARY.md`.
@ -144,9 +173,10 @@ Tüm numaralı dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonun
```
.
├── README.md
├── SUMMARY.md # Yönetici özeti, 37 soru-cevap, FREE bileşen listesi
├── SUMMARY.md # Yönetici özeti, 39 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
├── docs/ # 34 numaralı doküman (00–33) + _TEMPLATE.md
├── admin/ # git'ten tek komut kurulum (bf-bootstrap.sh, bf-creds, bf-menu)
├── 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)
@ -236,4 +266,4 @@ Maliyet kuralı:
| 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.
Repo iskeleti ve uygulama katmanı hazırdır: 34 numaralı doküman (00–33), `blueforce-install.sh` + 18 modül, git tabanlı operatör konsolu (`admin/bf-bootstrap.sh`, `admin/bf-menu`, `admin/bf-creds`), provisioning katmanı (ISO builder, autoinstall, offline-repo sözleşmesi, firstboot, enrollment, release), 21 Ansible playbook + 4 rol, config/monitoring/rustdesk dosyaları ve doğrulama testleri 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.

View file

@ -11,6 +11,8 @@
## 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)
- **Nasıl kurulur?** — Kurulumun birincil yolu **git deposudur**: `git clone https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git` → `cd Blueforce-Linux` → `sudo admin/bf-bootstrap.sh` (depoyu klonlamadan tek satır alternatifi: `curl -fsSL https://raw.githubusercontent.com/emirhanmamak-blueforce/Blueforce-Linux/main/admin/bf-bootstrap.sh | sudo bash`) → `sudo bf-menu`. Bootstrap yalnız operatör araçlarını ve `bf-*` komutlarını kurar; **cihaz kimliği (bayi no) İSTEMEZ**, disk bölmez, veri silmez, onaysız güncelleme yapmaz (idempotent). Kimlik ilk `bf-menu` seansında girilir. ISO/firstboot yolu ([`26`](docs/26-BLUEFORCE-FIELD-OS-ISO.md)/[`27`](docs/27-OFFLINE-PROVISIONING.md)) **ikincil/opsiyoneldir** ve silinmez. Ayrıntılı adım adım rehber: [`33`](docs/33-OPERATOR-CONSOLE.md). → [`05`](docs/05-ONE-CLICK-INSTALLER.md), [`33`](docs/33-OPERATOR-CONSOLE.md) (K-09, K-12, K-20)
- **Menü nasıl kullanılır?** — Sahadaki tek giriş noktası `sudo bf-menu` **operatör konsoludur**: 5 kategori numarayla seçilir, komut ezberlenmez (**1** Installation, **2** Device operations, **3** Central (preparation), **4** Tools, **5** Device information; ana ekranda `q` = çıkış, alt menülerde `0` = geri). Her komut çalıştırılmadan önce ekranda gösterilir ve onay istenir; yıkıcı işlemler iki kez sorar. Kurulumda "**Dealer number (8 digits)**" sorulur (ör. `12010193` → `BF-12010193` / `bf-12010193`); geçersiz format kurulumu başlatmaz (K-12). Filo işlemleri (kategori 2) hedefi **tek cihaz (bayi no) / grup-dalga (lab, pilot_1, pilot_2, wave_1, wave_2, production) / tüm filo** olarak seçtirir; wave-scoped playbook'ta "tüm filo" dalga dalga ilerler ve her dalga ayrı onaylanır, production için `production` yazmak zorunludur (K-11). Onaysız güncelleme seçeneği **yoktur**; `bf-gui-*` yalnız **yerel fiziksel** ekranı açar/kapatır, RDP (xrdp/xrdp-sesman) her zaman hazırdır (K-22). Şifre üretimi opsiyoneldir ve **varsayılan kapalıdır** (`bf-creds --generate`; parola ekrana/loga gitmez). Denetim log'u `/var/log/blueforce-console.log` (mod `600`). → [`33`](docs/33-OPERATOR-CONSOLE.md), [`09`](docs/09-FLEET-MANAGEMENT.md), [`10`](docs/10-UPDATE-AND-ROLLBACK-POLICY.md) (K-11, K-13, K-22)
- **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 <BASE> --checksum-file <SHA256SUMS>` ç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)
@ -22,7 +24,7 @@
- **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)).
Bu 12 soru + öğrenme rehberi sorusu, karar günlüğünde S-28…S-37 olarak izlenir ([Ek B-2](docs/24-DECISION-LOG.md)); bu tura `docs/33` ile **"Nasıl kurulur?"** ve **"Menü nasıl kullanılır?"** soruları eklendi (yukarıdaki ilk bloğun devamı).
---
@ -169,6 +171,7 @@ kaynak URL'ler [`24 Ek A`](docs/24-DECISION-LOG.md).
## Ek: QA Doğrulama (2026-09-16 güncellemesi)
- **33 numaralı doküman mevcut:** `docs/00` … `docs/32` (+ `docs/_TEMPLATE.md`); kanonik set `00`–`31`, `32` denetim/çelişki raporudur.
- **Ek (operatör konsolu):** `docs/33-OPERATOR-CONSOLE.md` eklendi (§1–§13 + Mermaid; git kurulumu + `bf-menu` menü rehberi). Numaralı set artık `docs/00` … `docs/33` (34 dosya).
- **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-<no>`, makine adı `bf-<no>`; aykırı kullanım yok (kalan `BF-xxxx`/`BF-no` ifadeleri şablon yer tutucudur).

315
admin/README.md Normal file
View file

@ -0,0 +1,315 @@
# Blueforce operator console (`bf-menu`)
Numbered terminal console for the Blueforce field fleet. One entry point for the
operator: no command has to be memorised, every command is printed before it
runs, and the dangerous ones ask twice.
Three layers, one screen:
| Layer | What it is | Where |
|---|---|---|
| Local install | This device, brought to the Field OS standard | category **1** |
| Fleet operations | The repository's existing Ansible playbooks, always with an explicit `--limit` | category **2** |
| Read-only information | Device and fleet state, no change anywhere | categories **3, 4, 5** |
The console is a thin front end. It does not reimplement installer logic, does
not invent playbooks, and does not talk to devices itself: it selects the right
`bf-*` tool, playbook and target, then executes exactly that.
---
## 1. Requirements
- Ubuntu LTS field device or admin workstation (the console is plain bash 4+).
- `git` for the checkout; `ansible-core`/`ansible` **only** on the machine that
drives fleet operations (categories 2 and 3). A field device that only installs
itself needs neither Ansible nor an inventory.
- `sudo` for category 1 (local install) and for writing to
`/var/log/blueforce-console.log`.
- Optional, per category: `python3` (reads `state.json`), `wireguard-tools`
(`wg`), `systemd`, `dpkg`.
## 2. Quick start
```bash
git clone <repository-url> Blueforce-Linux
cd Blueforce-Linux
sudo admin/bf-bootstrap.sh # optional: links the tools into /usr/local/bin
sudo admin/bf-menu # or: sudo bf-menu, once bootstrap ran
```
An install is `git`-based on purpose: the running revision is traceable with
`git log`, and the device identity (dealer number) is never baked into an image
— it is typed in the first console session. ISO/firstboot remain a secondary,
optional path (`docs/26`, `docs/27`).
Options:
```
-h, --help usage
-V, --version console version
--repo PATH repository root (default: parent of this script, symlinks resolved)
--inventory PATH Ansible inventory (default: <repo>/ansible/inventory/hosts.yml)
--log-file PATH audit log (default: /var/log/blueforce-console.log)
--dry-run print every command instead of running it
--no-color disable ANSI colors (NO_COLOR is honored too)
--list print the operation catalogue and exit (non-interactive)
--list-targets print inventory groups and host counts, then exit
```
Environment: `BF_REPO`, `BF_INVENTORY`, `BF_CONSOLE_LOG` mirror the options.
Exit status: `0` success or clean quit, `1` failure, `2` usage error.
## 3. Menu map
`0` is **Back** on every screen, `q` is **Quit** on every screen. The console
never changes anything from a menu that only displays information.
**1) Installation — this device, locally**
| # | Action | Under the hood |
|---|---|---|
| 1 | Pre-flight check | `blueforce-install.sh --dealer-id <no> --check` (writes nothing) |
| 2 | Install this device | `--dealer-id <no> [--offline] [--resume]` |
| 3 | Resume a failed run | `--dealer-id <no> --resume` |
| 4 | Run a single module | `--dealer-id <no> --only <NN-name>` |
| 5 | Show module state | `/var/lib/blueforce/install-state` |
| 6 | Show the installer log | `tail -n 40 /var/log/blueforce-install.log` |
Dealer numbers are validated against `^[0-9]{8}$` before anything runs; a wrong
format stops the flow immediately. Actions 2–4 require root and will say so.
**2) Device operations — the fleet, via Ansible**
*2.1 Read-only reports* (11 operations, nothing is changed):
| # | Operation | Playbook |
|---|---|---|
| 1 | Ping / reachability | `bf-ping.yml` |
| 2 | Uptime | `bf-uptime.yml` |
| 3 | Disk usage | `bf-disk-check.yml` |
| 4 | Docker status | `bf-docker-status.yml` |
| 5 | Package versions | `bf-package-check.yml` |
| 6 | Pending security updates | `bf-security-updates-check.yml` |
| 7 | Provisioning phase report | `bf-provisioning-status.yml` |
| 8 | Enrollment + central evidence | `bf-enrollment-status.yml` |
| 9 | Field OS version report | `bf-fieldos-version.yml` |
| 10 | Remote access channels | `bf-remote-channels-check.yml` |
| 11 | Offline readiness report | `bf-offline-ready-check.yml` |
*2.2 Maintenance* (10 operations, destructive ones ask twice):
| # | Operation | Playbook | Scope |
|---|---|---|---|
| 1 | Restart RDP | `bf-rdp-restart.yml` | simple |
| 2 | Restart RustDesk | `bf-rustdesk-restart.yml` | simple |
| 3 | Restart WireGuard | `bf-wireguard-restart.yml` | simple |
| 4 | Restart an allowlisted service | `bf-service-restart.yml` | simple |
| 5 | GUI off (headless) | `bf-gui-off.yml` | wave |
| 6 | GUI on (maintenance) | `bf-gui-on.yml` | wave |
| 7 | Collect logs / support bundle | `bf-collect-logs.yml` | wave |
| 8 | Run an approved script | `bf-run-script.yml` | wave |
| 9 | Controlled reboot | `bf-reboot.yml` | release |
| 10 | Deploy approved update | `bf-deploy-update.yml` | release |
*2.3 Show inventory targets* lists the waves with their device counts.
The console cross-checks its own table against the playbook file before running
(see §5); a playbook whose gates changed refuses to run rather than run ungated.
**3) Central (preparation)** — verifies and prepares central integration; it
installs no central component. Central readiness report (inventory, placeholders
in `group_vars/all.yml`, admin key path), configured endpoints, a read-only
DNS/TCP probe of those endpoints, the per-wave gate state, and the preparation
checklist.
**4) Tools** — `git pull --ff-only` of the checkout, environment/reference info,
the operation catalogue, the local gates (`bf-check-local`, `bf-check-enrollment`,
`bf-check-ready`), `bf-diagnostics`, `bf-live-hw-check`, `bf-remote-status --check`,
`bf-release`, `bf-hardware-inventory.sh`, `bf-support-bundle`, and the console's
own log.
**5) Device information** — identity (hostname, `BF-<no>`, dealer number),
`bf-status`, provisioning/enrollment state, network (addresses, routes, DNS,
WireGuard **peer counts and handshake ages**), disk/memory/CPU, installed pinned
packages, service status, recent journal errors, plus the fleet inventory summary
and a "resolve a target" helper that shows the exact `--limit` for copy/paste.
## 4. Target selection
Every device operation asks for a target first — never a default:
```
Target selection (mode: any|wave|release)
1) Single device by dealer number -> bf-<8 digits>
2) Group / wave -> lab, pilot_1, pilot_2, wave_1, wave_2, production
3) All devices in the fleet -> --limit all
9) Type the target directly -> dealer number, group, hostname or 'all'
0) Back q) Quit
```
Rules the console enforces:
- **Single device** — the dealer number must match `^[0-9]{8}$` and the device
must exist in the inventory. The wave is derived from the inventory, so a
wave-scoped playbook gets `-e operation_wave=<its wave>`. A device outside
every wave cannot be used for a wave-scoped operation.
- **Group / wave** — the group must exist in the inventory. For wave-scoped and
release-gated operations only the six approved waves are offered.
- **All devices** — allowed for simple and wave-scoped playbooks. It shows how
many devices are affected and requires typing `all` before anything runs. For
wave-scoped playbooks the fleet is processed **one wave at a time** in
`lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production`, each wave shown and
confirmed on its own, stopping at the first failure so no later wave is touched.
- **Release-gated playbooks** (`bf-reboot.yml`, `bf-deploy-update.yml`) accept an
approved wave group only: no single device, no fleet-wide run. That is the
playbooks' own gate (docs/10) and the console does not work around it.
## 5. What gets added to a command
The console derives the gate variables from the playbook file itself (its
non-comment lines), so the table in the menu can never silently drop a gate:
| Scope | Detected by | Variables added |
|---|---|---|
| `simple` | neither wave variable | only `--limit` |
| `wave` | uses `operation_wave` | `-e operation_wave=<wave> -e operation_confirmed=true`, plus `-e production_override=true` for the production wave |
| `release` | uses `release_wave` | `-e release_wave=<wave>`, `-e reboot_confirmed=true` / `-e wave_gate_confirmed=true` when the playbook declares them, plus the production override for the production wave |
Confirmations, in order:
1. the target and the full command are printed;
2. for `release` scope, the **wave gate** must be typed as `GATE` (the console
never sets `wave_gate_confirmed=true` on its own — only after the previous
wave's health gate and central approval, docs/10);
3. for the production wave, the word `production` must be typed;
4. for fleet-wide scope, the word `all` must be typed;
5. `y` runs, `c` runs with `--check` (dry run of the playbook), anything else cancels.
Extra variables are validated with the same rules the playbooks enforce, so the
operator gets a clear message instead of an Ansible assertion failure: package
pins must be `package=version`; service names must be in the playbook allowlist;
script paths must be inside `/opt/blueforce/bin` or `/usr/local/sbin`; the reboot
grace period must be a whole number of seconds.
## 6. Dry runs
- `--dry-run` prints every command and executes nothing.
- Answering `c` at the run prompt executes the command with `--check` appended,
which is a real Ansible dry run.
- Category 1's pre-flight already is the installer's own `--check`.
## 7. Audit log
Default `/var/log/blueforce-console.log`, created with mode **600** (it is
re-`chmod`ed to 600 if it already exists). If it is not writable, the console
warns and falls back to `$XDG_STATE_HOME/blueforce/blueforce-console.log`; it
never logs into a world-readable file.
Each run records one line per event: session start, target, the exact command,
and the exit status. It does **not** store raw playbook output: tool output may
contain whatever a device script prints, and copying it into a log is how a
secret ends up on disk. Output is shown on screen and stays there.
Before anything reaches the log it passes a single sanitizer that redacts
`password=`, `token=`, `secret=`, `api_key=`, `private_key=`, `passphrase=`,
`psk=` and `BEGIN … PRIVATE KEY` payloads. The console itself never asks for a
secret: no password, token or private key is typed into or stored by it.
## 8. Security notes
- **No secret material is read, printed or logged.** The console never runs bare
`wg show` (private and preshared keys stay off the screen) — only the listen
port, peer count and handshake ages.
- **`state.json` is read through a whitelist** of non-secret keys (`phase`,
`enrollment_status`, `fleet_status`, `device_id`, `provisioning_id`). A future
token field cannot leak into the UI or the log.
- **No ungated update.** There is no `apt upgrade`, no `latest` pull and no
free-form command anywhere in the menu; updates go through the release-gated
playbook or not at all (docs/10).
- **Destructive actions ask twice**: a warning plus an explicit `y`, and the
highest-risk scopes need a typed word (`GATE`, `production`, `all`, or the wave
name in a fleet-wide loop).
- **Fail-closed on drift**: if the playbook's declared gates do not match the
console's expectations, the console refuses to run that playbook at all.
- **Root is used where the underlying tool needs it** (local installer, support
bundle). Read-only information screens are careful about what they read.
- The console **never edits the inventory**. Adding a device stays a reviewed
edit of `ansible/inventory/hosts.yml`:
```yaml
pilot_1:
hosts:
bf-12010193:
ansible_host: 10.42.1.20
```
## 9. Deliberately out of scope
- **No distro/ISO build.** ISO and firstboot remain a secondary path (`docs/26`,
`docs/27`); the primary install path is `git clone` + install. Category 4 does
not offer `provisioning/iso/build-blueforce-iso.sh`.
- **No password generation.** Credential files are the separate, optional
`admin/bf-creds` tool and default to **off**; the console does not call it.
- **No central server installation.** Category 3 verifies and prepares;
installing the hub, monitoring or the token service is infrastructure work.
- **No new playbooks, no free-form commands.** If an operation is not one of the
21 existing playbooks, the console does not fake it.
## 10. Troubleshooting
| Symptom | Why | What to do |
|---|---|---|
| `ansible-playbook was not found on this machine` | categories 2/3 need Ansible on the driving machine | `sudo apt install ansible` (or `pipx install ansible-core`), or run the command from another admin host |
| `No Ansible inventory found under …` | no `ansible/inventory/hosts.yml` yet | `cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml`, then fill in real `bf-<no>` hosts |
| Only `hosts.example.yml` exists | it holds example IDs and is never used automatically | copy it and replace the example devices |
| `Invalid dealer number '<x>'` | must be exactly 8 digits | type the number as printed on the device record, leading zeros included |
| `bf-<no> is not in the inventory` | the device is not registered | add it to the right wave group (§8), then retry |
| `… is not an approved wave` | a wave-scoped or release-gated operation got a non-wave group | pick one of `lab, pilot_1, pilot_2, wave_1, wave_2, production` |
| `Refusing to run <playbook>: the console expects scope …` | the playbook's gates changed since the console table was written | update `admin/bf-menu` before using that playbook |
| `This operation needs root on THIS device` | local install runs as root | `sudo bf-menu` |
| Log warning about `/var/log/blueforce-console.log` | not writable, another admin owns it, or the mode is wrong | run with `sudo`, or pass `--log-file <path>` |
## 11. Files
| File | Role |
|---|---|
| `admin/bf-menu` | entry point: menu screens, operation catalogue, command building, confirmations, audit log |
| `admin/lib/tui.sh` | presentation and input helpers (screens, items, prompts, confirmations, messages) |
| `admin/lib/targets.sh` | device identity, inventory parsing, target selection, `--limit` construction |
Library contract: `bf-menu` sets `BF_REPO`/`BF_INVENTORY` and sources both
libraries; `targets.sh` fills `BF_TARGET_KIND`, `BF_TARGET_LIMIT`,
`BF_TARGET_WAVE`, `BF_TARGET_COUNT`, `BF_TARGET_LABEL` and never renders UI or
executes anything; `tui.sh` never exits the process except in `tui_die`.
Both libraries are safe to source on their own, which is how the target logic and
the inventory parser can be tested without a terminal.
## 12. Verifying a change
```bash
bash -n admin/bf-menu admin/lib/tui.sh admin/lib/targets.sh
shellcheck -x admin/bf-menu admin/lib/tui.sh admin/lib/targets.sh
admin/bf-menu --list # catalogue, no inventory needed
admin/bf-menu --list-targets # groups, hosts and counts
```
`--list` and `--list-targets` are the non-interactive entry points: they exercise
the catalogue and the inventory parser without touching a device.
The console was checked on an admin workstation with a stub `ansible-playbook`
that echoes its arguments, covering: every scope/target combination and the exact
`-e` variables produced, the refusal paths (single device for release scope,
fleet-wide release run, wrong wave for a wave-scoped run, bad dealer number,
unknown device, bad package pin, service outside the allowlist, script outside
the approved directories, production/gate/fleet-wide words withheld), `--dry-run`
executing nothing, `--check` being appended, the missing-Ansible and
missing-inventory explanations, the log's 600 mode and its redaction of a test
secret, and clean EOF behaviour on a closed stdin. Playbook coverage was
cross-checked programmatically: all 21 playbooks in `ansible/playbooks/` appear
exactly once and every declared scope matches the playbook's own gate variables.
Real devices have not been touched yet — the LAB run is what turns these
expectations into evidence.

346
admin/bf-bootstrap.sh Executable file
View file

@ -0,0 +1,346 @@
#!/usr/bin/env bash
#
# bf-bootstrap.sh — one-command install of the Blueforce Linux admin toolbox.
#
# Documented entry points:
# curl -fsSL https://raw.githubusercontent.com/emirhanmamak-blueforce/Blueforce-Linux/main/admin/bf-bootstrap.sh | sudo bash
# sudo admin/bf-bootstrap.sh # from a checkout
#
# Steps, in order:
# 1. root check (apply mode only; --check is read-only and needs no root),
# 2. Ubuntu release check against the supported baseline (24.04 / 26.04),
# 3. dependency install: git, curl, ansible-core,
# 4. clone the repository into <dir> (default /opt/blueforce-linux) or
# fast-forward an existing checkout (never resets, never discards work),
# 5. link admin/bf-*, scripts/diagnostics/bf-* and scripts/maintenance/bf-*
# into /usr/local/bin (idempotent; a re-run refreshes the links),
# 6. make admin/bf-menu executable so `sudo bf-menu` starts the on-device
# installer menu,
# 7. print a clear summary.
#
# Safety
# * This script writes no secret and installs code only: credentials stay on
# the device and are never fetched by the installer (see admin/bf-creds).
# * Re-running it is safe: the checkout is fast-forwarded and the tool links
# are re-created. Nothing is deleted and no `git reset` is ever performed,
# so local work in the checkout is never discarded.
# * --check reports the same plan without touching the system.
#
set -euo pipefail
REPO_URL="${BF_REPO_URL:-https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git}"
BRANCH_DEFAULT="main"
INSTALL_DIR_DEFAULT="/opt/blueforce-linux"
BIN_DIR="${BF_BIN_DIR:-/usr/local/bin}"
SUPPORTED_UBUNTU=("24.04" "26.04")
# Command name / apt package name pairs for the installer prerequisites.
DEPENDENCY_COMMANDS=(git curl ansible-playbook)
DEPENDENCY_PACKAGES=(git curl ansible-core)
CHECK_MODE=0
ASSUME_YES=0
BRANCH="$BRANCH_DEFAULT"
DIR=""
LINKED=0
MENU_READY=0
usage() {
cat <<'EOF'
Usage: sudo bf-bootstrap.sh [--check] [--dir DIR] [--branch BRANCH] [--yes] [--help]
Install (or refresh) the Blueforce Linux admin toolbox from git: fetch the
repository, install the prerequisites, link the bf-* tools into
/usr/local/bin, and make `sudo bf-menu` available for the on-device setup.
Options:
--check Read-only: report what would happen. It never mutates
anything and never requires root.
--dir DIR Checkout directory. Default: the checkout this script lives
in, otherwise /opt/blueforce-linux.
--branch BRANCH Branch to track (default: main).
--yes, -y Do not ask for confirmation.
--help, -h Show this help and exit.
Environment:
BF_REPO_URL Repository URL (default: the upstream GitHub repository).
BF_INSTALL_DIR Checkout directory; same as --dir.
BF_BIN_DIR Tool link directory (default: /usr/local/bin).
Notes:
* Installer prerequisites are git, curl and ansible-core (ansible-playbook).
* Supported baseline is Ubuntu 24.04 / 26.04; any other release only warns.
* This script installs code only. It never writes, fetches, or prints a
secret; device credentials are handled by admin/bf-creds.
EOF
}
say() { printf 'bf-bootstrap: %s\n' "$*"; }
warn() { printf 'bf-bootstrap: WARN: %s\n' "$*" >&2; }
die() { printf 'bf-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; }
# run: executes a mutating command, or only reports it under --check.
run() {
if [[ "$CHECK_MODE" -eq 1 ]]; then
printf 'bf-bootstrap: check: would run: %s\n' "$*"
return 0
fi
"$@"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_MODE=1; shift ;;
--dir) DIR="${2:?missing value for --dir}"; shift 2 ;;
--branch) BRANCH="${2:?missing value for --branch}"; shift 2 ;;
--yes|-y) ASSUME_YES=1; shift ;;
--help|-h) usage; exit 0 ;;
*) printf 'bf-bootstrap: ERROR: unknown argument: %s\n\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
# --------------------------------------------------------------------------- #
# Where does this script live? When it runs from a checkout, that checkout is
# the default target; the piped one-command form has no checkout, so the
# default install directory is used.
# --------------------------------------------------------------------------- #
SCRIPT_SOURCE="${BASH_SOURCE[0]:-}"
if [[ -n "$SCRIPT_SOURCE" && -r "$SCRIPT_SOURCE" ]]; then
SCRIPT_REAL="$(readlink -f "$SCRIPT_SOURCE" 2>/dev/null || printf '%s' "$SCRIPT_SOURCE")"
CANDIDATE="$(cd "$(dirname "$SCRIPT_REAL")/.." 2>/dev/null && pwd || true)"
if [[ -n "$CANDIDATE" && -f "$CANDIDATE/admin/bf-bootstrap.sh" && -d "$CANDIDATE/scripts/diagnostics" ]]; then
CHECKOUT_ROOT="$CANDIDATE"
fi
fi
if [[ -z "$DIR" ]]; then
DIR="${BF_INSTALL_DIR:-${CHECKOUT_ROOT:-$INSTALL_DIR_DEFAULT}}"
fi
if [[ -z "$BRANCH" ]]; then
die 'branch must not be empty'
fi
# --------------------------------------------------------------------------- #
# 1. Root check. --check only reports, so it works as an unprivileged user.
# --------------------------------------------------------------------------- #
if [[ "$CHECK_MODE" -eq 1 ]]; then
if [[ "$(id -u)" -ne 0 ]]; then
say 'check: not running as root; apply mode will require sudo'
fi
else
if [[ "$(id -u)" -ne 0 ]]; then
die "must run as root: sudo $0 (or: sudo bf-bootstrap.sh)"
fi
fi
# --------------------------------------------------------------------------- #
# 2. Ubuntu release check. A different release is only a warning: the goal is
# to install the toolbox, not to block a working host.
# --------------------------------------------------------------------------- #
DISTRO_ID=""
DISTRO_VERSION=""
DISTRO_PRETTY="unknown"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
DISTRO_ID="${ID:-}"
DISTRO_VERSION="${VERSION_ID:-}"
DISTRO_PRETTY="${PRETTY_NAME:-unknown}"
fi
if [[ "$DISTRO_ID" == "ubuntu" ]]; then
SUPPORTED=0
for release in "${SUPPORTED_UBUNTU[@]}"; do
if [[ "$DISTRO_VERSION" == "$release" ]]; then
SUPPORTED=1
fi
done
if [[ "$SUPPORTED" -eq 1 ]]; then
say "ubuntu ${DISTRO_VERSION} is a supported baseline"
else
warn "Ubuntu ${DISTRO_VERSION:-unknown} is outside the tested baseline (${SUPPORTED_UBUNTU[*]}); continuing"
fi
else
warn "this is not an Ubuntu host (ID=${DISTRO_ID:-unknown}, ${DISTRO_PRETTY}); the Field OS baseline is Ubuntu ${SUPPORTED_UBUNTU[*]}"
fi
# --------------------------------------------------------------------------- #
# Operator confirmation. Only a terminal prompts: the documented one-command
# form (`curl ... | sudo bash`) has no terminal on stdin and never blocks.
# --------------------------------------------------------------------------- #
if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 && -t 0 ]]; then
printf 'Install or update the Blueforce toolbox in %s? [y/N] ' "$DIR"
ANSWER=""
read -r ANSWER || ANSWER=""
if [[ ! "$ANSWER" =~ ^[Yy]([Ee][Ss])?$ ]]; then
say 'aborted by the operator'
exit 2
fi
fi
# --------------------------------------------------------------------------- #
# 3. Dependencies.
# --------------------------------------------------------------------------- #
MISSING_COMMANDS=()
MISSING_PACKAGES=()
say 'dependency check'
for index in "${!DEPENDENCY_COMMANDS[@]}"; do
if ! command -v "${DEPENDENCY_COMMANDS[$index]}" >/dev/null 2>&1; then
MISSING_COMMANDS+=("${DEPENDENCY_COMMANDS[$index]}")
MISSING_PACKAGES+=("${DEPENDENCY_PACKAGES[$index]}")
fi
done
if [[ ${#MISSING_PACKAGES[@]} -eq 0 ]]; then
say "dependencies present: ${DEPENDENCY_COMMANDS[*]}"
elif [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would install with apt-get: ${MISSING_PACKAGES[*]} (missing: ${MISSING_COMMANDS[*]})"
else
if ! command -v apt-get >/dev/null 2>&1; then
die "apt-get not found; install these packages first: ${MISSING_PACKAGES[*]}"
fi
say "installing dependencies: ${MISSING_PACKAGES[*]}"
export DEBIAN_FRONTEND=noninteractive
run apt-get update
run apt-get install -y --no-install-recommends "${MISSING_PACKAGES[@]}"
fi
# --------------------------------------------------------------------------- #
# 4. Repository: clone once, then fast-forward only.
# --------------------------------------------------------------------------- #
update_checkout() {
local remote="" origin_url="" dirty=""
if ! git -C "$DIR" rev-parse --verify --quiet HEAD >/dev/null 2>&1; then
warn "checkout has no commits yet: $DIR; skipping the update"
return 0
fi
# Prefer `origin`, otherwise accept the checkout's single remote (a local
# clone of this project may name it `github`, as the upstream checkout does).
if git -C "$DIR" remote get-url origin >/dev/null 2>&1; then
remote="origin"
else
remote="$(git -C "$DIR" remote 2>/dev/null | head -n 1 || true)"
fi
if [[ -z "$remote" ]]; then
warn "checkout has no git remote: $DIR; skipping the update"
return 0
fi
origin_url="$(git -C "$DIR" remote get-url "$remote" 2>/dev/null || true)"
if [[ "$origin_url" != *"Blueforce-Linux"* ]]; then
warn "remote '$remote' looks unexpected: $origin_url"
fi
dirty="$(git -C "$DIR" status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
warn "local changes present in $DIR; skipping the update (nothing was reset or stashed)"
return 0
fi
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would fast-forward $DIR to $remote/$BRANCH"
return 0
fi
say "updating $DIR (fast-forward only, remote $remote)"
if ! git -C "$DIR" fetch --prune --quiet "$remote" "$BRANCH"; then
warn "fetch failed (offline?); keeping the current checkout"
return 0
fi
if ! git -C "$DIR" merge --ff-only --quiet "$remote/$BRANCH"; then
warn "cannot fast-forward to $remote/$BRANCH; keeping the current checkout (no reset performed)"
fi
return 0
}
if [[ -e "$DIR" && ! -d "$DIR" ]]; then
die "install directory exists and is not a directory: $DIR"
fi
if [[ -d "$DIR" ]]; then
if [[ -d "$DIR/.git" ]]; then
say "existing checkout: $DIR"
update_checkout
else
die "directory exists but is not a git checkout; refusing to touch it: $DIR"
fi
elif [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would clone $REPO_URL (branch $BRANCH) into $DIR"
else
say "cloning $REPO_URL (branch $BRANCH) into $DIR"
run install -d -m 0755 "$(dirname "$DIR")"
run git clone --branch "$BRANCH" --single-branch -- "$REPO_URL" "$DIR"
fi
# --------------------------------------------------------------------------- #
# 5. Link the tools into /usr/local/bin. Idempotent: `ln -sfn` refreshes an
# existing link, and a re-run after `git pull` needs no extra step.
# --------------------------------------------------------------------------- #
link_tools() {
local source="" base=""
for source in "$DIR"/admin/bf-* "$DIR"/scripts/diagnostics/bf-* "$DIR"/scripts/maintenance/bf-*; do
if [[ ! -f "$source" ]]; then
continue
fi
base="$(basename "$source")"
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would link $source -> $BIN_DIR/$base"
else
chmod 0755 "$source"
ln -sfn "$source" "$BIN_DIR/$base"
fi
LINKED=$((LINKED + 1))
done
}
if [[ -d "$DIR" ]]; then
run install -d -m 0755 "$BIN_DIR"
link_tools
if [[ "$LINKED" -eq 0 ]]; then
warn "no bf-* tools found under $DIR (admin/, scripts/diagnostics/, scripts/maintenance/)"
elif [[ "$CHECK_MODE" -eq 0 ]]; then
say "linked $LINKED tools into $BIN_DIR"
fi
elif [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would link the bf-* tools from $DIR into $BIN_DIR"
fi
# --------------------------------------------------------------------------- #
# 6. bf-menu: the on-device installer menu entry point.
# --------------------------------------------------------------------------- #
MENU_SOURCE="$DIR/admin/bf-menu"
if [[ -f "$MENU_SOURCE" ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would make $MENU_SOURCE executable and link $BIN_DIR/bf-menu"
else
chmod 0755 "$MENU_SOURCE"
ln -sfn "$MENU_SOURCE" "$BIN_DIR/bf-menu"
say "bf-menu ready: $BIN_DIR/bf-menu"
fi
MENU_READY=1
else
warn "admin/bf-menu is not present in $DIR; 'sudo bf-menu' will work once the menu module is in the checkout"
fi
# --------------------------------------------------------------------------- #
# 7. Summary.
# --------------------------------------------------------------------------- #
if [[ "$CHECK_MODE" -eq 1 ]]; then
printf '\nbf-bootstrap: check complete -- no changes were made.\n'
printf ' checkout : %s\n' "$DIR"
printf ' branch : %s\n' "$BRANCH"
printf ' tools : %s tool(s) would be linked into %s\n' "$LINKED" "$BIN_DIR"
printf ' next : sudo bf-bootstrap.sh --yes\n'
exit 0
fi
REVISION=""
if [[ -d "$DIR/.git" ]]; then
REVISION="$(git -C "$DIR" rev-parse --short HEAD 2>/dev/null || true)"
fi
printf '\nbf-bootstrap: summary\n'
printf ' repository : %s\n' "$DIR"
printf ' branch : %s\n' "$BRANCH"
if [[ -n "$REVISION" ]]; then
printf ' revision : %s\n' "$REVISION"
fi
printf ' tools : %s linked into %s\n' "$LINKED" "$BIN_DIR"
if [[ "$MENU_READY" -eq 1 ]]; then
printf ' bf-menu : %s\n' "$BIN_DIR/bf-menu"
else
printf ' bf-menu : pending (admin/bf-menu is not in the checkout yet)\n'
fi
printf ' next : sudo bf-menu\n'
printf '\nInstallation complete. To get started: sudo bf-menu\n'
printf 'Kurulum tamam. Başlamak için: sudo bf-menu\n'

236
admin/bf-creds Executable file
View file

@ -0,0 +1,236 @@
#!/usr/bin/env bash
#
# bf-creds — optional, default-OFF credential generation and local delivery.
#
# Scope (deliberate, decided with the operator)
# * DEFAULT BEHAVIOUR: nothing is generated. Unless `--generate` (or
# BF_GENERATE_CREDENTIALS=1) is given, no password is created and the
# command says so explicitly.
# * With `--generate`: a device credential record is written to
# /root/blueforce-credentials.txt with mode 0600. It carries the device
# identity (BF-<no>), the hostname, the creation time, the WireGuard
# PUBLIC key plus the private key PATH (never its contents), the RustDesk
# ID when the installer recorded one, and a freshly generated local
# administrator password.
# * With `--fetch`: the record is copied to
# <repo>/admin/secrets/<dealer-number>.txt (directory 0700, file 0600).
# That path is git-ignored; it must never be committed.
# * Secrets are written to files only. No password is ever printed to
# stdout/stderr, written to a log, or passed through argv — so it cannot
# land in the shell history or the process list.
#
# What this tool does NOT do
# Remote access stays exactly as the installer left it: OpenSSH password
# authentication stays disabled (`PasswordAuthentication no`,
# docs/06-USERS-SSH-AND-PERMISSIONS.md, docs/11-SECURITY-HARDENING.md) and
# the `blueforce` maintenance account stays locked. bf-creds never edits
# sshd_config, never unlocks an account, and never enables a password login
# path; it is only an optional *local* delivery helper. See README.md
# ("admin/" section).
#
# Usage: sudo bf-creds [--generate] [--fetch] [--repo DIR] [--dealer-id N] [--help]
#
set -euo pipefail
SELF="${BASH_SOURCE[0]:-}"
SELF_REAL="$(readlink -f "$SELF" 2>/dev/null || printf '%s' "$SELF")"
SCRIPT_DIR="$(cd "$(dirname "$SELF_REAL")" && pwd)"
# shellcheck source=lib/creds.sh
source "$SCRIPT_DIR/lib/creds.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
GENERATE=0
FETCH=0
REPO_OVERRIDE=""
DEALER_OVERRIDE=""
usage() {
cat <<'EOF'
Usage: sudo bf-creds [--generate] [--fetch] [--repo DIR] [--dealer-id NNNNNNNN] [--help]
Report the device identity and the secrets that already exist on this host, and
optionally generate a local credential record and deliver it into the repository
checkout.
Options:
--generate Create the credential record (default path:
/root/blueforce-credentials.txt, mode 0600). Without this
flag NO password is generated and the command says so.
--fetch Copy the credential record to <repo>/admin/secrets/<dealer>.txt
(directory 0700, file 0600, git-ignored).
--repo DIR Repository root used as the --fetch destination
(default: the checkout this tool lives in).
--dealer-id N Eight-digit dealer number. Default: detected from the device
identity (installer state file, then the hostname).
--help, -h Show this help and exit.
Environment:
BF_GENERATE_CREDENTIALS=1 Same as --generate (still opt-in; the default is off).
BF_CREDENTIALS_FILE=PATH Override the credential record path.
BF_WG_DIR=PATH Override the WireGuard directory.
BF_STATE_FILE=PATH Override the installer state file.
BF_RUSTDESK_ID_FILE=PATH Override the recorded RustDesk ID file.
Guarantees:
* The generated password is written to the record file only. It is never
printed, never logged, and never placed on a command line.
* The WireGuard private key is never read: only its path is reported.
* Remote access is not changed: SSH password authentication stays disabled
and the `blueforce` account stays locked.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--generate) GENERATE=1; shift ;;
--fetch) FETCH=1; shift ;;
--repo) REPO_OVERRIDE="${2:?missing value for --repo}"; shift 2 ;;
--dealer-id) DEALER_OVERRIDE="${2:?missing value for --dealer-id}"; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) printf 'ERROR: unknown argument: %s\n\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ "${BF_GENERATE_CREDENTIALS:-0}" == "1" ]]; then
GENERATE=1
fi
if [[ -n "$DEALER_OVERRIDE" ]] && [[ ! "$DEALER_OVERRIDE" =~ ^[0-9]{8}$ ]]; then
printf 'ERROR: --dealer-id must be exactly 8 digits\n' >&2
exit 2
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
if [[ ! -d "$REPO_OVERRIDE" ]]; then
printf 'ERROR: --repo must name an existing directory: %s\n' "$REPO_OVERRIDE" >&2
exit 2
fi
REPO_ROOT="$(cd "$REPO_OVERRIDE" && pwd)"
fi
resolve_dealer_number() {
local id=""
if [[ -n "$DEALER_OVERRIDE" ]]; then
printf '%s' "$DEALER_OVERRIDE"
return 0
fi
id="$(bf_detect_device_id || true)"
bf_dealer_number "$id"
}
print_report() {
local id="" dealer="" host="" wg_pub="" wg_path="" rustdesk=""
id="$(bf_detect_device_id || true)"
dealer="$(resolve_dealer_number || true)"
host="$(hostname 2>/dev/null || echo unknown)"
printf 'device\n'
printf ' device_id : %s\n' "${id:-unknown}"
printf ' dealer_number : %s\n' "${dealer:-unknown}"
printf ' hostname : %s\n' "$host"
printf 'wireguard\n'
if wg_pub="$(bf_wireguard_public_key)"; then
printf ' public_key : %s\n' "$wg_pub"
else
printf ' public_key : not present\n'
fi
if wg_path="$(bf_wireguard_private_key_path)"; then
printf ' private_key_path : %s (contents are never read or recorded)\n' "$wg_path"
else
printf ' private_key_path : not present\n'
fi
printf 'rustdesk\n'
if rustdesk="$(bf_rustdesk_id)"; then
printf ' id : %s\n' "$rustdesk"
else
printf ' id : not present\n'
fi
printf 'credential record\n'
printf ' path : %s\n' "$BF_CREDENTIALS_FILE"
if [[ -f "$BF_CREDENTIALS_FILE" ]]; then
printf ' present : yes (mode %s)\n' "$(bf_file_mode "$BF_CREDENTIALS_FILE")"
if bf_has_generated_password "$BF_CREDENTIALS_FILE"; then
printf ' administrator password recorded : yes (the value lives only in the file)\n'
else
printf ' administrator password recorded : no\n'
fi
else
printf ' present : no\n'
fi
}
# credentials_body <timestamp> <password> (stdout = the record file body)
# The only caller pipes this straight into bf_write_secret_file; the body is
# never printed anywhere else.
credentials_body() {
local ts="$1" password="$2"
local id="" dealer="" host="" wg_pub="" wg_path="" rustdesk=""
id="$(bf_detect_device_id || true)"
dealer="$(resolve_dealer_number || true)"
host="$(hostname 2>/dev/null || echo unknown)"
wg_pub="$(bf_wireguard_public_key || true)"
wg_path="$(bf_wireguard_private_key_path || true)"
rustdesk="$(bf_rustdesk_id || true)"
cat <<EOF
# BLUEFORCE DEVICE CREDENTIALS
#
# Generated by bf-creds on the device. Root-owned, mode 0600.
# Delivery target (bf-creds --fetch): <checkout>/${BF_SECRETS_SUBDIR}/<dealer-number>.txt
# That path is git-ignored and must never be committed.
#
# Remote access is NOT changed by this tool: OpenSSH password authentication
# stays disabled (PasswordAuthentication no) and the blueforce maintenance
# account stays locked. See docs/06-USERS-SSH-AND-PERMISSIONS.md.
device_id: ${id:-unknown}
dealer_number: ${dealer:-unknown}
hostname: ${host}
generated_at: ${ts}
generator: bf-creds
[wireguard]
public_key: ${wg_pub:-not-present}
private_key_path: ${wg_path:-not-present}
private_key: NOT RECORDED (the WireGuard private key never leaves the device)
[rustdesk]
id: ${rustdesk:-not-present}
[generated]
local_administrator_password: ${password}
note: recorded for delivery only; bf-creds does not apply this password to any
account, does not edit sshd_config, and does not open a password login path.
Applying it stays a deliberate operator action on the device console.
EOF
}
print_report
if [[ "$GENERATE" -eq 1 ]]; then
generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)"
password="$(bf_generate_secret)" || bf_die 'cannot generate a random secret (openssl and /dev/urandom are both unavailable)'
if ! credentials_body "$generated_at" "$password" | bf_write_secret_file "$BF_CREDENTIALS_FILE"; then
bf_die "could not write the credential record: $BF_CREDENTIALS_FILE"
fi
unset password
printf '\ncredential record written : %s (mode 600)\n' "$BF_CREDENTIALS_FILE"
printf ' the generated local administrator password exists in that file only;\n'
printf ' bf-creds does not apply it to an account, does not enable SSH password\n'
printf ' login, and leaves the blueforce account locked.\n'
else
printf '\ncredential generation : DISABLED (this is the default)\n'
printf ' no password was generated. Re-run with --generate (or BF_GENERATE_CREDENTIALS=1)\n'
printf ' to create %s.\n' "$BF_CREDENTIALS_FILE"
fi
if [[ "$FETCH" -eq 1 ]]; then
if [[ ! -s "$BF_CREDENTIALS_FILE" ]]; then
bf_die "--fetch needs an existing credential record: $BF_CREDENTIALS_FILE (run --generate first)"
fi
dealer="$(resolve_dealer_number || true)"
if [[ -z "$dealer" ]]; then
bf_die 'cannot determine the dealer number; pass --dealer-id <8 digits> (fail-closed: the delivery file name depends on it)'
fi
destination="$(bf_secrets_target_path "$REPO_ROOT" "$dealer")" || bf_die 'cannot build the delivery path'
if ! bf_deliver_secret "$BF_CREDENTIALS_FILE" "$destination"; then
bf_die "could not deliver the credential record to: $destination"
fi
printf '\ncredential record delivered : %s (mode 600, directory 700, git-ignored)\n' "$destination"
fi

1626
admin/bf-menu Executable file

File diff suppressed because it is too large Load diff

249
admin/lib/creds.sh Executable file
View file

@ -0,0 +1,249 @@
#!/usr/bin/env bash
#
# creds.sh — shared helpers for the Blueforce credential tooling (admin/bf-creds).
#
# Contract
# * No helper here ever writes a secret to stdout, stderr, or a log file.
# Secrets travel through files only: root-owned, mode 0600, inside a mode
# 0700 directory. Callers capture generated values with `$( ... )` (which
# never prints) and must never echo, printf, export, or log them.
# * Lookup helpers are read-only. Only the `bf_write_secret_file` /
# `bf_deliver_secret` helpers touch the filesystem, and only at the target
# the caller names explicitly.
# * A WireGuard *private key is never read* by this library: only its path is
# ever reported. The same rule applies to every other private key material.
# * This library does not change the remote-access decision recorded in
# docs/06-USERS-SSH-AND-PERMISSIONS.md: OpenSSH password authentication
# stays disabled (`PasswordAuthentication no`) and the `blueforce`
# maintenance account stays locked. bf-creds is an optional *local*
# delivery helper: it never edits sshd_config, never unlocks an account,
# and never opens a password login path. See the header of admin/bf-creds
# and the "admin/" section of README.md.
#
# Usage: sourced by admin/bf-creds. Not meant to be executed on its own.
#
set -euo pipefail
# Executing the library directly would silently do nothing useful; say so.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
printf 'creds.sh is a library: source it (see admin/bf-creds).\n' >&2
exit 2
fi
if [[ -n "${BF_CREDS_LIB_LOADED:-}" ]]; then
return 0
fi
BF_CREDS_LIB_LOADED=1
# --------------------------------------------------------------------------- #
# Locations. Every path is overridable so tests can point the helpers at a
# sandbox and so a site can relocate the credential record deliberately.
# --------------------------------------------------------------------------- #
: "${BF_STATE_FILE:=/var/lib/blueforce/state.json}"
: "${BF_WG_DIR:=/etc/wireguard}"
: "${BF_RUSTDESK_ID_FILE:=/var/lib/blueforce/rustdesk-id}"
: "${BF_CREDENTIALS_FILE:=/root/blueforce-credentials.txt}"
: "${BF_SECRETS_SUBDIR:=admin/secrets}"
: "${BF_CREDENTIALS_MODE:=600}"
: "${BF_SECRETS_DIR_MODE:=700}"
# --------------------------------------------------------------------------- #
# Logging. Messages are always descriptions, never values.
# --------------------------------------------------------------------------- #
bf_log() { printf '[bf-creds] %s\n' "$*" >&2; }
bf_warn() { printf '[bf-creds] WARN: %s\n' "$*" >&2; }
bf_die() { printf '[bf-creds] ERROR: %s\n' "$*" >&2; exit 1; }
# --------------------------------------------------------------------------- #
# Read-only identity helpers.
# --------------------------------------------------------------------------- #
# bf_detect_device_id -> prints BF-<8 digits> (BF_DEVICE, then the installer
# state file, then the hostname); prints nothing and returns 1 when unknown.
bf_detect_device_id() {
local id=""
if [[ "${BF_DEVICE:-}" =~ ^BF-[0-9]{8}$ ]]; then
printf '%s' "$BF_DEVICE"
return 0
fi
if [[ -r "$BF_STATE_FILE" ]] && grep -q 'device_id' "$BF_STATE_FILE" 2>/dev/null; then
id="$(grep -oE 'BF-[0-9]{8}' "$BF_STATE_FILE" 2>/dev/null | head -n 1 || true)"
fi
if [[ -z "$id" ]]; then
id="$(hostname 2>/dev/null | tr '[:lower:]' '[:upper:]' || true)"
fi
if [[ "$id" =~ ^BF-[0-9]{8}$ ]]; then
printf '%s' "$id"
return 0
fi
return 1
}
# bf_dealer_number <device-id> -> prints the 8-digit dealer number, else nothing.
bf_dealer_number() {
local id="${1:-}"
if [[ "$id" =~ ^BF-([0-9]{8})$ ]]; then
printf '%s' "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
# bf_wireguard_public_key -> prints the public key (safe to display); nothing
# and return 1 when the installer has not created a keypair yet.
bf_wireguard_public_key() {
local f="$BF_WG_DIR/publickey" key=""
if [[ -s "$f" ]]; then
key="$(tr -d '[:space:]' < "$f" 2>/dev/null || true)"
fi
if [[ -n "$key" ]]; then
printf '%s' "$key"
return 0
fi
return 1
}
# bf_wireguard_private_key_path -> prints the PATH of the private key only.
# The contents are never opened: only the file's existence is checked.
bf_wireguard_private_key_path() {
local f="$BF_WG_DIR/privatekey"
if [[ -e "$f" ]]; then
printf '%s' "$f"
return 0
fi
return 1
}
# bf_rustdesk_id -> prints the RustDesk peer ID recorded by module 09-rustdesk.
bf_rustdesk_id() {
local f="$BF_RUSTDESK_ID_FILE" id=""
if [[ -s "$f" ]]; then
id="$(tr -d '[:space:]' < "$f" 2>/dev/null || true)"
fi
if [[ "$id" =~ ^[0-9]+$ ]]; then
printf '%s' "$id"
return 0
fi
return 1
}
# --------------------------------------------------------------------------- #
# Secret generation and file handling.
# --------------------------------------------------------------------------- #
# bf_generate_secret -> prints a fresh random secret on stdout (openssl, then
# /dev/urandom as a fallback). Capturing the result with `$( ... )` never
# prints it; the value must reach a mode-0600 file and nothing else. It is
# deliberately never passed through argv or a command line.
bf_generate_secret() {
local secret=""
if command -v openssl >/dev/null 2>&1; then
secret="$(openssl rand -base64 24 2>/dev/null | tr -d '\n' || true)"
fi
if [[ -z "$secret" ]] && [[ -r /dev/urandom ]] && command -v base64 >/dev/null 2>&1; then
secret="$(head -c 32 /dev/urandom 2>/dev/null | base64 | tr -d '\n' || true)"
fi
if [[ -z "$secret" ]]; then
return 1
fi
printf '%s' "$secret"
}
# bf_file_mode <path> -> prints the octal mode (Linux stat).
bf_file_mode() { stat -c '%a' "$1" 2>/dev/null || true; }
# bf_has_generated_password <file> -> 0 when the record carries a password line.
bf_has_generated_password() {
local f="${1:-}"
if [[ -s "$f" ]] && grep -qE '^local_administrator_password:[[:space:]]*[^[:space:]]+' "$f"; then
return 0
fi
return 1
}
# bf_write_secret_file <dest> (stdin: entire file body)
# Writes stdin to <dest> atomically, forces mode 0600 on the file and 0700 on
# the parent directory, and verifies the result. Never echoes the body.
bf_write_secret_file() {
local dest="${1:-}" tmp=""
if [[ -z "$dest" ]]; then
bf_warn 'bf_write_secret_file: destination required'
return 1
fi
if [[ -L "$dest" ]]; then
bf_warn "refusing to write through a symlink: $dest"
return 1
fi
tmp="$(mktemp "${TMPDIR:-/tmp}/bf-creds.XXXXXX" 2>/dev/null || true)"
if [[ -z "$tmp" ]]; then
bf_warn 'cannot create a temporary staging file'
return 1
fi
chmod 600 "$tmp" || { rm -f "$tmp"; return 1; }
if ! cat > "$tmp"; then
rm -f "$tmp"
bf_warn 'cannot stage the credential body'
return 1
fi
if ! install -d -m "$BF_SECRETS_DIR_MODE" -- "$(dirname "$dest")"; then
rm -f "$tmp"
bf_warn "cannot create the credential directory: $(dirname "$dest")"
return 1
fi
if ! install -m "$BF_CREDENTIALS_MODE" -- "$tmp" "$dest"; then
rm -f "$tmp"
bf_warn "cannot write the credential file: $dest"
return 1
fi
rm -f "$tmp"
if [[ "$(bf_file_mode "$dest")" != "$BF_CREDENTIALS_MODE" ]]; then
bf_warn "credential file mode is not $BF_CREDENTIALS_MODE: $dest"
return 1
fi
return 0
}
# --------------------------------------------------------------------------- #
# Delivery.
# --------------------------------------------------------------------------- #
# bf_secrets_target_path <repo-root> <dealer-number> -> destination path.
bf_secrets_target_path() {
local repo_root="${1:-}" dealer="${2:-}"
if [[ -z "$repo_root" || -z "$dealer" ]]; then
return 1
fi
printf '%s/%s/%s.txt' "$repo_root" "$BF_SECRETS_SUBDIR" "$dealer"
}
# bf_deliver_secret <src> <dest>
# Copies a credential file to the delivery target: directory mode 0700, file
# mode 0600. Prints nothing; the caller reports the path only.
bf_deliver_secret() {
local src="${1:-}" dest="${2:-}"
if [[ ! -s "$src" ]]; then
bf_warn "credential file is missing or empty: $src"
return 1
fi
if [[ -z "$dest" ]]; then
bf_warn 'bf_deliver_secret: destination required'
return 1
fi
if [[ -L "$dest" ]]; then
bf_warn "refusing to write through a symlink: $dest"
return 1
fi
if ! install -d -m "$BF_SECRETS_DIR_MODE" -- "$(dirname "$dest")"; then
bf_warn "cannot create the delivery directory: $(dirname "$dest")"
return 1
fi
if ! install -m "$BF_CREDENTIALS_MODE" -- "$src" "$dest"; then
bf_warn "cannot deliver the credential file to: $dest"
return 1
fi
if [[ "$(bf_file_mode "$dest")" != "$BF_CREDENTIALS_MODE" ]]; then
bf_warn "delivered file mode is not $BF_CREDENTIALS_MODE: $dest"
return 1
fi
return 0
}

476
admin/lib/targets.sh Normal file
View file

@ -0,0 +1,476 @@
#!/usr/bin/env bash
# admin/lib/targets.sh -- device identity, inventory parsing and target selection.
#
# Sourced by admin/bf-menu. Contains no UI rendering and no execution: it answers
# "which hosts does this operation apply to, and which Ansible --limit does that
# become".
#
# Contracts it enforces (sources of truth in parentheses):
# * dealer number matches ^[0-9]{8}$ exactly (docs/02)
# * machine hostname is bf-<dealer>, human id is BF-<dealer> (docs/02)
# * waves are lab(2) -> pilot_1(5) -> pilot_2(20) -> wave_1(50) -> wave_2(100)
# -> production, in that order (docs/10)
# * the inventory is the Ansible inventory; no device is ever addressed that is
# not in it (docs/09)
#
# Inputs (set by bf-menu before calling):
# BF_REPO repository root
# BF_INVENTORY resolved inventory path, or empty to trigger auto-discovery
#
# shellcheck shell=bash
# Canonical wave order. Kept in one place so the menu, the gate checks and the
# fleet-wide loop can never disagree.
BF_WAVES=(lab pilot_1 pilot_2 wave_1 wave_2 production)
# ---------------------------------------------------------------------------
# Identity helpers
# ---------------------------------------------------------------------------
# BF_DEALER_RE -- the single validation rule for dealer numbers.
BF_DEALER_RE='^[0-9]{8}$'
bf_dealer_validate() {
[[ "$1" =~ $BF_DEALER_RE ]]
}
bf_dealer_to_host() {
printf 'bf-%s\n' "$1"
}
bf_dealer_to_device_id() {
printf 'BF-%s\n' "$1"
}
# bf_host_to_dealer HOST -> dealer number, empty when the name is not bf-<8 digits>.
bf_host_to_dealer() {
local host="$1"
if [[ "$host" =~ ^bf-([0-9]{8})$ ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
# bf_is_wave NAME -- true for the six approved wave groups (docs/10).
bf_is_wave() {
local name="$1" wave
for wave in "${BF_WAVES[@]}"; do
[[ "$name" == "$wave" ]] && return 0
done
return 1
}
bf_waves_csv() {
local IFS=,
printf '%s\n' "${BF_WAVES[*]}"
}
# ---------------------------------------------------------------------------
# Inventory discovery and parsing
# ---------------------------------------------------------------------------
# bf_inventory_candidates -- every path we are willing to read, best first.
# hosts.example.yml is listed last on purpose: it is a template with example
# device IDs, so it is only usable after an explicit acknowledgement.
bf_inventory_candidates() {
local dir="${BF_REPO:-.}/ansible/inventory"
if [[ -n "${BF_INVENTORY:-}" ]]; then
printf '%s\n' "$BF_INVENTORY"
return 0
fi
printf '%s\n' \
"$dir/hosts.yml" \
"$dir/hosts.yaml" \
"$dir/hosts" \
"$dir/hosts.ini"
}
# bf_inventory_resolve -- pick the inventory file. Prints the path on success.
# Prints a remediation message to stderr and returns 1 when nothing is usable.
bf_inventory_resolve() {
local candidate example="${BF_REPO:-.}/ansible/inventory/hosts.example.yml"
if [[ -n "${BF_INVENTORY:-}" ]]; then
if [[ -r "$BF_INVENTORY" ]]; then
printf '%s\n' "$BF_INVENTORY"
return 0
fi
tui_error "Inventory not readable: $BF_INVENTORY"
tui_note "Pass a real path with --inventory PATH or unset it to auto-discover." >&2
return 1
fi
while IFS= read -r candidate; do
[[ -n "$candidate" ]] || continue
if [[ -r "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(bf_inventory_candidates)
tui_error "No Ansible inventory found under ${BF_REPO:-.}/ansible/inventory/."
# These remediation lines go to stderr on purpose: the resolver's stdout is
# captured as the resolved path by the caller.
if [[ -r "$example" ]]; then
tui_note "Only the example inventory exists: $example" >&2
tui_note "It carries example device IDs (bf-1201xxxx) and must not be used against real devices." >&2
tui_note "Create the real inventory first:" >&2
tui_note " cp $example ${BF_REPO:-.}/ansible/inventory/hosts.yml" >&2
tui_note " \$EDITOR ${BF_REPO:-.}/ansible/inventory/hosts.yml" >&2
else
tui_note "Create ansible/inventory/hosts.yml with your bf-<8-digit dealer> hosts (docs/02, docs/09)." >&2
fi
return 1
}
# bf_inventory_pairs FILE -- "group<TAB>host" for every host in the inventory.
# A deliberately small YAML reader: the inventory is a nested map of
# all.children.<group>.children.<wave>.hosts.<hostname>. It tracks indentation
# instead of pulling in a YAML parser (none is guaranteed on a field console).
bf_inventory_pairs() {
local inv="$1"
[[ -r "$inv" ]] || return 1
awk '
function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s }
function unquote(s) { gsub(/^["\x27]|["\x27]$/, "", s); return s }
{
line = $0
if (line ~ /^[[:space:]]*#/) next
if (line ~ /^[[:space:]]*$/) next
if (line ~ /^[[:space:]]*-/) next
if (line !~ /:/) next
match(line, /^ */); ind = RLENGTH
key = line; sub(/^[[:space:]]*/, "", key); sub(/:.*$/, "", key)
key = unquote(trim(key))
val = line; sub(/^[^:]*:/, "", val); val = trim(val)
if (key == "") next
while (depth > 0 && sind[depth] >= ind) depth--
# A "hosts:" block owns the nearest enclosing group key.
if (key == "hosts" && val == "") {
h_active = 1; h_ind = ind; h_step = 0
h_grp = (depth > 0) ? skey[depth] : ""
skey[++depth] = key; sind[depth] = ind
next
}
# Anything at or above the "hosts:" indentation ends that block.
if (h_active && ind <= h_ind) h_active = 0
if (h_active && ind > h_ind) {
if (h_step == 0) h_step = ind
if (ind == h_step) {
print h_grp "\t" key
skey[++depth] = key; sind[depth] = ind
next
}
}
if (val == "") { skey[++depth] = key; sind[depth] = ind }
}
' "$inv"
}
# Reads the inventory and caches the pairs; repeated calls are cheap and the
# cache is invalidated by BF_INVENTORY_CACHE_FILE going away.
bf_inventory_cache() {
local inv="${BF_INVENTORY:-}"
[[ -n "$inv" ]] || return 1
if [[ -z "${BF_INVENTORY_CACHE_FILE:-}" ]]; then
BF_INVENTORY_CACHE_FILE="$(mktemp "${TMPDIR:-/tmp}/bf-inventory.XXXXXX")"
export BF_INVENTORY_CACHE_FILE
fi
if [[ ! -s "$BF_INVENTORY_CACHE_FILE" ]]; then
bf_inventory_pairs "$inv" >"$BF_INVENTORY_CACHE_FILE" || return 1
fi
return 0
}
bf_inventory_groups() {
bf_inventory_cache || return 1
awk -F '\t' 'NF == 2 && !seen[$1]++ { print $1 }' "$BF_INVENTORY_CACHE_FILE" | grep -v '^$' || true
}
bf_inventory_hosts() {
bf_inventory_cache || return 1
awk -F '\t' 'NF == 2 && !seen[$2]++ { print $2 }' "$BF_INVENTORY_CACHE_FILE" | grep -v '^$' || true
}
bf_inventory_hosts_of_group() {
local group="$1"
bf_inventory_cache || return 1
awk -F '\t' -v g="$group" '$1 == g { print $2 }' "$BF_INVENTORY_CACHE_FILE"
}
# bf_count_lines -- wc -l without the set -e trap of grep -c returning 1 on zero.
bf_count_lines() {
local n
n="$(printf '%s\n' "$1" | grep -c . || true)"
printf '%s\n' "${n:-0}"
}
bf_inventory_group_exists() {
local group="$1"
bf_inventory_cache || return 1
awk -F '\t' -v g="$group" '$1 == g { found = 1; exit } END { exit !found }' "$BF_INVENTORY_CACHE_FILE"
}
bf_inventory_host_exists() {
local host="$1"
bf_inventory_cache || return 1
awk -F '\t' -v h="$host" '$2 == h { found = 1; exit } END { exit !found }' "$BF_INVENTORY_CACHE_FILE"
}
bf_inventory_count_all() {
bf_count_lines "$(bf_inventory_hosts || true)"
}
# bf_inventory_host_wave HOST -- the approved wave that contains HOST.
# Prints nothing and returns 1 when the device sits outside every wave group.
bf_inventory_host_wave() {
local host="$1" wave
bf_inventory_cache || return 1
for wave in "${BF_WAVES[@]}"; do
bf_inventory_group_exists "$wave" || continue
if bf_inventory_hosts_of_group "$wave" | grep -qx -- "$host"; then
printf '%s\n' "$wave"
return 0
fi
done
return 1
}
# bf_wave_groups_present -- canonical waves that actually exist in the inventory,
# always in rollout order.
bf_wave_groups_present() {
local wave
for wave in "${BF_WAVES[@]}"; do
if bf_inventory_group_exists "$wave"; then
printf '%s\n' "$wave"
fi
done
}
# ---------------------------------------------------------------------------
# Target selection
# ---------------------------------------------------------------------------
bf_target_reset() {
BF_TARGET_KIND=""
BF_TARGET_LIMIT=""
BF_TARGET_WAVE=""
BF_TARGET_LABEL=""
BF_TARGET_COUNT=0
}
bf_target_is_set() {
[[ -n "${BF_TARGET_KIND:-}" && -n "${BF_TARGET_LIMIT:-}" ]]
}
# bf_target_set_host DEALER [MODE]
# MODE: any|wave|release. Returns 1 (after explaining) when the mode forbids a
# single-device selection or the device is unknown to the inventory.
bf_target_set_host() {
local dealer="$1" mode="${2:-any}" host wave
if ! bf_dealer_validate "$dealer"; then
tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE} (8 digits, no spaces)."
return 1
fi
if [[ "$mode" == "release" ]]; then
tui_error "Release-gated operations are wave-scoped (docs/10): select a wave group, not a single device."
return 1
fi
host="$(bf_dealer_to_host "$dealer")"
if ! bf_inventory_host_exists "$host"; then
tui_error "$host is not in the inventory (${BF_INVENTORY:-unresolved})."
tui_note "Add the device to its wave group first, then retry (see admin/README.md, docs/02)."
return 1
fi
wave="$(bf_inventory_host_wave "$host" || true)"
if [[ -z "$wave" && "$mode" != "any" ]]; then
tui_error "$host is not inside an approved wave group ($(bf_waves_csv))."
tui_note "Wave-scoped playbooks refuse a device that no wave covers (fail-closed)."
return 1
fi
BF_TARGET_KIND="host"
BF_TARGET_LIMIT="$host"
BF_TARGET_WAVE="$wave"
BF_TARGET_COUNT=1
if [[ -n "$wave" ]]; then
BF_TARGET_LABEL="device $(bf_dealer_to_device_id "$dealer") ($host) in wave '$wave'"
else
BF_TARGET_LABEL="device $(bf_dealer_to_device_id "$dealer") ($host)"
fi
return 0
}
# bf_target_set_group GROUP [MODE]
bf_target_set_group() {
local group="$1" mode="${2:-any}" count
if [[ "$group" == "all" ]]; then
bf_target_set_all "$mode"
return $?
fi
if ! bf_inventory_group_exists "$group"; then
tui_error "Group '$group' is not in the inventory (${BF_INVENTORY:-unresolved})."
tui_note "Known groups: $(bf_inventory_groups | tr '\n' ' ')"
return 1
fi
if [[ "$mode" == "wave" || "$mode" == "release" ]] && ! bf_is_wave "$group"; then
tui_error "Group '$group' is not an approved wave; wave-scoped operations need one of: $(bf_waves_csv)."
return 1
fi
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
if [[ "$count" -eq 0 ]]; then
tui_error "Group '$group' contains no hosts; refusing an empty target."
return 1
fi
BF_TARGET_KIND="group"
BF_TARGET_LIMIT="$group"
BF_TARGET_WAVE="$group"
# shellcheck disable=SC2034 # read by bf-menu (library contract)
BF_TARGET_COUNT="$count"
if bf_is_wave "$group"; then
BF_TARGET_LABEL="wave '$group' ($count device(s))"
else
BF_TARGET_LABEL="group '$group' ($count device(s))"
fi
return 0
}
# bf_target_set_all MODE -- the whole fleet.
# For release-gated operations this is refused: the update/reboot playbooks are
# gated wave by wave and a fleet-wide limit would defeat the gate (docs/10).
bf_target_set_all() {
local mode="${1:-any}" count
if [[ "$mode" == "release" ]]; then
tui_error "Fleet-wide scope is refused for release-gated operations: roll out one wave at a time (docs/10)."
return 1
fi
count="$(bf_inventory_count_all)"
if [[ "$count" -eq 0 ]]; then
tui_error "The inventory lists no hosts; refusing an empty target."
return 1
fi
BF_TARGET_KIND="all"
BF_TARGET_LIMIT="all"
BF_TARGET_WAVE=""
# shellcheck disable=SC2034 # read by bf-menu (library contract)
BF_TARGET_COUNT="$count"
BF_TARGET_LABEL="all devices in the fleet ($count device(s))"
return 0
}
# bf_target_parse WORD MODE -- accept what the operator typed in one prompt:
# an 8-digit dealer number, a group/wave name, or "all".
bf_target_parse() {
local word="$1" mode="${2:-any}"
bf_target_reset
if [[ -z "$word" ]]; then
tui_error "No target given."
return 1
fi
if [[ "$word" == "all" ]]; then
bf_target_set_all "$mode"
return $?
fi
if bf_dealer_validate "$word"; then
bf_target_set_host "$word" "$mode"
return $?
fi
if bf_inventory_group_exists "$word"; then
bf_target_set_group "$word" "$mode"
return $?
fi
# A bf-<dealer> hostname is accepted as a convenience.
if [[ "$word" =~ ^bf-[0-9]{8}$ ]]; then
if bf_dealer_validate "${word#bf-}"; then
bf_target_set_host "${word#bf-}" "$mode"
return $?
fi
fi
tui_error "'$word' is not a dealer number (${BF_DEALER_RE}), a group, or 'all'."
tui_note "Known groups: $(bf_inventory_groups | tr '\n' ' ')"
return 1
}
# bf_target_describe -- one line for the confirmation screen.
bf_target_describe() {
printf '%s --limit %s%s\n' "${BF_TARGET_LABEL:-unset}" "${BF_TARGET_LIMIT:-unset}" \
"$([[ -n "${BF_TARGET_WAVE:-}" ]] && printf ' (wave %s)' "$BF_TARGET_WAVE")"
}
# bf_target_select MODE
# Interactive target picker. MODE:
# any single device, any group, or the whole fleet
# wave single device, approved wave group, or the whole fleet wave by wave
# release approved wave group only
# On success the BF_TARGET_* globals describe the selection.
bf_target_select() {
local mode="${1:-any}" choice word count index group_list=()
bf_target_reset
while true; do
tui_section "Target selection (mode: $mode)"
tui_item 1 "Single device by dealer number" "8 digits, e.g. 12010101"
tui_item 2 "Group / wave" "lab, pilot_1, pilot_2, wave_1, wave_2, production"
if [[ "$mode" == "release" ]]; then
tui_note " all) not offered: release-gated operations run one wave at a time"
else
tui_item 3 "All devices in the fleet" "wave-scoped playbooks ask per wave"
fi
tui_item 9 "Type the target directly" "dealer number, group or 'all'"
tui_footer "Back to the operation"
printf '\n'
if ! tui_read "Target > "; then return 1; fi
choice="$TUI_REPLY"
case "$choice" in
""|0|q|Q) return 1 ;;
1)
tui_read "Dealer number (8 digits) > " || return 1
word="$TUI_REPLY"
bf_target_set_host "$word" "$mode" && return 0
;;
2)
tui_section "Groups in the inventory"
group_list=()
while IFS= read -r group; do
[[ -n "$group" ]] || continue
group_list+=("$group")
done < <(if [[ "$mode" == "any" ]]; then bf_inventory_groups || true; else bf_wave_groups_present || true; fi)
if [[ ${#group_list[@]} -eq 0 ]]; then
tui_error "No usable group in the inventory (${BF_INVENTORY:-unresolved})."
return 1
fi
index=0
for group in "${group_list[@]}"; do
index=$((index + 1))
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
if bf_is_wave "$group"; then
tui_item "$index" "$group" "$count device(s), approved wave"
else
tui_item "$index" "$group" "$count device(s)"
fi
done
printf '\n'
tui_note "Approved waves: $(bf_waves_csv) -- source: $(basename "${BF_INVENTORY:-unresolved}")"
if ! tui_read "Group number > "; then return 1; fi
choice="$TUI_REPLY"
if ! tui_menu_index "$choice" "$index"; then
tui_error "Enter a number between 1 and $index."
continue
fi
bf_target_set_group "${group_list[$((choice - 1))]}" "$mode" && return 0
;;
3)
if [[ "$mode" == "release" ]]; then
tui_error "Fleet-wide scope is refused here."
continue
fi
bf_target_set_all "$mode" && return 0
;;
9)
tui_read "Target (dealer number, group or all) > " || return 1
bf_target_parse "$TUI_REPLY" "$mode" && return 0
;;
*) tui_error "Unknown choice '$choice'." ;;
esac
printf '\n'
tui_pause
done
}

216
admin/lib/tui.sh Normal file
View file

@ -0,0 +1,216 @@
#!/usr/bin/env bash
# admin/lib/tui.sh -- terminal UI helpers for the Blueforce operator console.
#
# Sourced by admin/bf-menu. This file contains presentation and input helpers
# only: no fleet logic, no inventory parsing, and no side effects when sourced.
#
# Contract:
# * user-facing output goes to stdout, diagnostics/warnings to stderr,
# * no helper calls exit except tui_die (and it only does so when called),
# * no helper ever prints a secret, and nothing here writes to the log,
# * every helper tolerates `set -u`: optional arguments use ${x:-} defaults.
#
# shellcheck shell=bash
# ---------------------------------------------------------------------------
# Colors. Disabled automatically when stdout is not a terminal, when NO_COLOR
# is set, or when the caller asks for "never".
# ---------------------------------------------------------------------------
TUI_COLOR_MODE="${TUI_COLOR_MODE:-auto}"
# shellcheck disable=SC2034 # color variables are consumed by bf-menu
tui_init_colors() {
local want="${1:-$TUI_COLOR_MODE}"
local enable=0
case "$want" in
always) enable=1 ;;
never) enable=0 ;;
*)
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
enable=1
fi
;;
esac
if [[ "$enable" -eq 1 ]]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
C_MAGENTA=$'\033[35m'
C_CYAN=$'\033[36m'
else
C_RESET="" C_BOLD="" C_DIM="" C_RED="" C_GREEN="" C_YELLOW=""
C_BLUE="" C_MAGENTA="" C_CYAN=""
fi
export C_RESET C_BOLD C_DIM C_RED C_GREEN C_YELLOW C_BLUE C_MAGENTA C_CYAN
}
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
TUI_WIDTH="${TUI_WIDTH:-78}"
tui_hr() {
local char="${1:--}"
local line
line="$(printf "%*s" "$TUI_WIDTH" "" | tr ' ' "$char")"
printf '%s%s%s\n' "$C_DIM" "$line" "$C_RESET"
}
# tui_banner TITLE [SUBTITLE]
tui_banner() {
local title="$1" subtitle="${2:-}"
printf '\n'
tui_hr '='
printf ' %s%s%s\n' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
if [[ -n "$subtitle" ]]; then
printf ' %s%s%s\n' "$C_DIM" "$subtitle" "$C_RESET"
fi
tui_hr '='
printf '\n'
}
# tui_screen TITLE [SUBTITLE]
# Clears the terminal first when interactive so one screen fits one page; the
# clear is skipped for non-interactive runs (pipes, tests, logs).
tui_screen() {
if [[ -t 1 && "${TUI_CLEAR:-yes}" == "yes" ]]; then
printf '\033[H\033[2J'
fi
tui_banner "${1:-}" "${2:-}"
}
# tui_item NUMBER LABEL [DESCRIPTION]
tui_item() {
local number="$1" label="$2" desc="${3:-}"
if [[ -n "$desc" ]]; then
printf ' %s%2s%s) %-46s %s%s%s\n' "$C_BOLD" "$number" "$C_RESET" "$label" "$C_DIM" "$desc" "$C_RESET"
else
printf ' %s%2s%s) %s\n' "$C_BOLD" "$number" "$C_RESET" "$label"
fi
}
# tui_section LABEL -- visual separator inside a long menu.
tui_section() {
printf '\n %s%s%s\n' "$C_MAGENTA" "${1:-}" "$C_RESET"
}
# tui_footer [BACK_LABEL] -- every screen ends with back and quit.
tui_footer() {
local back="${1:-Back}"
printf '\n'
tui_hr '-'
printf ' %s%2s%s) %-46s %s%2s%s) %s\n' \
"$C_BOLD" "0" "$C_RESET" "$back" "$C_BOLD" "q" "$C_RESET" "Quit the console"
tui_hr '-'
}
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
# tui_read PROMPT -> TUI_REPLY
# Returns 0 on a line, 1 on EOF (stdin closed / non-interactive); on EOF the
# reply is "q" so the caller's menu loop quits instead of spinning forever.
tui_read() {
local prompt="${1:-} "
TUI_REPLY=""
printf '%s' "$prompt"
if ! IFS= read -r TUI_REPLY; then
TUI_REPLY="q"
printf '\n'
return 1
fi
# Trim surrounding whitespace so " 2 " is accepted like "2".
TUI_REPLY="${TUI_REPLY#"${TUI_REPLY%%[![:space:]]*}"}"
TUI_REPLY="${TUI_REPLY%"${TUI_REPLY##*[![:space:]]}"}"
return 0
}
# tui_prompt LABEL [DEFAULT] -> TUI_REPLY
tui_prompt() {
local label="$1" default="${2:-}" suffix=""
if [[ -n "$default" ]]; then
suffix=" [$default]"
fi
tui_read "${label}${suffix}: " || true
if [[ -z "$TUI_REPLY" ]]; then
TUI_REPLY="$default"
fi
}
# tui_confirm QUESTION [yes|no] -- default is NO: only an explicit y/yes proceeds.
tui_confirm() {
local question="$1" default="${2:-no}" suffix="[y/N]"
[[ "$default" == "yes" ]] && suffix="[Y/n]"
tui_read "${question} ${suffix} " || true
case "${TUI_REPLY,,}" in
y|yes) return 0 ;;
n|no) return 1 ;;
"")
[[ "$default" == "yes" ]] && return 0
return 1
;;
*) return 1 ;;
esac
}
# tui_confirm_token QUESTION TOKEN -- the operator must type the exact token.
# Used for the highest-risk operations (production waves, fleet-wide runs).
tui_confirm_token() {
local question="$1" token="$2"
tui_warn "$question"
tui_read "$(printf 'Type %s%s%s to confirm (anything else cancels): ' "$C_BOLD" "$token" "$C_RESET")" || true
[[ "$TUI_REPLY" == "$token" ]]
}
# tui_ask_run QUESTION -> 0 = run, 2 = run with --check, 1 = cancel
tui_ask_run() {
local question="$1"
tui_read "${question} [y=run / c=run with --check / N=cancel] " || true
case "${TUI_REPLY,,}" in
y|yes) return 0 ;;
c|check) return 2 ;;
*) return 1 ;;
esac
}
# tui_menu_index NUMBER MAX -- true when NUMBER selects an item of the menu.
tui_menu_index() {
local number="$1" max="$2"
[[ "$number" =~ ^[0-9]+$ ]] || return 1
((number >= 1 && number <= max)) || return 1
return 0
}
tui_pause() {
local message="${1:-Press Enter to continue}"
if [[ -t 0 ]]; then
tui_read "${C_DIM}${message}${C_RESET}" || true
fi
}
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
tui_msg() { printf '%s\n' "$*"; }
tui_info() { printf '%s[i]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; }
tui_ok() { printf '%s[OK]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
tui_warn() { printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
tui_error(){ printf '%s[ERROR]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
tui_note() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
# tui_kv KEY VALUE -- aligned key/value line for information screens.
tui_kv() {
printf ' %-24s %s\n' "$1" "$2"
}
# tui_die MESSAGE [EXIT_CODE] -- last resort: report and exit with a non-zero
# status. Everything user-recoverable should return a status instead.
tui_die() {
local message="$1" code="${2:-1}"
tui_error "$message"
exit "$code"
}

611
docs/33-OPERATOR-CONSOLE.md Normal file
View file

@ -0,0 +1,611 @@
# 33 — Operatör Konsolu Rehberi (`bf-menu`)
> Kısa özet: Bu rehber, Linux/DevOps'a yeni başlayan bir operatörün Blueforce cihazını **git deposundan** kurmasını ve tek giriş noktası olan `sudo bf-menu` **kategori menüsü** ile cihazı işletmesini adım adım anlatır. Kurulum, filo işlemleri, merkez hazırlığı, araçlar ve cihaz bilgisi ekranlarının tamamı bu menüden açılır; komut ezberlemek gerekmez.
- Dosya: `docs/33-OPERATOR-CONSOLE.md`
- İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI)
- Uygulama: `admin/bf-menu` (+ `admin/lib/tui.sh`, `admin/lib/targets.sh`), `admin/bf-bootstrap.sh`, `admin/bf-creds`
- Durum: [ ] Taslak — ekran çıktıları **örnektir (taklit)** ve LAB'da alınan gerçek çıktıyla güncellenir
> **Dürüstlük notu (bu reponun kuralı):** Aşağıdaki menü ekranları ve çıktılar, tasarımı anlatmak için yazılmış **örnek ekranlardır**; terminal genişliğine, renklerin kapalı olmasına ve envanter içeriğine göre birebir farklı görünebilir. Gerçek çıktılar ilk LAB koşusunda bu dosyaya işlenir (§10, §13).
---
## 1. Amaç
**Bu araç nedir?** `bf-menu`, cihaza `sudo bf-menu` yazınca açılan, numaralarla gezilen bir **operatör konsoludur**. Ekranda 5 kategori çıkar (Kurulum, Cihaz işlemleri, Merkez hazırlığı, Araçlar, Cihaz bilgisi); bir numara yazıp Enter'a basarsın, konsol senin adına doğru komutu çalıştırır ve ne yaptığını Türkçe/İngilizce satırlarla gösterir.
**Neyi kolaylaştırır?** Normalde cihazı kurmak, durumunu okumak, teşhis paketi almak veya bir filo işlemi başlatmak için onlarca komutu ve doğru sırasını bilmek gerekir. `bf-menu` bunları numaralı başlıklar altında toplar, **çalıştırmadan önce komutu ekranda gösterip onay ister**, tehlikeli işlemlerde iki kez sorar ve her işlemi bir denetim log'una (`/var/log/blueforce-console.log`, mod `600`) yazar. Kısacası: ezberleyeceğin tek şey `sudo bf-menu` komutudur.
## 2. Kapsam
- Kapsam içi: Ubuntu'da ilk kurulum (git'ten indir + bootstrap), ana menü ve 5 kategori, tek cihaz/grup/tüm filo hedef seçimi, uçtan uca örnek seans, şifre dosyası (`bf-creds`) davranışı, en sık 5 hata ve güvenlik kuralları.
- Kapsam dışı: ISO/firstboot/offline provisioning yolu (26, 27 — artık **ikincil/opsiyonel**), merkez sunucularının kurulumu (07, 09 — konsol yalnız **hazırlar ve denetler**, kurmaz), update onay zincirinin merkez tarafı (10, 21), rol bazlı eğitim müfredatı (31).
## 3. Kararlar
```text
KARAR: Cihaz ve filo işlemlerinin tek operatör giriş noktası `sudo bf-menu` kategori menüsüdür; operatör komut ezberlemez, numaralı kategoriden seçer.
GEREKÇE: Operatör profili Linux/DevOps'a yeni başlayan kişidir; serbest komut yazımı 700 cihazda yazım hatasını filo arızasına çevirir (K-11 blast-radius). Konsol her komutu çalıştırmadan önce gösterir ve onay ister; dalga kapılı playbook'lara bayrak sessizce geçirilmez, operatör kapıyı ayrıca onaylar.
ALTERNATİF: Operatörün dokümandan komut kopyalaması — sıra atlama ve yanlış hedef seçme riski yüksek; elendi. Yalnız merkez Semaphore UI — saha cihazında çevrimdışıyken erişilemez; merkez tarafı için korunur (09).
RİSK: Konsolun tablosu, altındaki playbook'tan ayrışır ve yanlış kapıyla çalıştırır; azaltma: konsol her playbook'un kapsamını (simple/wave/release) playbook'un kendisinden okuyup karşılaştırır, uyuşmazlıkta **çalıştırmayı reddeder** (`admin/bf-menu` → `bf_playbook_check`).
MALİYET: Ücretsiz.
LİSANS: Yok (kurum içi operatör aracı).
```
```text
KARAR: Kurulum git deposundan yapılır: depo klonlanır ve `sudo admin/bf-bootstrap.sh` (veya `curl -fsSL <repo>/admin/bf-bootstrap.sh | sudo bash`) çalıştırılır. ISO/firstboot yolu (26, 27) silinmez, ikincil/opsiyonel yol olarak korunur.
GEREKÇE: Dağıtım imajı/ISO üretimi bu fazda yapılmayacaktır; en hızlı doğrulanabilir yol, sürümü Git geçmişiyle izlenebilen tek bir bootstrap scriptidir. Bootstrap repoyu `/opt/blueforce-linux` (veya bulunduğu checkout) altına alır, ön koşulları (git, curl, ansible-core) kurar ve `bf-*` araçlarını `/usr/local/bin`'e bağlar; cihaz kimliği (bayi no) hiçbir zaman bir imaja gömülmez (K-09/K-12).
ALTERNATİF: Tek USB Field OS ISO (K-20) — üretim/doğrulama maliyeti yüksek olduğu için bu fazda birincil yol değil; yedek olarak korunur. USB'ye elle kopyalama — sürüm izi ve güncellenebilirlik yok; elendi.
RİSK: `curl | bash` deseni kötüye kullanıma açıktır; azaltma: script yalnız resmi depo adresinden çalıştırılır, `git clone` ile içeriği önce incelenebilir, secret yazmaz, `git reset`/`stash` yapmaz ve yerel değişiklikleri asla silmez.
MALİYET: Ücretsiz.
LİSANS: Yok (kurum içi script).
```
```text
KARAR: Şifre/kimlik bilgisi üretimi OPSİYONELDİR ve VARSAYILAN OLARAK KAPALIDIR; yalnız `bf-creds --generate` açıkça istendiğinde üretilir ve parola hiçbir zaman ekrana/loga/komut satırına yazılmaz.
GEREKÇE: SSH zaten anahtar zorunludur (parola girişi kapalı, `blueforce` hesabı kilitli — K-13); kullanılmayan bir parola üretmek sızma yüzeyini büyütür. Varsayılan kapalı olması secret'ın git'e/medyaya/support bundle'a kaçmasını da baştan engeller.
ALTERNATİF: Kurulumda otomatik parola üretimi — hiçbir kanal bu parolayı kullanmadığı için gereksiz secret üretir; elendi. Parolayı repoya/ISO'ya gömmek — secret yasağına aykırı; yasak.
RİSK: Kayıt dosyası yanlış yere kopyalanıp paylaşılır; azaltma: dosya `/root` altında mod `600`, `--fetch` hedefi `admin/secrets/` git-ignore'lu (dizin `700`), parola yalnız dosyaya yazılır.
MALİYET: Ücretsiz.
LİSANS: Yok.
```
## 4. Neden Bu Karar?
700 cihazlık bir filoda en pahalı hata, teknisyenin yanlış komutu yanlış cihazda çalıştırmasıdır. Konsol bu riski üç yerden küçültür: (1) operatör komut yazmaz, doğru işlemi seçer; (2) her komut **çalıştırılmadan önce ekranda** görünür ve onay ister — "ne çalışacak?" sorusu asla cevapsız kalmaz; (3) filo işlemleri hedefi (`--limit`) açıkça seçtirir ve dalga kapılarını (production için `production`, release kapısı için `GATE` yazma) operatöre ayrıca onaylatır. Git tabanlı kurulum ise "hangi sürüm sahada?" sorusunu `git log` ile yanıtlanabilir kılar. Şifre üretiminin varsayılan kapalı olması aynı felsefenin devamıdır: anahtar zaten var, ikinci bir gizli bilgi üretmenin faydası yok.
## 5. Alternatifler
| Alternatif | Artı | Eksi | Sonuç |
|---|---|---|---|
| Numaralı kategori konsolu (`bf-menu`) | Keşfedilebilir, komutu gösterip onay ister, çevrimdışı çalışır, denetim log'u tutar | Konsol tablosu playbook'la senkron tutulmalı | **Seçildi** |
| Dokümandan komut kopyalama | Araç gerektirmez | Sıra atlama + yanlış hedef riski | Elendi |
| Yalnız Semaphore UI (web) | Güzel arayüz, merkezden yönetim | Saha cihazında çevrimdışı erişilemez; cihaz-kurulumunu kapsamaz | Merkez için korunur (09) |
| Grafik masaüstü aracı (GUI uygulaması) | Görsel | Cihazlar terminale boot eder (K-03), ek bağımlılık ister | Elendi |
| Tek düz komut scripti (`bf-do`) | Basit | Keşfedilebilirlik ve hedef seçimi yok, acemi için anlaşılmaz | Elendi |
## 6. Avantajlar
- Öğrenme maliyeti tek satır: `sudo bf-menu`.
- Her komut çalıştırılmadan önce gösterilir; tehlikeli işlemler iki kez onaylanır — yanlış komut/yanlış sıra riski ortadan kalkar.
- Filo işlemleri yalnız mevcut playbook'larla ve açık `--limit` ile çalışır; konsol **yeni playbook üretmez** ve envanter dışı bir cihaza dokunmaz.
- Çevrimdışı çalışır — ISO/firstboot yoluna bağımlı değildir.
- Denetim log'u (`/var/log/blueforce-console.log`, mod `600`) komutu, hedefi ve çıkış kodunu kaydeder; **secret ve ham playbook çıktısı log'a yazılmaz**.
## 7. Dezavantajlar
- Konsol tablosu ile playbook'lar arasında kapsam (scope) uyuşmazlığı olursa konsol çalıştırmayı reddeder; bu, playbook güncellemesiyle konsolun birlikte güncellenmesini zorunlu kılar (`admin/bf-menu` → `bf_playbook_check`).
- Kategoriler 2, 3 ve 5-filo için makinede `ansible-playbook` gerekir; yoksa konsol açıklama verir ama işlemi çalıştırmaz.
- Çok özel/tek seferlik işler konsol kataloğunda yoksa operatör kabuğa döner (kataloğa ekleme PR ile yapılır).
- TUI çıktısı log'a birebir kopyalanmaz; her seçim ayrı bir denetim satırı üretir (K-14).
## 8. Riskler
| Risk | Olasılık | Etki | Azaltma |
|---|---|---|---|
| Konsol bayatlar, yanlış kapıyla çalıştırır | Orta | Yüksek | `bf_playbook_check` kapsam karşılaştırması uyuşmazlıkta reddeder; §10 testi her sürümde koşar |
| Operatör "tüm filo" hedefini yanlışlıkla seçer | Orta | Yüksek | Wave-scoped playbook'larda filo dalga dalga ilerler, her dalga **ayrı onaylanır**; production için `production` yazma zorunlu (09/10) |
| `curl | bash` ile sahte script çalıştırma | Düşük | Yüksek | Yalnız resmi depo adresi; `git clone` + gözden geçirme önerilir |
| Şifre kaydı yanlışlıkla paylaşılır | Düşük | Yüksek | Varsayılan kapalı; `/root/blueforce-credentials.txt` mod `600`; `admin/secrets/` git-ignore'lu (§9.5) |
| İşlem yarıda kalkar (elektrik) | Orta | Orta | Kurulum idempotent; menü "Resume after a failed run" sunar (§9.6) |
| Konsol, envanter dışı cihaza dokunur | Düşük | Yüksek | Hedefler envanterden çözülür; cihaz/grup bulunamazsa işlem reddedilir |
## 9. Uygulama Planı
Bu bölüm sıfırdan başlayan içindir. Her adımda **hangi komutu nereye yazacağın** ve **ekranda ne göreceğin** yazılıdır.
### 9.1 Ubuntu'da ilk kurulum — adım adım
**Ön koşullar (elinde olması gerekenler):**
- Ubuntu Server tabanlı bir makine (desteklenen baseline `24.04` / `26.04`).
- `sudo` yetkisi olan bir kullanıcı.
- Kurulum sırasında **internet** (repoyu git'ten indirmek için). Cihaz kurulduktan sonra çalışması için internet gerekmez.
**Adım 1 — Terminali aç.** Ekranda şöyle bir satır görürsün:
```text
emirhan@makine:~$
```
`$` işareti "senin yazacağın yer" demektir; **`$` işaretini sen yazmazsın.** Aşağıdaki komutları bu satıra yazıp Enter'a bas.
**Adım 2 — Git kurulu mu?**
```bash
git --version
```
Beklenen çıktı: `git version 2.x.y`. `command not found` yazarsa kur:
```bash
sudo apt update && sudo apt install -y git
```
`[sudo] password for emirhan:` yazınca **klavyeden parolanı yaz** — ekranda hiçbir karakter görünmez, bu normaldir — sonra Enter.
**Adım 3 — Depoyu indir (klonla).**
```bash
git clone https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git
cd Blueforce-Linux
```
Ekranda `Cloning into 'Blueforce-Linux'...` görürsün ve komut `$` satırına döner.
**Adım 4 — Bootstrap'ı çalıştır.** İki yol var, ikisi de aynı sonucu verir.
*Yol A — depo içinden (önerilen; önce içeriği inceleyebilirsin):*
```bash
sudo admin/bf-bootstrap.sh
```
*Yol B — tek satırla (depoyu klonlamadan; repoyu `/opt/blueforce-linux` altına klonlar):*
```bash
curl -fsSL https://raw.githubusercontent.com/emirhanmamak-blueforce/Blueforce-Linux/main/admin/bf-bootstrap.sh | sudo bash
```
Bootstrap ne yapar (kendi özet çıktısı, örnek):
```text
bf-bootstrap: ubuntu 26.04 is a supported baseline
bf-bootstrap: dependency check
bf-bootstrap: dependencies present: git curl ansible-playbook
bf-bootstrap: cloning https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git (branch main) into /opt/blueforce-linux
bf-bootstrap: linked 26 tools into /usr/local/bin
bf-bootstrap: bf-menu ready: /usr/local/bin/bf-menu
bf-bootstrap: summary
repository : /opt/blueforce-linux
branch : main
revision : <git hash>
tools : 26 linked into /usr/local/bin
bf-menu : /usr/local/bin/bf-menu
next : sudo bf-menu
Installation complete. To get started: sudo bf-menu
Kurulum tamam. Başlamak için: sudo bf-menu
```
| Bootstrap ne yapar | Ne yapmaz |
|---|---|
| Ön koşulları kurar (`git`, `curl`, `ansible-core`) | Cihaz kimliği (bayi no) atamaz |
| Repoyu klonlar veya mevcut checkout'u **fast-forward** eder | `git reset` / `git stash` yapmaz; yerel değişikliği **silmez** |
| `admin/bf-*`, `scripts/diagnostics/bf-*`, `scripts/maintenance/bf-*` araçlarını `/usr/local/bin`'e bağlar | Parola/secret üretmez veya indirmez |
| `admin/bf-menu`'yü çalıştırılabilir yapıp `sudo bf-menu`'yü hazırlar | Disk bölmez, servis silmez, onaysız paket güncellemez |
> `sudo admin/bf-bootstrap.sh --check` **hiçbir şeyi değiştirmeden** ne yapılacağını raporlar (root bile gerekmez). Önce bunu çalıştırmak iyi alışkanlıktır.
**Adım 5 — Menüyü aç ve doğrula.**
```bash
sudo bf-menu
```
Menü açılıyorsa kurulum tamam. Bundan sonra her iş için aynı komut:
```bash
sudo bf-menu
```
> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Menüden çıkmak için ana ekranda `q`; alt menülerde önce `0` (geri), sonra `q` (çıkış).
### 9.2 Menüyü kullanma — kategori kategori
`sudo bf-menu` çalıştırınca **ana menü** açılır. Bir numara yazıp Enter'a bas.
**Ana menü (örnek ekran):**
```text
==============================================================================
Blueforce operator console 1.0.0
repo: /opt/blueforce-linux
==============================================================================
Categories
1) Installation this device, local one-click installer
2) Device operations fleet playbooks, explicit target
3) Central (preparation) verify and prepare central integration
4) Tools repository and local helpers
5) Device information read-only information screens
------------------------------------------------------------------------------
q) Quit the console
Log: /var/log/blueforce-console.log
------------------------------------------------------------------------------
Choice >
```
Kategori kategori ne yapar ve hangi soruları sorar:
**1) Installation (bu cihaz)** — yalnız **senin oturduğun makineyi** kurar; hiçbir filo cihazına dokunmaz.
```text
1) Pre-flight check installer --check, writes nothing
2) Install this device full run, asks dealer number
3) Resume after a failed run installer --resume
4) Run a single module installer --only
5) Show module state /var/lib/blueforce/install-state
6) Show the installer log tail of /var/log/blueforce-install.log
```
Sorduğu sorular: **`Dealer number (8 digits) >`**; 2. ve 3. seçenekte ayrıca "offline repo'dan mı kurulsun?" ve "zaten OK olan modüller atlansın mı?" onayları; son olarak **`Run this command? [y=run / c=run with --check / N=cancel]`**. 2, 3 ve 4 numaralı seçenekler `root` ister (konsolu `sudo` ile açtıysan hazırdır). Format yanlışsa (`^[0-9]{8}$` uymuyorsa) konsol kurulumu başlatmaz.
**2) Device operations (filo)** — yalnız repodaki **mevcut** playbook'ları, açık bir hedefle çalıştırır.
```text
1) Read-only reports 11 operations, no change on any device
2) Maintenance actions 10 operations, some destructive
3) Show inventory targets groups, hosts and counts
```
*2.1 Read-only fleet reports (11 işlem, hiçbiri cihazı değiştirmez):*
| # | İşlem | Ne yapar |
|---|---|---|
| 1 | Ping / reachability | SSH erişilebilirliği (salt-okunur) |
| 2 | Uptime | Çalışma süresi ve yük |
| 3 | Disk usage | Disk kullanımı; eşik üstünde uyarır (eşik sorulur) |
| 4 | Docker status | Docker servisi ve konteynerler |
| 5 | Package versions | Pinlenmiş paketlerin kurulu sürümleri |
| 6 | Pending security updates | **Yalnız rapor**; asla kurmaz |
| 7 | Provisioning phase report | Cihaz başına `state.json` fazı |
| 8 | Enrollment + central evidence | ENROLLED/READY iddialarını merkezi kanıta karşı denetler |
| 9 | Field OS version report | Sürüm; istenirse drift kontrolü (beklenen sürüm sorulur) |
| 10 | Remote access channels | SSH/xRDP/RustDesk/WireGuard hazırlığı |
| 11 | Offline readiness report | Offline provisioning yerel ön koşulları |
*2.2 Fleet maintenance (10 işlem, bir kısmı yıkıcı):*
| # | İşlem | Not |
|---|---|---|
| 1–3 | Restart RDP / RustDesk / WireGuard | Servis yeniden başlatma |
| 4 | Restart an allowlisted service | Yalnız izinli servis adları (`blueforce-agent`, `docker`, `ssh`, `sshd`, `systemd-journald`, `cron`) |
| 5 | GUI off (headless) | Dalgayı `multi-user.target`'e alır |
| 6 | GUI on (maintenance) | Grafik oturumu geri açar |
| 7 | Collect logs / support bundle | Zaman damgalı journal + snapshot paketi (satır sayısı sorulur) |
| 8 | Run an approved script | Yalnız `/opt/blueforce/bin` ve `/usr/local/sbin` |
| 9 | Controlled reboot | Seri yeniden başlatma + sağlık kapısı (bekleme süresi sorulur) |
| 10 | Deploy approved package update | `package=version` pinleri + sağlık kapısı |
> **`bf-gui-off` xRDP'i durdurmaz.** `xrdp`/`xrdp-sesman` her zaman enable+active kalır; `bf-gui-*` yalnız **yerel fiziksel** grafik katmanını kontrol eder (K-22). "GUI kapalı ≠ RDP kapalı."
**3) Central (preparation)** — merkez entegrasyonunu **denetler/hazırlar**; merkez sunucusunu **kurmaz**.
```text
1) Central readiness report inventory, placeholders, admin key
2) Show configured central endpoints WireGuard hub, monitoring, mirror
3) Probe central endpoints DNS + TCP, read-only
4) Wave rollout gate state per-wave device counts and gate rules
5) Central preparation checklist what must exist before enrollment
```
**4) Tools** — repo ve yerel yardımcılar.
```text
1) Update this checkout git pull --ff-only
2) Environment and reference info paths, versions, resolved settings
3) Show the operation catalogue --list output
4) Local offline gate bf-check-local
5) Enrollment gate bf-check-enrollment
6) Ready gate bf-check-ready
7) Deep diagnostics bf-diagnostics (or bf-status)
8) Field OS release identity bf-release
9) Hardware inventory bf-hardware-inventory.sh
10) Support bundle bf-support-bundle --out-dir
11) Show the console log last 40 lines of the audit log
```
**5) Device information** — salt-okunur bilgi ekranları.
```text
1) Identity hostname, device id, dealer number
2) Health summary bf-status
3) Provisioning and enrollment state state.json whitelist + bf-enrollment-status
4) Network addresses, route, DNS, WireGuard peers
5) Disk, memory, CPU df, free, load
6) Installed pinned packages dpkg-query
7) Service status ssh, xrdp, rustdesk, docker, wg, meshagent
8) Recent journal errors journalctl -p err
9) Inventory summary waves, groups, counts
10) Resolve a target check a dealer number or group
```
> **Not (WireGuard):** Konsol WireGuard private/preshared key'lerini **hiçbir zaman istemez, göstermez veya loglamaz**; yalnız peer durumunu okur.
### 9.3 Cihaz seçimi: tek cihaz mı grup mu
Kategori **2**'de bir işlem seçtiğinde konsol önce **hedefi** sorar:
```text
Target selection (mode: wave)
1) Single device by dealer number 8 digits, e.g. 12010101
2) Group / wave lab, pilot_1, pilot_2, wave_1, wave_2, production
3) All devices in the fleet asks per wave for wave-scoped playbooks
9) Type the target directly dealer number, group or 'all'
Target >
```
| Hedef türü | Menüde ne yazarsın | Örnek | Boyut | Tipik kullanım |
|---|---|---|---|---|
| Tek cihaz | bayi no (8 hane) | `12010101` | 1 | Arıza teşhisi, tek cihaz bakımı |
| Grup / dalga | grup adı | `lab` | 2 | Yeni playbook denemesi (önce LAB kuralı) |
| Grup / dalga | grup adı | `pilot_1` / `pilot_2` / `wave_1` / `wave_2` | 5 / 20 / 50 / 100 | Kademeli yayılım (K-11) |
| Tüm filo | `3` (veya doğrudan `all`) | — | kalan | Yalnız onaylı; dalga dalga ilerler |
Kurallar (konsolun kendi davranışı):
- **Tek cihaz** normalde en güvenli seçenektir; cihaz envanterde yoksa konsol **reddeder** (`is not in the inventory`).
- **Grup / dalga** seçiminde liste, her grubun cihaz sayısıyla ve onaylı dalga olup olmadığıyla gösterilir.
- **Wave-scoped** bir playbook'ta tüm filo seçilirse konsol, dalgaları **sırayla** (`lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production`) işler ve **her dalga için ayrı ayrı** onay ister; bir dalga başarısız olursa sonraki dalgaya **geçmez**.
- **Release-gated** playbook'lar (reboot, deploy-update) tek cihaz veya tüm filo kabul etmez; **tek onaylı dalga** ister.
- **production** dalgası ayrıca `production` yazdırılarak, release kapısı ise `GATE` yazdırılarak onaylanır. Hiçbir kapı sessizce geçilmez.
### 9.4 Uçtan uca örnek seans (örnek ekran çıktısı)
Aşağıdaki iki kısa seans, kurulumdan günlük işletime tipik akışı gösterir. (Çıktılar **örnektir**.)
**Seans A — Yeni cihazın kurulumu (bayi no girme)**
```text
emirhan@makine:~$ sudo bf-menu
==============================================================================
Blueforce operator console 1.0.0
repo: /opt/blueforce-linux
==============================================================================
Categories
1) Installation 2) Device operations 3) Central (preparation)
4) Tools 5) Device information
------------------------------------------------------------------------------
q) Quit the console
Log: /var/log/blueforce-console.log
------------------------------------------------------------------------------
Choice > 1
==============================================================================
1) Installation (this device)
Local install only -- no fleet device is touched here
==============================================================================
1) Pre-flight check installer --check, writes nothing
2) Install this device full run, asks dealer number
3) Resume after a failed run installer --resume
4) Run a single module installer --only
5) Show module state /var/lib/blueforce/install-state
6) Show the installer log tail of /var/log/blueforce-install.log
------------------------------------------------------------------------------
0) Back q) Quit the console
------------------------------------------------------------------------------
Choice > 1
Read-only pre-flight: the installer creates no log, state or directory in --check mode.
Dealer number (8 digits) > 12010193
Pre-flight check for BF-12010193
bash /opt/blueforce-linux/scripts/install/blueforce-install.sh --dealer-id 12010193 --check
[1/18] precheck ........ OK [2/18] system ........ OK
... [18/18] final-check ... OK
Press Enter to continue
```
Şimdi gerçek kurulum (aynı menüden **2**):
```text
Choice > 2
Dealer number (8 digits) > 12010193
Install from the local offline repository (--offline)? [y/N] n
[i] Target device: BF-12010193 (bf-12010193)
Command
bash /opt/blueforce-linux/scripts/install/blueforce-install.sh --dealer-id 12010193
[WARN] This is a DESTRUCTIVE operation: install on BF-12010193
Proceed? [y=run / c=run with --check / N=cancel] y
[1/18] precheck ..................... OK
[2/18] system ....................... OK
...
[18/18] final-check .................. OK
[OK] Command finished successfully (exit 0).
Press Enter to continue
```
Kurulum sonunda cihaz `PROVISIONED_OFFLINE` olur; **`READY` değildir** — merkeze kayıt (enrollment) ve merkezi kanal kanıtı gerekir (K-21). Bunu görmek için kategori **5 → 3** (Provisioning and enrollment state) açılır.
**Seans B — Günlük kontrol (salt-okunur rapor) ve teşhis paketi**
```text
Choice > 2
==============================================================================
2) Device operations
Existing playbooks only, always with an explicit target
==============================================================================
1) Read-only reports 11 operations, no change on any device
2) Maintenance actions 10 operations, some destructive
3) Show inventory targets groups, hosts and counts
------------------------------------------------------------------------------
0) Back q) Quit the console
------------------------------------------------------------------------------
Choice > 1
... 1) Ping / reachability 2) Uptime 3) Disk usage 4) Docker status ...
Choice > 1
Target selection (mode: any)
1) Single device by dealer number 8 digits, e.g. 12010101
2) Group / wave lab, pilot_1, pilot_2, wave_1, wave_2, production
3) All devices in the fleet
9) Type the target directly dealer number, group or 'all'
Target > 1
Dealer number (8 digits) > 12010193
Command
ansible-playbook -i ansible/inventory/hosts.yml ansible/playbooks/bf-ping.yml --limit bf-12010193
Run this command? [y=run / c=run with --check / N=cancel] y
PLAY [Ping / reachability] ...
bf-12010193 | SUCCESS => {"changed": false, "ping": "pong"}
[OK] Command finished successfully (exit 0).
```
Teşhis paketi için kategori **4 → 10** (Support bundle); paket yolu ekranda yazılır ve paket **parola, private key veya token içermez** (K-14).
### 9.5 Kimlik bilgileri / şifre dosyası (opsiyonel — varsayılan KAPALI)
**Önce şunu bil:** SSH bu cihazda **anahtar zorunludur**; parola girişi kapalıdır ve `blueforce` bakım hesabı kilitlidir (K-13). Yani günlük yönetim için parolaya ihtiyaç yoktur. Bu yüzden şifre üretimi **varsayılan olarak kapalıdır** ve yalnız açıkça istenirse yapılır.
| Konu | Davranış (`admin/bf-creds`) |
|---|---|
| Varsayılan | **Kapalı.** `sudo bf-creds` hiçbir parola üretmez; yalnız cihazda **zaten var olan** gizlileri raporlar. |
| Üretme | `sudo bf-creds --generate` — cihaz kaydını yazar (ayrıca `BF_GENERATE_CREDENTIALS=1`) |
| Kayıt dosyası | `/root/blueforce-credentials.txt` (mod `0600`) — cihaz kimliği (`BF-<no>`), hostname, oluşturma zamanı, WireGuard **public** anahtarı + private anahtarın **yolu** (içeriği değil), varsa RustDesk ID ve yerel **yönetici parolası** |
| Çekme (merkeze/kasaya) | `sudo bf-creds --fetch` — `<repo>/admin/secrets/<bayi-no>.txt` (dizin `0700`, dosya `0600`, **git-ignore'lu**) |
| Parola ekranda görünür mü? | **Hayır.** Parola yalnızca dosyaya yazılır; stdout/stderr'e, log'a veya komut satırına asla konmaz. |
| Git'e / ISO'ya / destek paketine girer mi? | **Hayır.** Secret hiçbir zaman depoya, medyaya veya support bundle'a konmaz. |
| SSH'ı değiştirir mi? | **Hayır.** `bf-creds` `sshd_config`'i düzenlemez, hesap açmaz/kilit açmaz, parola girişi yolunu açmaz. |
**Neden varsayılan kapalı?** Kullanılmayan her secret fazladan bir sızma yüzeyidir. Anahtar tabanlı erişim varken ikinci bir parola üretmek, sadece "git'e kaçabilecek yeni bir dosya" yaratır. İstisna (ör. yerel konsolda geçici bir parola isteyen bir ekip) `--generate` ile karşılanır ve kayıt dosyasının varlığı kayda geçer. **Varsayılanı değiştirmek bir karar gerektirir; sessizce açılmaz.**
### 9.6 Bir şey ters giderse — en sık 5 hata
| # | Belirti (ekranda ne görürsün) | Neden | Çözüm |
|---|---|---|---|
| 1 | `bf-menu: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra `sudo /usr/local/bin/bf-menu` ile tam yoldan dene |
| 2 | `[ERROR] This operation needs root on THIS device.` | Kurulum seçenekleri root ister; konsolu `sudo`'suz açtın | Konsolu `sudo bf-menu` ile aç (kural: bu işlem için konsol `Re-run the console with sudo: sudo admin/bf-menu` der) |
| 3 | `[ERROR] Invalid dealer number '1201019': it must match ^[0-9]{8}$ (8 digits, no spaces).` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` |
| 4 | `[WARN] ansible-playbook not found ... fleet operations cannot run.` veya `[WARN] ... is not available` | Makinede Ansible yok ya da araç PATH'te değil | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` |
| 5 | `[ERROR] Playbook not found: ansible/playbooks/...` / `Refusing to run ... the console expects scope 'X' but the playbook declares 'Y'.` | Konsol ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | Kategori **4 → 1** ile checkout'u güncelle (`git pull --ff-only`) veya doğru repoyu ver: `sudo bf-menu --repo /opt/blueforce-linux` |
Ek olarak sık görülen iki durum:
- **`git clone` internet/DNS hatası** (`Could not resolve host`): İnternet/DNS'i kontrol et; kurulum için internet yalnız bu adımda gerekir.
- **`[ERROR] ... is not in the inventory`**: Cihaz veya grup `ansible/inventory`'de yok; hedefi envanterle eşle (02) veya `bf-bootstrap` ile checkout'u güncelle.
### 9.7 Güvenlik kuralları
1. **Secret git'e, ISO'ya veya destek paketine girmez.** Parola, private key ve token repoda/medyada bulunmaz; `bf-support-bundle` çıktısı secret sızdırmaz (K-14).
2. **Onaysız update yoktur.** Güncelleme yalnız kategori 2 → "Deploy approved package update" ile, `package=version` pinleri + dalga kapısı + sağlık kapısı altında yapılır (K-11). Serbest `apt upgrade`, `latest` imaj veya otomatik güncelleyici mimaride yoktur (kategori 2'de "Pending security updates" **yalnız rapor**tur).
3. **Yıkıcı işlemler onaylıdır.** Her komut çalıştırılmadan önce ekranda gösterilir, onay istenir; yıkıcı işlemler ayrıca ikinci kez sorulur; production dalgası için `production` ve release kapısı için `GATE` yazılması zorunludur. Disk bölme/silme konsolda otomatikleştirilmemiştir.
4. **SSH yalnız anahtarla.** Parola girişi kapalı, `blueforce` hesabı kilitli; paylaşılan private key yasaktır (K-13).
5. **Tek kullanımlık enrollment token loglanmaz.** Token konsol dışında bir kez girilir; denetim log'u ham çıktıyı ve secret'ı yazmaz (K-17).
6. **Cihaz kimliği imajda taşınmaz.** Bayi no yalnız ilk kurulum seansında girilir (K-09/K-12).
## 10. Test Planı
| Test | Beklenen sonuç | Ortam |
|---|---|---|
| `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde | LAB(2) |
| `sudo bf-menu --list` / `--list-targets` | İşlem kataloğu ve envanter hedefleri hatasız listelenir | LAB(2) |
| `sudo bf-menu` açılır, `q` ile çıkılır | Konsol hiçbir değişiklik yapmaz | LAB(2) |
| Kategori 1 → 1 kuru koşu (`--check`) | Değişiklik yok, rapor üretir | LAB(2) |
| Kategori 1 → 2 kurulum, bayi no `12010193` | `PROVISIONED_OFFLINE`; hostname `bf-12010193` | LAB(2) |
| Geçersiz bayi no (7/9 hane, harf) | Konsol kurulumu başlatmaz | LAB(2) |
| Kategori 2 → 2 → 9 reboot, tek cihaz | Onay istenir; cihaz yeniden başlar; log satırı yazılır | LAB(2) |
| Kategori 2 wave-scoped playbook, hedef "all" | Dalga dalga; her dalga ayrı onaylanır; hata sonraki dalgayı durdurur | LAB(2) |
| production dalgası denemesi | `production` yazılmadan çalışmaz | LAB(2) |
| Kategori 4 → 11 konsol log'u | Mod `600`; secret ve ham playbook çıktısı yok | LAB(2) |
| `sudo bf-creds` (bayraksız) | Hiçbir parola üretilmez, açıkça söyler | LAB(2) |
| `sudo bf-creds --generate` | Kayıt dosyası `0600`; parola ekranda görünmez | LAB(2) |
## 11. Rollback
1. Kurulum yarıda kaldıysa: kategori **1 → 3 "Resume after a failed run"** (`--resume`); modüller idempotenttir.
2. Yanlış bayi no girildiyse: doğru no ile tam yeniden koşu; kimlik modülü hostname'i üzerine yazar (K-12).
3. Bootstrap yanlış kurulduysa: repoyu güncelleyip (`git pull --ff-only`) yeniden koş; bootstrap **hiçbir şeyi silmez, `git reset`/`stash` yapmaz**, yerel çalışmanı korur.
4. Filo işlemi başarısız dalga bıraktıysa: "Deploy approved package update" geri alma zinciri önceki onaylı release manifestine döner (10, 30).
5. Çözülmeyen cihaz: 17-RECOVERY seviye merdiveni (L8 = temiz yeniden kurulum) uygulanır.
## 12. Kontrol Listesi
- [x] `admin/bf-bootstrap.sh`, `admin/bf-menu` ve `admin/bf-creds` repoda mevcut; `sudo bf-menu` tek giriş noktası.
- [x] Kurulum git tabanlı (clone + bootstrap); ISO yolu ikincil/opsiyonel olarak korunuyor (26 silinmedi).
- [x] Her komut çalıştırılmadan önce gösterilip onaylanıyor; yıkıcı işlemler iki kez soruyor.
- [x] Bayi no kapısı `^[0-9]{8}$`; geçersiz girişte kurulum başlamıyor.
- [x] Filo işlemleri açık `--limit` ile; wave-scoped `all` dalga dalga ilerliyor; production için `production` yazma zorunlu.
- [x] Konsol, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor.
- [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor.
- [x] Kategori 2'de onaysız güncelleme seçeneği **yok**; "Pending security updates" yalnız rapor.
- [x] Denetim log'u mod `600`; secret ve ham playbook çıktısı yazılmıyor.
- [ ] Örnek ekran çıktıları gerçek LAB çıktısıyla değiştirildi.
## 13. Açık Sorular
- [ ] Konsol işlem kataloğunun playbook kümesiyle tam eşleşmesi: `bf-service-restart`/`bf-run-script` gibi yeni playbook'lar kataloğa nasıl eklenecek (süreç/PR şablonu) (sahibi: 09 + konsol sahibi).
- [ ] Merkez hazırlığı ekranlarının (kategori 3) gerçek merkez endpoint'leri geldiğinde hangi kanıtı "hazır" sayacağı (sahibi: 29/21 yazarları).
- [ ] `bf-creds --fetch` hedefinin (`admin/secrets/`) kurumsal kasa (vault) akışına bağlanması (sahibi: 06-ssh + merkezi admin).
- [ ] Konsol işlem geçmişinin merkezi log toplamaya (Loki/ELK, Faz-sonrası) dahil edilip edilmeyeceği (sahibi: 15-LOGGING yazarı).
- [ ] Git kurulumu ile ISO/firstboot yolunun uzun vadeli rolü: ISO tamamen arşiv mi, yedek yol mu kalır (sahibi: yönetim + 26 yazarı).
- [ ] Kurumsal proxy/air-gapped sahalarda `git clone` yerine iç ayna (mirror) deposu kullanılacak mı; bootstrap `--repo`/`BF_REPO_URL` ile buna hazır mı (sahibi: merkezi admin).
---
## Ek: Menü haritası
```mermaid
flowchart TB
START["sudo bf-menu"] --> MAIN{"Ana menü — kategori seç<br/>q = çıkış"}
MAIN --> C1["1) Installation<br/>bu cihazı kurar (filo cihazına dokunmaz)"]
MAIN --> C2["2) Device operations<br/>mevcut playbook'lar + açık hedef"]
MAIN --> C3["3) Central (preparation)<br/>merkez entegrasyonunu denetle/hazırla"]
MAIN --> C4["4) Tools<br/>repo + yerel yardımcılar"]
MAIN --> C5["5) Device information<br/>salt-okunur bilgi ekranları"]
C1 --> I1["1 Kuru koşu --check<br/>2 Kurulum (bayi no)<br/>3 Devam --resume<br/>4 Tek modül --only<br/>5 Modül durumu / 6 Log"]
C2 --> P1["2.1 Salt-okunur raporlar (11)"]
C2 --> P2["2.2 Bakım işlemleri (10, yıkıcı onaylı)"]
C2 --> P3["2.3 Envanter hedefleri"]
C3 --> K1["Hazırlık raporu / endpoint'ler / probe<br/>dalga kapı durumu / kontrol listesi"]
C4 --> T1["Checkout güncelle / ortam bilgisi / katalog<br/>gate'ler / bf-release / destek paketi / log"]
C5 --> D1["Kimlik / sağlık / durum / ağ / disk<br/>paketler / servisler / journal / envanter"]
```
## Ek: İlk kurulum ve durum akışı
```mermaid
flowchart LR
G["git clone<br/>Blueforce-Linux"] --> B["sudo admin/bf-bootstrap.sh<br/>(veya curl | sudo bash)"]
B --> M["sudo bf-menu"]
M --> ID["Kategori 1: bayi no gir<br/>ör. 12010193"]
ID --> INST["18/18 modül OK"]
INST --> PO["PROVISIONED_OFFLINE<br/>yerel kurulum bitti"]
PO -->|kategori 5: enrollment| EN["ENROLLED"]
EN -->|merkez kanal kanıtı| RDY["READY<br/>teslim edilebilir"]
B -. "ISO/firstboot yolu<br/>(ikincil, opsiyonel)" .-> FB["docs/26 / docs/27"]
```
## Ek: Hızlı referans (menü ↔ komut)
| Menüde | Altında çalışan komut / playbook |
|---|---|
| 1 → 1 Kuru koşu | `blueforce-install.sh --dealer-id <8hane> --check` |
| 1 → 2 Kurulum | `blueforce-install.sh --dealer-id <8hane>` (`--offline`, `--resume` opsiyonel) |
| 1 → 4 Tek modül | `blueforce-install.sh --dealer-id <8hane> --only <modül>` |
| 1 → 5 / 6 Modül durumu / log | `/var/lib/blueforce/install-state`, `/var/log/blueforce-install.log` |
| 2.1 → 1 Ping | `bf-ping.yml` |
| 2.1 → 9 Field OS sürümü | `bf-fieldos-version.yml` |
| 2.2 → 7 Log topla | `bf-collect-logs.yml` |
| 2.2 → 9 Kontrollü reboot | `bf-reboot.yml` (release-gated, seri + sağlık kapısı) |
| 2.2 → 10 Onaylı güncelleme | `bf-deploy-update.yml` (`package=version` pinleri) |
| 3 → 1..5 Merkez hazırlığı | `ansible/inventory/group_vars/all.yml` denetimi + DNS/TCP probe |
| 4 → 4/5/6 Kapılar | `bf-check-local`, `bf-check-enrollment`, `bf-check-ready` |
| 4 → 8 Sürüm kimliği | `bf-release` |
| 4 → 10 Destek paketi | `bf-support-bundle --out-dir` |
| 5 → 2/3 Sağlık ve durum | `bf-status`, `/var/lib/blueforce/state.json`, `bf-enrollment-status` |
| Konsol dışı (opsiyonel) | `sudo bf-creds --generate` / `--fetch` |
| Denetim log'u | `/var/log/blueforce-console.log` (mod `600`) |

752
tests/check-admin-static.sh Executable file
View file

@ -0,0 +1,752 @@
#!/usr/bin/env bash
#
# Static + behavioural contract tests for the git-based bootstrap and the
# optional credential tool:
#
# admin/bf-bootstrap.sh one-command install from git
# admin/bf-creds optional, default-OFF credential generation/delivery
# admin/lib/creds.sh shared helpers
#
# Everything here is mock/sandboxed: no network, no real apt, no root, and no
# write outside a mktemp sandbox. The bootstrap apply path is exercised with
# `id` and `apt-get` shims placed first in PATH, every PATH entry providing
# ansible-playbook removed (so the dependency branch runs deterministically and
# still only reaches the apt-get shim), and BF_INSTALL_DIR / BF_BIN_DIR /
# BF_REPO_URL pointed at the sandbox — a real package install or a real
# /usr/local/bin link can never happen from this suite.
#
# Every load-bearing check is paired with a negative control: the same
# predicate is run against a deliberately broken copy and MUST trip, so a
# check that has quietly stopped testing anything fails the suite instead of
# passing silently.
#
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BOOTSTRAP="$REPO_ROOT/admin/bf-bootstrap.sh"
CREDS="$REPO_ROOT/admin/bf-creds"
CREDS_LIB="$REPO_ROOT/admin/lib/creds.sh"
GITIGNORE="$REPO_ROOT/.gitignore"
FAIL=0
CHECKS=0
fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; }
pass() { printf 'OK: %s\n' "$*"; }
note() { printf 'NOTE: %s\n' "$*"; }
check() { CHECKS=$((CHECKS + 1)); }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/check-admin.XXXXXX")"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
# --------------------------------------------------------------------------- #
# Predicates and assertion helpers.
# --------------------------------------------------------------------------- #
has() { [[ -f "$1" ]] && grep -qE -- "$2" "$1"; }
lacks() { [[ -f "$1" ]] && ! grep -qE -- "$2" "$1"; }
# Case-insensitive variants, used for the secret-material scan (a planted
# `DEMO_TOKEN=...` must be found just like a lowercase one).
has_i() { [[ -f "$1" ]] && grep -qiE -- "$2" "$1"; }
lacks_i() { [[ -f "$1" ]] && ! grep -qiE -- "$2" "$1"; }
# contract <desc> <file> <predicate> <regex> -- the predicate must hold.
contract() {
check
if "$3" "$2" "$4"; then
pass "$1"
else
fail "$1 [file=${2#"$REPO_ROOT"/} pattern=$4]"
fi
}
# control <desc> <broken-file> <predicate> <regex> -- the predicate must NOT hold.
control() {
check
if "$3" "$2" "$4"; then
fail "negative control did not trip: $1"
else
pass "negative control trips: $1"
fi
}
expect_rc() { # <desc> <expected-rc> <actual-rc>
check
if [[ "$2" -eq "$3" ]]; then
pass "$1"
else
fail "$1 (expected exit $2, got $3)"
fi
}
expect_absent() { # <desc> <path>
check
if [[ ! -e "$2" ]]; then
pass "$1"
else
fail "$1 (path exists: $2)"
fi
}
expect_present() { # <desc> <path>
check
if [[ -e "$2" ]]; then
pass "$1"
else
fail "$1 (path missing: $2)"
fi
}
expect_mode() { # <desc> <path> <mode>
check
local mode=""
mode="$(stat -c '%a' "$2" 2>/dev/null || true)"
if [[ "$mode" == "$3" ]]; then
pass "$1"
else
fail "$1 (mode is '${mode:-missing}', expected $3)"
fi
}
expect_contains() { # <desc> <file> <literal>
check
if grep -qF -- "$3" "$2" 2>/dev/null; then
pass "$1"
else
fail "$1 (literal not found: $3)"
fi
}
expect_absent_text() { # <desc> <file> <literal>
check
if grep -qF -- "$3" "$2" 2>/dev/null; then
fail "$1 (literal found: $3)"
else
pass "$1"
fi
}
# secret_leaked <output-file> <secret> -- 0 when the secret is visible.
# This is the leak detector used for the generated credential; its negative
# control below proves it can actually find a secret.
secret_leaked() { grep -qF -- "$2" "$1" 2>/dev/null; }
# --------------------------------------------------------------------------- #
# 1. Presence, shebang, strict mode, syntax.
# --------------------------------------------------------------------------- #
for target in "$BOOTSTRAP" "$CREDS" "$CREDS_LIB"; do
check
if [[ -f "$target" ]]; then
pass "present: ${target#"$REPO_ROOT"/}"
else
fail "missing: ${target#"$REPO_ROOT"/}"
fi
done
for target in "$BOOTSTRAP" "$CREDS" "$CREDS_LIB"; do
[[ -f "$target" ]] || continue
name="${target#"$REPO_ROOT"/}"
check
if [[ "$(head -n 1 "$target")" == '#!/usr/bin/env bash' ]]; then
pass "bash shebang: $name"
else
fail "bash shebang missing: $name"
fi
check
if grep -q '^set -euo pipefail$' "$target"; then
pass "strict mode (set -euo pipefail): $name"
else
fail "strict mode missing: $name"
fi
check
if bash -n "$target" 2>/dev/null; then
pass "bash parses: $name"
else
fail "bash parse error: $name"
fi
done
# The two entry points are invoked directly and symlinked into /usr/local/bin.
for target in "$BOOTSTRAP" "$CREDS"; do
[[ -f "$target" ]] || continue
check
if [[ -x "$target" ]]; then
pass "executable bit: ${target#"$REPO_ROOT"/}"
else
fail "not executable: ${target#"$REPO_ROOT"/}"
fi
done
# Negative control for the syntax gate: a broken script must be rejected.
printf '#!/usr/bin/env bash\nif true; then\n' > "$WORK/broken.sh"
check
if bash -n "$WORK/broken.sh" 2>/dev/null; then
fail 'negative control did not trip: bash -n accepted broken syntax'
else
pass 'negative control trips: bash -n rejects broken syntax'
fi
# --------------------------------------------------------------------------- #
# 2. --help works and documents the documented interface.
# --------------------------------------------------------------------------- #
check
if bash "$BOOTSTRAP" --help > "$WORK/bootstrap-help.out" 2>&1; then
pass 'bf-bootstrap.sh --help exits 0'
else
fail 'bf-bootstrap.sh --help failed'
fi
for flag in '--check' '--dir' '--branch' '--yes'; do
contract "bf-bootstrap --help documents $flag" "$WORK/bootstrap-help.out" has "\\${flag}"
done
check
if bash "$CREDS" --help > "$WORK/creds-help.out" 2>&1; then
pass 'bf-creds --help exits 0'
else
fail 'bf-creds --help failed'
fi
for flag in '--generate' '--fetch' '--repo' '--dealer-id'; do
contract "bf-creds --help documents $flag" "$WORK/creds-help.out" has "\\${flag}"
done
# Unknown arguments are refused with exit 2 (and never mutate anything).
set +e
bash "$BOOTSTRAP" --definitely-not-a-flag > "$WORK/bootstrap-bad.out" 2>&1
bootstrap_bad_rc=$?
bash "$CREDS" --definitely-not-a-flag > "$WORK/creds-bad.out" 2>&1
creds_bad_rc=$?
set -e
expect_rc 'bf-bootstrap.sh rejects an unknown argument with exit 2' 2 "$bootstrap_bad_rc"
expect_rc 'bf-creds rejects an unknown argument with exit 2' 2 "$creds_bad_rc"
# --------------------------------------------------------------------------- #
# 3. bf-bootstrap.sh static contracts (+ negative controls).
# --------------------------------------------------------------------------- #
contract 'bf-bootstrap checks for root before mutating' "$BOOTSTRAP" has 'id -u'
contract 'bf-bootstrap installs prerequisites with apt-get' "$BOOTSTRAP" has 'apt-get install'
contract 'bf-bootstrap prepares git/curl/ansible-core' "$BOOTSTRAP" has 'ansible-playbook'
contract 'bf-bootstrap clones the repository' "$BOOTSTRAP" has 'git clone'
contract 'bf-bootstrap updates an existing checkout fast-forward only' "$BOOTSTRAP" has 'merge --ff-only'
contract 'bf-bootstrap never rewrites the checkout history' "$BOOTSTRAP" lacks 'reset --hard|git clean'
contract 'bf-bootstrap links tools into /usr/local/bin' "$BOOTSTRAP" has '/usr/local/bin'
contract 'bf-bootstrap links idempotently' "$BOOTSTRAP" has 'ln -sfn'
contract 'bf-bootstrap discovers the admin tools' "$BOOTSTRAP" has 'admin/bf-\*'
contract 'bf-bootstrap discovers the diagnostics tools' "$BOOTSTRAP" has 'scripts/diagnostics/bf-\*'
contract 'bf-bootstrap discovers the maintenance tools' "$BOOTSTRAP" has 'scripts/maintenance/bf-\*'
contract 'bf-bootstrap wires up bf-menu' "$BOOTSTRAP" has 'bf-menu'
contract 'bf-bootstrap supports Ubuntu 24.04' "$BOOTSTRAP" has '24\.04'
contract 'bf-bootstrap supports Ubuntu 26.04' "$BOOTSTRAP" has '26\.04'
contract 'bf-bootstrap has a read-only check mode' "$BOOTSTRAP" has 'CHECK_MODE'
contract 'bf-bootstrap prints the completion contract' "$BOOTSTRAP" has 'Kurulum tamam\. Başlamak için: sudo bf-menu'
contract 'bf-bootstrap carries no private key material' "$BOOTSTRAP" lacks 'BEGIN [A-Z ]*PRIVATE KEY'
contract 'bf-bootstrap generates no WireGuard key' "$BOOTSTRAP" lacks 'wg genkey'
# Negative controls: break each load-bearing property and prove the check trips.
sed -E 's#/usr/local/bin#/usr/bin#g' "$BOOTSTRAP" > "$WORK/b-ins-local-bin.sh"
chmod 0755 "$WORK/b-ins-local-bin.sh"
control 'bf-bootstrap target directory is checked' "$WORK/b-ins-local-bin.sh" has '/usr/local/bin'
sed -E 's#merge --ff-only#reset --hard#g' "$BOOTSTRAP" > "$WORK/b-reset.sh"
chmod 0755 "$WORK/b-reset.sh"
control 'bf-bootstrap fast-forward requirement is checked' "$WORK/b-reset.sh" has 'merge --ff-only'
control 'bf-bootstrap history-rewrite ban is checked' "$WORK/b-reset.sh" lacks 'reset --hard|git clean'
sed -E 's#id -u#true#g' "$BOOTSTRAP" > "$WORK/b-no-root.sh"
chmod 0755 "$WORK/b-no-root.sh"
control 'bf-bootstrap root check is checked' "$WORK/b-no-root.sh" has 'id -u'
sed -E '/Kurulum tamam/d' "$BOOTSTRAP" > "$WORK/b-no-summary.sh"
chmod 0755 "$WORK/b-no-summary.sh"
control 'bf-bootstrap completion contract is checked' "$WORK/b-no-summary.sh" has 'Kurulum tamam\. Başlamak için: sudo bf-menu'
# --------------------------------------------------------------------------- #
# 4. bf-bootstrap.sh runtime: --check is mutation-free, apply mode works.
# --------------------------------------------------------------------------- #
# A sandbox PATH shim: `id` reports uid 0 (so the apply path can run unprivileged
# inside the sandbox) and `apt-get` only records the call. Nothing else changes.
SHIM="$WORK/shims"
mkdir -p "$SHIM"
cat > "$SHIM/id" <<'SHIMEOF'
#!/usr/bin/env bash
if [[ "${1:-}" == "-u" ]]; then printf '0\n'; exit 0; fi
printf 'uid=0(root) gid=0(root) groups=0(root)\n'
SHIMEOF
cat > "$SHIM/apt-get" <<'SHIMEOF'
#!/usr/bin/env bash
printf 'shim apt-get: %s\n' "$*" >> "${BF_SHIM_APT_LOG:-/dev/null}"
exit 0
SHIMEOF
chmod 0755 "$SHIM/id" "$SHIM/apt-get"
# Deterministic dependency-install branch: drop every PATH entry that provides
# ansible-playbook, so the bootstrap really sees a missing prerequisite and
# takes its apt-get path (against the shim, never the real package manager).
# git and curl must stay resolvable; when they would be filtered out too, the
# unmodified PATH is kept and the assertion below falls back to a note.
PATH_NO_ANSIBLE=""
while IFS= read -r path_dir; do
[[ -z "$path_dir" ]] && continue
[[ -x "$path_dir/ansible-playbook" ]] && continue
PATH_NO_ANSIBLE="${PATH_NO_ANSIBLE:+$PATH_NO_ANSIBLE:}$path_dir"
done < <(printf '%s\n' "$PATH" | tr ':' '\n')
APPLY_PATH="$SHIM:$PATH_NO_ANSIBLE"
if ! PATH="$APPLY_PATH" command -v git >/dev/null 2>&1 || ! PATH="$APPLY_PATH" command -v curl >/dev/null 2>&1; then
APPLY_PATH="$SHIM:$PATH"
fi
bootstrap_apply() { # <checkout-dir> <bin-dir> <out-file> <apt-log>
PATH="$APPLY_PATH" \
BF_SHIM_APT_LOG="$4" \
BF_BIN_DIR="$2" \
BF_INSTALL_DIR="$1" \
BF_REPO_URL="$REPO_ROOT" \
bash "$BOOTSTRAP" --yes < /dev/null > "$3" 2>&1
}
# 4a. --check must create nothing at all, even under a full sandbox prefix.
CHECK_ROOT="$WORK/check-root"
set +e
PATH="$APPLY_PATH" \
BF_BIN_DIR="$CHECK_ROOT/bin" \
BF_INSTALL_DIR="$CHECK_ROOT/checkout" \
BF_REPO_URL="$REPO_ROOT" \
bash "$BOOTSTRAP" --check < /dev/null > "$WORK/check.out" 2>&1
check_rc=$?
set -e
expect_rc 'bf-bootstrap --check exits 0' 0 "$check_rc"
expect_absent 'bf-bootstrap --check creates no checkout' "$CHECK_ROOT/checkout"
expect_absent 'bf-bootstrap --check creates no bin directory' "$CHECK_ROOT/bin"
expect_absent 'bf-bootstrap --check creates nothing under its target prefix' "$CHECK_ROOT"
expect_contains 'bf-bootstrap --check reports that nothing was changed' "$WORK/check.out" 'no changes were made'
if PATH="$APPLY_PATH" command -v ansible-playbook >/dev/null 2>&1; then
note 'ansible-playbook resolves even after PATH filtering: the dependency-plan assertion is limited to the static contract'
else
expect_contains 'check mode reports the dependency install it would perform' "$WORK/check.out" 'would install with apt-get'
fi
# 4b. --check against the real checkout must install nothing and must discover
# the admin tools (this is the only non-sandboxed run in the suite).
local_bin_before="$(ls -1 /usr/local/bin 2>/dev/null | sort | tr '\n' ' ')"
mode_before="$(stat -c '%a' "$BOOTSTRAP" "$CREDS" 2>/dev/null | tr '\n' ' ')"
set +e
bash "$BOOTSTRAP" --check < /dev/null > "$WORK/check-repo.out" 2>&1
check_repo_rc=$?
set -e
expect_rc 'bf-bootstrap --check runs unprivileged against the checkout' 0 "$check_repo_rc"
expect_contains 'check mode reports admin/bf-bootstrap.sh as a tool' "$WORK/check-repo.out" 'admin/bf-bootstrap.sh'
expect_contains 'check mode reports admin/bf-creds as a tool' "$WORK/check-repo.out" 'admin/bf-creds'
expect_contains 'check mode reports a diagnostics tool' "$WORK/check-repo.out" 'scripts/diagnostics/bf-status'
check
if [[ "$local_bin_before" == "$(ls -1 /usr/local/bin 2>/dev/null | sort | tr '\n' ' ')" ]]; then
pass 'bf-bootstrap --check installs nothing into /usr/local/bin'
else
fail 'bf-bootstrap --check wrote into /usr/local/bin'
fi
check
if [[ "$mode_before" == "$(stat -c '%a' "$BOOTSTRAP" "$CREDS" 2>/dev/null | tr '\n' ' ')" ]]; then
pass 'bf-bootstrap --check leaves tool modes unchanged'
else
fail 'bf-bootstrap --check changed tool modes'
fi
# 4c. Harness self-test: prove the "nothing was created" assertion can detect a
# mutation, so 4a is not a vacuous pass. A synthetic probe that only creates
# its target when check mode is bypassed must trip the very same assertion.
PROBE="$WORK/probe.sh"
cat > "$PROBE" <<'PROBEEOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--check" ]]; then
printf 'check only\n'
exit 0
fi
mkdir -p "$2"
printf 'created\n' > "$2/marker"
PROBEEOF
probe_ok="$WORK/probe-ok"
set +e
bash "$PROBE" --check "$probe_ok" >/dev/null 2>&1
set -e
expect_absent 'harness self-test: the probe creates nothing in check mode' "$probe_ok"
sed -E 's#--check#--never#g' "$PROBE" > "$WORK/probe-broken.sh"
probe_broken="$WORK/probe-broken"
set +e
bash "$WORK/probe-broken.sh" --check "$probe_broken" >/dev/null 2>&1
set -e
expect_present 'harness self-test control: the same assertion detects a mutation' "$probe_broken/marker"
# 4d. Apply mode in the sandbox (fresh clone path).
APPLY_ROOT="$WORK/apply-root"
mkdir -p "$APPLY_ROOT"
set +e
bootstrap_apply "$APPLY_ROOT/checkout" "$APPLY_ROOT/bin" "$WORK/apply1.out" "$WORK/apt1.log"
apply_rc=$?
set -e
expect_rc 'bf-bootstrap apply mode exits 0 in the sandbox' 0 "$apply_rc"
expect_present 'bf-bootstrap cloned the repository' "$APPLY_ROOT/checkout/.git"
expect_present 'bf-bootstrap linked a diagnostics tool' "$APPLY_ROOT/bin/bf-status"
expect_contains 'bf-bootstrap prints the completion contract' "$WORK/apply1.out" 'Kurulum tamam. Başlamak için: sudo bf-menu'
expect_contains 'bf-bootstrap reports the repository in its summary' "$WORK/apply1.out" "$APPLY_ROOT/checkout"
expect_contains 'bf-bootstrap warns when bf-menu is not in the clone' "$WORK/apply1.out" 'admin/bf-menu is not present'
linked1="$(find "$APPLY_ROOT/bin" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')"
check
if [[ "$linked1" -ge 10 ]]; then
pass "bf-bootstrap linked the tool set ($linked1 links)"
else
fail "bf-bootstrap linked too few tools ($linked1 links)"
fi
# The dependency branch runs against the apt-get shim, never the real apt.
if PATH="$APPLY_PATH" command -v ansible-playbook >/dev/null 2>&1; then
note 'ansible-playbook resolves even after PATH filtering: the dependency-install assertion is skipped'
else
expect_contains 'dependency install goes through apt' "$WORK/apt1.log" 'install -y --no-install-recommends'
expect_contains 'the package list names the missing prerequisite' "$WORK/apt1.log" 'ansible-core'
fi
# 4e. Idempotency: a second apply run must succeed and keep the same links.
set +e
bootstrap_apply "$APPLY_ROOT/checkout" "$APPLY_ROOT/bin" "$WORK/apply2.out" "$WORK/apt2.log"
apply2_rc=$?
set -e
expect_rc 'bf-bootstrap apply mode is idempotent on re-run' 0 "$apply2_rc"
linked2="$(find "$APPLY_ROOT/bin" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')"
check
if [[ "$linked1" == "$linked2" ]]; then
pass "re-run keeps the same tool set ($linked2 links)"
else
fail "re-run changed the tool set ($linked1 -> $linked2 links)"
fi
# 4f. An existing checkout is refreshed, not reset: local changes are reported
# and preserved, and admin/bf-* tools are linked from it.
SEEDED="$WORK/seeded"
git clone --quiet "$REPO_ROOT" "$SEEDED"
cp -a "$REPO_ROOT/admin" "$SEEDED/admin"
set +e
bootstrap_apply "$SEEDED" "$WORK/seeded-bin" "$WORK/apply3.out" "$WORK/apt3.log"
apply3_rc=$?
set -e
expect_rc 'bf-bootstrap apply mode accepts an existing checkout' 0 "$apply3_rc"
expect_present 'bf-bootstrap linked admin/bf-bootstrap.sh' "$WORK/seeded-bin/bf-bootstrap.sh"
expect_present 'bf-bootstrap linked admin/bf-creds' "$WORK/seeded-bin/bf-creds"
expect_contains 'bf-bootstrap reports local changes instead of resetting them' "$WORK/apply3.out" 'local changes present'
expect_present 'bf-bootstrap left the existing checkout intact' "$SEEDED/admin/bf-creds"
# 4g. Apply mode without root must refuse before touching anything.
if [[ "$(id -u)" -ne 0 ]]; then
ROOT_TARGET="$WORK/root-target"
set +e
BF_BIN_DIR="$WORK/root-bin" \
BF_INSTALL_DIR="$ROOT_TARGET" \
BF_REPO_URL="$REPO_ROOT" \
bash "$BOOTSTRAP" --yes < /dev/null > "$WORK/root.out" 2>&1
root_rc=$?
set -e
expect_rc 'bf-bootstrap refuses to apply without root' 1 "$root_rc"
expect_contains 'bf-bootstrap explains the root requirement' "$WORK/root.out" 'must run as root'
expect_absent 'the refused run created no checkout' "$ROOT_TARGET"
expect_absent 'the refused run created no bin directory' "$WORK/root-bin"
else
note 'running as root: the unprivileged refusal control is skipped'
fi
# 4h. A target directory that is not a git checkout must be refused untouched.
NOT_A_REPO="$WORK/not-a-repo"
mkdir -p "$NOT_A_REPO"
printf 'keep me\n' > "$NOT_A_REPO/precious.txt"
set +e
PATH="$SHIM:$PATH" \
BF_BIN_DIR="$WORK/not-a-repo-bin" \
BF_INSTALL_DIR="$NOT_A_REPO" \
BF_REPO_URL="$REPO_ROOT" \
bash "$BOOTSTRAP" --yes < /dev/null > "$WORK/not-a-repo.out" 2>&1
not_a_repo_rc=$?
set -e
expect_rc 'bf-bootstrap refuses a directory that is not a git checkout' 1 "$not_a_repo_rc"
expect_contains 'the refusal is explained' "$WORK/not-a-repo.out" 'not a git checkout'
expect_present 'the foreign directory was left untouched' "$NOT_A_REPO/precious.txt"
# --------------------------------------------------------------------------- #
# 5. bf-creds static contracts (+ negative controls).
# --------------------------------------------------------------------------- #
contract 'bf-creds is default-OFF by default' "$CREDS" has 'BF_GENERATE_CREDENTIALS:-0'
contract 'bf-creds has no default-ON regression' "$CREDS" lacks 'BF_GENERATE_CREDENTIALS:-1'
contract 'bf-creds accepts the explicit --generate opt-in' "$CREDS" has 'GENERATE=1'
contract 'bf-creds accepts the explicit --fetch opt-in' "$CREDS" has 'FETCH=1'
contract 'bf-creds marks generation disabled by default' "$CREDS" has 'DISABLED'
contract 'bf-creds never enables shell tracing' "$CREDS" lacks 'set -x'
contract 'bf-creds writes the record at mode 0600' "$CREDS_LIB" has 'BF_CREDENTIALS_MODE:=600'
contract 'bf-creds keeps the delivery path at admin/secrets' "$CREDS_LIB" has 'BF_SECRETS_SUBDIR:=admin/secrets'
contract 'bf-creds reports the WireGuard private key path only' "$CREDS" has 'private_key_path'
contract 'bf-creds documents that the SSH decision is unchanged' "$CREDS" has 'PasswordAuthentication no'
contract 'creds.sh forces 0600 on the written file' "$CREDS_LIB" has 'install -m "\$BF_CREDENTIALS_MODE"'
contract 'creds.sh forces 0700 on the delivery directory' "$CREDS_LIB" has 'install -d -m "\$BF_SECRETS_DIR_MODE"'
contract 'creds.sh refuses to write through a symlink' "$CREDS_LIB" has 'refusing to write through a symlink'
contract 'creds.sh never enables shell tracing' "$CREDS_LIB" lacks 'set -x'
# Mutation builder for bf-creds copies: the tool sources lib/creds.sh relative
# to its own real path, so a mutant needs a lib/ directory beside it.
creds_mutant() { # <name> <awk-program-file>
local dir="$WORK/mut-$1"
mkdir -p "$dir/lib"
ln -sfn "$CREDS_LIB" "$dir/lib/creds.sh"
awk -f "$2" "$CREDS" > "$dir/bf-creds"
chmod 0755 "$dir/bf-creds"
printf '%s' "$dir/bf-creds"
}
# Negative control: a default-ON variant must trip both default-off checks.
cat > "$WORK/default-on.awk" <<'AWKEOF'
{ gsub(/BF_GENERATE_CREDENTIALS:-0/, "BF_GENERATE_CREDENTIALS:-1"); print }
AWKEOF
DEFAULT_ON="$(creds_mutant default-on "$WORK/default-on.awk")"
control 'bf-creds default-off contract is checked' "$DEFAULT_ON" has 'BF_GENERATE_CREDENTIALS:-0'
control 'bf-creds default-ON ban is checked' "$DEFAULT_ON" lacks 'BF_GENERATE_CREDENTIALS:-1'
# Negative control: shell tracing (which would print secrets) must trip the ban.
mkdir -p "$WORK/mut-xtrace/lib"
ln -sfn "$CREDS_LIB" "$WORK/mut-xtrace/lib/creds.sh"
awk '{ print } END { print "set -x" }' "$CREDS" > "$WORK/mut-xtrace/bf-creds"
chmod 0755 "$WORK/mut-xtrace/bf-creds"
control 'bf-creds shell-tracing ban is checked' "$WORK/mut-xtrace/bf-creds" lacks 'set -x'
# Negative control: a leak of the generated secret must trip the leak detector.
cat > "$WORK/leak.awk" <<'AWKEOF'
/cannot generate a random secret/ {
print
print " printf \"leaked-credential=%s\\n\" \"$password\""
next
}
{ print }
AWKEOF
LEAK_MUTANT="$(creds_mutant leak "$WORK/leak.awk")"
# --------------------------------------------------------------------------- #
# 6. bf-creds runtime in a sandbox (identity, default-off, generate, fetch).
# --------------------------------------------------------------------------- #
CSB="$WORK/creds-sandbox"
mkdir -p "$CSB/wg"
FAKE_WG_PRIVATE='FAKEWGPRIVATE0000000000000000000000000000000='
FAKE_WG_PUBLIC='FAKEWGPUBLIC1111111111111111111111111111111='
FAKE_RUSTDESK='987654321'
printf '%s\n' "$FAKE_WG_PUBLIC" > "$CSB/wg/publickey"
printf '%s\n' "$FAKE_WG_PRIVATE" > "$CSB/wg/privatekey"
chmod 0600 "$CSB/wg/privatekey"
printf '%s\n' "$FAKE_RUSTDESK" > "$CSB/rustdesk-id"
printf '{ "phase": "PROVISIONED_OFFLINE", "device_id": "BF-12345678" }\n' > "$CSB/state.json"
CREDS_FILE="$CSB/credentials.txt"
CREDS_STATE_FILE="$CSB/state.json"
CREDS_DEVICE='BF-12345678'
# creds_env <script> <out-file> [args...]
creds_env() {
local script="$1" out="$2"
shift 2
BF_STATE_FILE="${CREDS_STATE_FILE:-$CSB/state.json}" \
BF_DEVICE="${CREDS_DEVICE-}" \
BF_WG_DIR="$CSB/wg" \
BF_RUSTDESK_ID_FILE="$CSB/rustdesk-id" \
BF_CREDENTIALS_FILE="$CREDS_FILE" \
bash "$script" "$@" > "$out" 2>&1
}
# 6a. Default behaviour: report only, no generation, and it says so.
CREDS_FILE="$CSB/default-run.txt"
set +e
creds_env "$CREDS" "$WORK/creds-default.out"
default_rc=$?
set -e
expect_rc 'bf-creds default run exits 0' 0 "$default_rc"
expect_absent 'bf-creds default run generates no credential file' "$CREDS_FILE"
expect_contains 'bf-creds states that generation is disabled by default' "$WORK/creds-default.out" 'DISABLED'
expect_contains 'bf-creds states that no password was generated' "$WORK/creds-default.out" 'no password was generated'
expect_absent_text 'bf-creds default run prints no credential line' "$WORK/creds-default.out" 'local_administrator_password'
expect_contains 'bf-creds reports the device identity' "$WORK/creds-default.out" 'device_id : BF-12345678'
expect_contains 'bf-creds reports the WireGuard public key' "$WORK/creds-default.out" "$FAKE_WG_PUBLIC"
expect_contains 'bf-creds reports the private key path' "$WORK/creds-default.out" "$CSB/wg/privatekey"
expect_contains 'bf-creds reports the RustDesk ID' "$WORK/creds-default.out" "$FAKE_RUSTDESK"
# Negative control for the default-off behaviour: the default-ON mutant must
# create the credential file without any flag, proving 6a is not vacuous.
CREDS_FILE="$WORK/default-on-run.txt"
set +e
creds_env "$DEFAULT_ON" "$WORK/creds-default-on.out"
set -e
expect_present 'control: the default-ON mutant generates without a flag' "$CREDS_FILE"
# 6b. --generate writes the record, and the secret never reaches stdout.
CREDS_FILE="$CSB/credentials.txt"
set +e
creds_env "$CREDS" "$WORK/creds-generate.out" --generate
generate_rc=$?
set -e
expect_rc 'bf-creds --generate exits 0' 0 "$generate_rc"
expect_present 'bf-creds --generate writes the credential record' "$CREDS_FILE"
expect_mode 'the credential record is mode 600' "$CREDS_FILE" 600
expect_contains 'the record carries the device id' "$CREDS_FILE" 'device_id: BF-12345678'
expect_contains 'the record carries the dealer number' "$CREDS_FILE" 'dealer_number: 12345678'
expect_contains 'the record carries the hostname' "$CREDS_FILE" 'hostname:'
expect_contains 'the record carries the creation time' "$CREDS_FILE" 'generated_at:'
expect_contains 'the record carries the WireGuard public key' "$CREDS_FILE" "$FAKE_WG_PUBLIC"
expect_contains 'the record carries the private key path' "$CREDS_FILE" "$CSB/wg/privatekey"
expect_contains 'the record states the private key is not recorded' "$CREDS_FILE" 'NOT RECORDED'
expect_absent_text 'the record never contains the WireGuard private key value' "$CREDS_FILE" "$FAKE_WG_PRIVATE"
expect_absent_text 'stdout never contains the WireGuard private key value' "$WORK/creds-generate.out" "$FAKE_WG_PRIVATE"
expect_contains 'stdout names the credential record' "$WORK/creds-generate.out" "$CREDS_FILE"
expect_contains 'stdout confirms the record was written' "$WORK/creds-generate.out" 'written'
GENERATED_PASSWORD="$(sed -n 's/^local_administrator_password:[[:space:]]*//p' "$CREDS_FILE" | head -n 1)"
check
if [[ ${#GENERATED_PASSWORD} -ge 16 ]]; then
pass "the generated password is long enough (${#GENERATED_PASSWORD} characters)"
else
fail "the generated password is too short (${#GENERATED_PASSWORD} characters)"
fi
check
if secret_leaked "$WORK/creds-generate.out" "$GENERATED_PASSWORD"; then
fail 'the generated password leaked to stdout'
else
pass 'the generated password never reaches stdout'
fi
check
if secret_leaked "$WORK/creds-generate.out" "$FAKE_WG_PRIVATE"; then
fail 'the WireGuard private key leaked to stdout'
else
pass 'the WireGuard private key never reaches stdout'
fi
# Negative control for the leak detector: the leak mutant must be detected.
CREDS_FILE="$WORK/leak-run.txt"
set +e
creds_env "$LEAK_MUTANT" "$WORK/creds-leak.out" --generate
set -e
LEAKED_PASSWORD="$(sed -n 's/^local_administrator_password:[[:space:]]*//p' "$CREDS_FILE" | head -n 1)"
check
if [[ -n "$LEAKED_PASSWORD" ]] && secret_leaked "$WORK/creds-leak.out" "$LEAKED_PASSWORD"; then
pass 'control: the leak detector finds a secret that is printed'
else
fail 'control: the leak detector did not find a printed secret (vacuous check)'
fi
# 6c. --fetch delivers the record next to the checkout at 0600/0700.
FETCH_REPO="$WORK/fetch-repo"
mkdir -p "$FETCH_REPO"
CREDS_FILE="$CSB/credentials.txt"
set +e
creds_env "$CREDS" "$WORK/creds-fetch.out" --fetch --repo "$FETCH_REPO"
fetch_rc=$?
set -e
FETCH_DEST="$FETCH_REPO/admin/secrets/12345678.txt"
expect_rc 'bf-creds --fetch exits 0' 0 "$fetch_rc"
expect_present 'bf-creds --fetch delivers the credential file' "$FETCH_DEST"
expect_mode 'the delivered file is mode 600' "$FETCH_DEST" 600
expect_mode 'the delivery directory is mode 700' "$FETCH_REPO/admin/secrets" 700
check
if cmp -s "$CSB/credentials.txt" "$FETCH_DEST"; then
pass 'the delivered file matches the credential record'
else
fail 'the delivered file differs from the credential record'
fi
expect_contains '--fetch reports only the delivery path' "$WORK/creds-fetch.out" "$FETCH_DEST"
expect_absent_text '--fetch never prints the generated password' "$WORK/creds-fetch.out" "$GENERATED_PASSWORD"
expect_absent_text '--fetch never prints the WireGuard private key' "$WORK/creds-fetch.out" "$FAKE_WG_PRIVATE"
# 6d. --fetch fails closed when the dealer number cannot be determined.
CREDS_FILE="$WORK/no-device/credentials.txt"
mkdir -p "$WORK/no-device"
cp "$CSB/credentials.txt" "$CREDS_FILE"
CREDS_STATE_FILE="$WORK/absent-state.json"
CREDS_DEVICE=''
set +e
creds_env "$CREDS" "$WORK/creds-nodevice.out" --fetch --repo "$FETCH_REPO"
nodevice_rc=$?
set -e
expect_rc 'bf-creds --fetch fails closed without a dealer number' 1 "$nodevice_rc"
expect_contains 'the failure explains the missing dealer number' "$WORK/creds-nodevice.out" 'cannot determine the dealer number'
CREDS_STATE_FILE="$CSB/state.json"
CREDS_DEVICE='BF-12345678'
# 6e. --dealer-id and --repo argument validation.
set +e
creds_env "$CREDS" "$WORK/creds-bad-dealer.out" --dealer-id 123 >/dev/null 2>&1
bad_dealer_rc=$?
creds_env "$CREDS" "$WORK/creds-bad-repo.out" --fetch --repo "$WORK/does-not-exist" >/dev/null 2>&1
bad_repo_rc=$?
set -e
expect_rc 'bf-creds rejects a malformed --dealer-id' 2 "$bad_dealer_rc"
expect_rc 'bf-creds rejects a --repo that does not exist' 2 "$bad_repo_rc"
# --------------------------------------------------------------------------- #
# 7. .gitignore: the delivery path is ignored, the tools are not.
# --------------------------------------------------------------------------- #
check
if git -C "$REPO_ROOT" check-ignore -q -- 'admin/secrets/12345678.txt'; then
pass 'admin/secrets/<dealer-number>.txt is git-ignored'
else
fail 'the bf-creds delivery path is NOT git-ignored (add admin/secrets/ to .gitignore)'
fi
# Positive control for `git check-ignore` itself: a pattern that is definitely
# present must be reported as ignored, so the assertion above cannot pass by
# accident.
check
if git -C "$REPO_ROOT" check-ignore -q -- 'probe/example.key'; then
pass 'control: git check-ignore evaluates this repository'
else
fail 'control: git check-ignore did not report a *.key path as ignored'
fi
# The tools themselves must stay tracked: an over-broad rule would hide them.
check
if git -C "$REPO_ROOT" check-ignore -q -- 'admin/bf-bootstrap.sh'; then
fail 'admin/bf-bootstrap.sh is git-ignored'
else
pass 'admin/bf-bootstrap.sh is not git-ignored'
fi
check
if git -C "$REPO_ROOT" check-ignore -q -- 'admin/lib/creds.sh'; then
fail 'admin/lib/creds.sh is git-ignored'
else
pass 'admin/lib/creds.sh is not git-ignored'
fi
contract '.gitignore has no over-broad admin or catch-all rule' "$GITIGNORE" lacks '^([*]|admin/?)$'
# --------------------------------------------------------------------------- #
# 8. The shipped admin files carry no secret-like material.
# --------------------------------------------------------------------------- #
# Heuristic for a *literal* credential assignment:
# * the separator is `:` or `=`,
# * the value is NOT a shell reference (`${...}`, `$(...)`),
# * the value contains a digit or a base64 symbol, which is what separates a
# credential literal from prose (a log message such as
# `bf_deliver_secret: destination required` must not be reported).
# A purely alphabetic secret would slip through: this is a guard against
# obviously planted material, not a replacement for the repository-wide scan.
SECRET_ASSIGNMENT='(^|[^A-Za-z])(password|passwd|token|api[_-]?key|secret)[[:space:]]*[:=][[:space:]]*[A-Za-z0-9+/=_.-]*[0-9+/=][A-Za-z0-9+/=_.-]*'
PRIVATE_KEY_BLOCK='BEGIN (OPENSSH|RSA|EC|DSA|PGP) PRIVATE KEY'
for target in "$BOOTSTRAP" "$CREDS" "$CREDS_LIB"; do
[[ -f "$target" ]] || continue
contract "no private key block in ${target#"$REPO_ROOT"/}" "$target" lacks_i "$PRIVATE_KEY_BLOCK"
contract "no secret assignment in ${target#"$REPO_ROOT"/}" "$target" lacks_i "$SECRET_ASSIGNMENT"
done
# Negative controls: a planted secret must be found in each file.
for target in "$BOOTSTRAP" "$CREDS"; do
[[ -f "$target" ]] || continue
planted="$WORK/planted-$(basename "$target")"
cp "$target" "$planted"
printf '%s\n' 'DEMO_TOKEN=abcd1234efgh' >> "$planted"
control "secret assignment detection in $(basename "$target")" "$planted" lacks_i "$SECRET_ASSIGNMENT"
printf '%s\n' '-----BEGIN OPENSSH PRIVATE KEY-----' >> "$planted"
control "private key detection in $(basename "$target")" "$planted" lacks_i "$PRIVATE_KEY_BLOCK"
done
# --------------------------------------------------------------------------- #
# Summary.
# --------------------------------------------------------------------------- #
if [[ "$FAIL" -ne 0 ]]; then
printf '\ncheck-admin-static: FAILED (%d checks)\n' "$CHECKS" >&2
exit 1
fi
printf '\ncheck-admin-static: all passed (%d checks)\n' "$CHECKS"