Unify Blueforce identity and Python operator surfaces

This commit is contained in:
emirhanmamak-blueforce 2026-09-17 13:22:26 +03:00
parent 708453677d
commit 5cc312f58a
89 changed files with 495 additions and 27523 deletions

View file

@ -80,7 +80,7 @@ flowchart TB
## Kurulum Akışı (özet) ## 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. > **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`, bkz. [`docs/33`](docs/33-OPERATOR-CONSOLE.md)). ISO/firstboot akışı offline saha için korunur.
```mermaid ```mermaid
flowchart LR flowchart LR
@ -168,7 +168,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 | | 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) | | 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 | | 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 | | 33 | `33-OPERATOR-CONSOLE.md` | Python operatör konsolu (`bf`, `bf --tui`) — git kurulumu + çalışma rehberi |
Şablon: `docs/_TEMPLATE.md` (siteye alınmaz). Karar günlüğü: `docs/24-DECISION-LOG.md`. Özet ve soru-cevap: `SUMMARY.md`. Şablon: `docs/_TEMPLATE.md` (siteye alınmaz). Karar günlüğü: `docs/24-DECISION-LOG.md`. Özet ve soru-cevap: `SUMMARY.md`.
@ -180,7 +180,7 @@ Tüm numaralı dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonun
├── SUMMARY.md # Yönetici özeti, 39 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) ├── IMPLEMENTATION-REPORT.md # Uygulama raporu (ne yapıldı / hangi testler geçti / LAB bekleyenler)
├── docs/ # 34 numaralı doküman (00–33) + _TEMPLATE.md ├── docs/ # 34 numaralı doküman (00–33) + _TEMPLATE.md
├── admin/ # git'ten tek komut kurulum (bf-bootstrap.sh, bf-creds, bf-menu) ├── admin/ # git'ten tek komut kurulum (bf-bootstrap.sh, bf-creds, bf)
├── provisioning/ # Field OS medya üretimi ├── provisioning/ # Field OS medya üretimi
│ ├── autoinstall/ # autoinstall.yaml + user-data + meta-data (interactive storage) │ ├── autoinstall/ # autoinstall.yaml + user-data + meta-data (interactive storage)
│ ├── iso/ # ISO doğrulama + build-blueforce-iso.sh (+ test-iso.sh) │ ├── iso/ # ISO doğrulama + build-blueforce-iso.sh (+ test-iso.sh)

View file

@ -54,7 +54,7 @@ Bu 12 soru + öğrenme rehberi sorusu, karar günlüğünde S-28…S-37 olarak i
[`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md), [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md) (K-16, K-17, K-21) [`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md), [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md) (K-16, K-17, K-21)
8. **Installer nasıl çalışacak?** — Tek giriş grafik operatör uygulamasıdır (`sudo bf` → `Kurulum` sekmesi → 8. **Installer nasıl çalışacak?** — Tek giriş grafik operatör uygulamasıdır (`sudo bf` → `Kurulum` sekmesi →
"Bu cihazı kur"); bu düğme `blueforce-install.sh --dealer-id <8hane>` motorunu çağırır ve paket deposunu "Bu cihazı kur"); bu düğme `blueforce-install.sh --dealer-id <8hane>` motorunu çağırır ve paket deposunu
otomatik bulur (`--offline` zorunlu değil). Yedek yol `sudo bf --tui`, acil yedek bash menü `sudo admin/bf-menu`. otomatik bulur (`--offline` zorunlu değil). Terminal yüzeyi `sudo bf --tui`.
Kurucu yalnız `PROVISIONED_OFFLINE` yazar. `READY`, installer çıkış kodu değil enrollment sonrası merkezi Kurucu yalnız `PROVISIONED_OFFLINE` yazar. `READY`, installer çıkış kodu değil enrollment sonrası merkezi
WireGuard, erişim ve monitoring doğrulamasının sonucudur. → WireGuard, erişim ve monitoring doğrulamasının sonucudur. →
[`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-21) [`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-21)

View file

@ -1,6 +1,6 @@
# Blueforce operator console (`bf-menu`) # Blueforce operator console (`bf`)
Numbered terminal console for the Blueforce field fleet. One entry point for the Python 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 operator: no command has to be memorised, every command is printed before it
runs, and the dangerous ones ask twice. runs, and the dangerous ones ask twice.
@ -20,7 +20,7 @@ not invent playbooks, and does not talk to devices itself: it selects the right
## 1. Requirements ## 1. Requirements
- Ubuntu LTS field device or admin workstation (the console is plain bash 4+). - Ubuntu LTS field device or admin workstation (the console is Python 3.8+).
- `git` for the checkout; `ansible-core`/`ansible` **only** on the machine that - `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 drives fleet operations (categories 2 and 3). A field device that only installs
itself needs neither Ansible nor an inventory. itself needs neither Ansible nor an inventory.
@ -36,7 +36,8 @@ git clone <repository-url> Blueforce-Linux
cd Blueforce-Linux cd Blueforce-Linux
sudo admin/bf-bootstrap.sh # optional: links the tools into /usr/local/bin sudo admin/bf-bootstrap.sh # optional: links the tools into /usr/local/bin
sudo admin/bf-menu # or: sudo bf-menu, once bootstrap ran sudo bf # after bootstrap
sudo bf --tui # explicit terminal surface
``` ```
An install is `git`-based on purpose: the running revision is traceable with An install is `git`-based on purpose: the running revision is traceable with

View file

@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
''''command -v python3 >/dev/null 2>&1 || { printf '%s\n' "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir." "" "Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu" >&2; exit 3; }; exec python3 "$0" "$@" # ''' ''''command -v python3 >/dev/null 2>&1 || { printf '%s\n' "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir." "" "Python kurulumu için Ubuntu paket yöneticisini kullanın." >&2; exit 3; }; exec python3 "$0" "$@" # '''
"""bfos operator console — thin entry point (sh/Python polyglot). """bfos operator console — thin entry point (sh/Python polyglot).
The file is valid POSIX shell *and* valid Python: The file is valid POSIX shell *and* valid Python:
@ -7,8 +7,7 @@ The file is valid POSIX shell *and* valid Python:
* run as ``admin/bf`` (or as the ``bf`` symlink that * run as ``admin/bf`` (or as the ``bf`` symlink that
``admin/bf-bootstrap.sh`` creates), the shell line above checks that ``admin/bf-bootstrap.sh`` creates), the shell line above checks that
``python3`` exists and, when it does not, prints a Turkish error and points at ``python3`` exists and, when it does not, prints a Turkish error and points at
the proven bash console (``admin/bf-menu``) — a device without Python is still the Python console is the single operator surface,
operational,
* everything after that line is the Python entry point: it puts the repository * everything after that line is the Python entry point: it puts the repository
root on ``sys.path`` and calls :func:`bfos.cli.main`. Nothing else lives here; root on ``sys.path`` and calls :func:`bfos.cli.main`. Nothing else lives here;
all logic is in the ``bfos`` package next to it. all logic is in the ``bfos`` package next to it.
@ -27,7 +26,7 @@ if sys.version_info < MIN_PYTHON: # pragma: no cover - depends on the interpret
sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1] sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1]
) )
) )
sys.stderr.write("Yedek yol (bash): sudo admin/bf-menu\n") sys.stderr.write("Python 3.8+ kurulumu gereklidir.\n")
raise SystemExit(3) raise SystemExit(3)
@ -51,7 +50,7 @@ if not ROOT:
sys.stderr.write( sys.stderr.write(
"HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n" "HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n"
"ya da --repo YOL ile depo yolunu belirtin.\n" "ya da --repo YOL ile depo yolunu belirtin.\n"
"Yedek yol (bash): sudo admin/bf-menu\n" "Python konsolunu depo kökünden çalıştırın veya --repo YOL kullanın.\n"
) )
raise SystemExit(3) raise SystemExit(3)

View file

@ -14,8 +14,7 @@
# fast-forward an existing checkout (never resets, never discards work), # fast-forward an existing checkout (never resets, never discards work),
# 5. link admin/bf-*, scripts/diagnostics/bf-* and scripts/maintenance/bf-* # 5. link admin/bf-*, scripts/diagnostics/bf-* and scripts/maintenance/bf-*
# into /usr/local/bin (idempotent; a re-run refreshes the links), # 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 # 6. make admin/bf executable so `sudo bf` starts the Python console,
# installer menu,
# 7. print a clear summary. # 7. print a clear summary.
# #
# Safety # Safety
@ -50,7 +49,7 @@ Usage: sudo bf-bootstrap.sh [--check] [--dir DIR] [--branch BRANCH] [--yes] [--h
Install (or refresh) the Blueforce Linux admin toolbox from git: fetch the Install (or refresh) the Blueforce Linux admin toolbox from git: fetch the
repository, install the prerequisites, link the bf-* tools into repository, install the prerequisites, link the bf-* tools into
/usr/local/bin, and make `sudo bf-menu` available for the on-device setup. /usr/local/bin, and make `sudo bf` available for the on-device setup.
Options: Options:
--check Read-only: report what would happen. It never mutates --check Read-only: report what would happen. It never mutates
@ -317,13 +316,9 @@ fi
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 6. Operator console entry point. # 6. Operator console entry point.
# #
# The Python console (`admin/bf` -> bfos) is the primary path; the bash # The Python console (`admin/bf` -> bfos) is the only operator path.
# `bf-menu` stays linked as a fallback so a host without python3 can still be
# operated.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
PY_CONSOLE="$DIR/admin/bf" PY_CONSOLE="$DIR/admin/bf"
MENU_SOURCE="$DIR/admin/bf-menu"
MENU_READY=0
if [[ -f "$PY_CONSOLE" ]]; then if [[ -f "$PY_CONSOLE" ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then if [[ "$CHECK_MODE" -eq 1 ]]; then
@ -333,28 +328,10 @@ if [[ -f "$PY_CONSOLE" ]]; then
ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf" ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf"
say "bf ready: $BIN_DIR/bf (Python console)" say "bf ready: $BIN_DIR/bf (Python console)"
fi fi
MENU_READY=1
else else
warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout" warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout"
fi fi
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 (bash fallback)"
fi
MENU_READY=1
else
warn "admin/bf-menu is not present in $DIR; the bash fallback will work once it is in the checkout"
fi
if [[ "$MENU_READY" -eq 0 ]]; then
warn "no operator console was found in $DIR; link one of admin/bf or admin/bf-menu manually"
fi
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 7. Summary. # 7. Summary.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -383,17 +360,10 @@ if [[ -x "$BIN_DIR/bf" ]]; then
else else
printf ' console : pending (admin/bf is not in the checkout yet)\n' printf ' console : pending (admin/bf is not in the checkout yet)\n'
fi fi
if [[ -x "$BIN_DIR/bf-menu" ]]; then
printf ' fallback : %s (bash)\n' "$BIN_DIR/bf-menu"
else
printf ' fallback : pending (admin/bf-menu is not in the checkout yet)\n'
fi
# The Python console is the recommended entry point; the bash menu stays as the
# fallback for a host without python3.
if [[ -x "$BIN_DIR/bf" ]]; then if [[ -x "$BIN_DIR/bf" ]]; then
printf ' next : sudo bf\n' printf ' next : sudo bf\n'
else else
printf ' next : sudo bf-menu\n' printf ' next : sudo admin/bf\n'
fi fi
printf '\nInstallation complete. To get started: sudo bf\n' printf '\nInstallation complete. To get started: sudo bf\n'
printf 'Kurulum tamam. Başlamak için: sudo bf\n' printf 'Kurulum tamam. Başlamak için: sudo bf\n'

File diff suppressed because it is too large Load diff

View file

@ -1,476 +0,0 @@
#!/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
}

View file

@ -1,216 +0,0 @@
#!/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"
}

View file

@ -54,7 +54,10 @@
remote_wg_interface: "{{ wg_interface | default('wg0') }}" remote_wg_interface: "{{ wg_interface | default('wg0') }}"
remote_wg_handshake_max_age: "{{ wg_handshake_max_age_seconds | default(180) }}" remote_wg_handshake_max_age: "{{ wg_handshake_max_age_seconds | default(180) }}"
remote_rustdesk_system_config: /etc/rustdesk/RustDesk2.toml remote_rustdesk_system_config: /etc/rustdesk/RustDesk2.toml
remote_rustdesk_user_config: /home/blueforce/.config/RustDesk/RustDesk2.toml remote_dealer_id_file: /etc/blueforce/dealer-id
remote_dealer_id: "{{ lookup('file', remote_dealer_id_file) | trim }}"
remote_bf_user: "bf-{{ remote_dealer_id }}"
remote_rustdesk_user_config: "/home/{{ remote_bf_user }}/.config/RustDesk/RustDesk2.toml"
remote_mesh_config: /etc/meshagent/meshagent.json remote_mesh_config: /etc/meshagent/meshagent.json
remote_status_bin: /usr/local/bin/bf-remote-status remote_status_bin: /usr/local/bin/bf-remote-status
pre_tasks: pre_tasks:

View file

@ -64,6 +64,10 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("-V", "--version", action="store_true", help="sürümü göster ve çık") parser.add_argument("-V", "--version", action="store_true", help="sürümü göster ve çık")
parser.add_argument("--gui", action="store_true", parser.add_argument("--gui", action="store_true",
help="grafik arayüzü aç (varsayılan davranış)") help="grafik arayüzü aç (varsayılan davranış)")
parser.add_argument("--install", action="store_true",
help="yalnızca bağımsız yerel kurulum yüzeyini aç")
parser.add_argument("--portal", action="store_true",
help="yalnızca bağımsız uzaktan yönetim yüzeyini aç")
parser.add_argument("--tui", action="store_true", parser.add_argument("--tui", action="store_true",
help="terminal konsolunu aç (grafik arayüz açılamıyorsa yedek)") help="terminal konsolunu aç (grafik arayüz açılamıyorsa yedek)")
parser.add_argument("--full", action="store_true", parser.add_argument("--full", action="store_true",
@ -1037,8 +1041,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.tui and args.gui: if args.tui and (args.gui or args.install or args.portal):
sys.stderr.write("HATA: --tui ve --gui birlikte kullanılamaz.\n") sys.stderr.write("HATA: --tui, --gui, --install ve --portal birlikte kullanılamaz.\n")
return config.EXIT_USAGE
if args.install and args.portal:
sys.stderr.write("HATA: --install ve --portal birlikte kullanılamaz.\n")
return config.EXIT_USAGE return config.EXIT_USAGE
if args.version: if args.version:
@ -1057,6 +1064,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
gui_argv: List[str] = [] gui_argv: List[str] = []
if args.full: if args.full:
gui_argv.append("--full") gui_argv.append("--full")
if args.install:
gui_argv.append("--install")
if args.portal:
gui_argv.append("--portal")
if args.dry_run: if args.dry_run:
gui_argv.append("--dry-run") gui_argv.append("--dry-run")
if args.repo: if args.repo:

View file

@ -72,7 +72,7 @@ DISK_WARN_MAX = 100
DEFAULT_REBOOT_GRACE = 300 DEFAULT_REBOOT_GRACE = 300
DEFAULT_LOG_LINES = 500 DEFAULT_LOG_LINES = 500
# Non-secret identity/lifecycle keys of state.json (whitelist, mirrors bf-menu). # Non-secret identity/lifecycle keys of state.json.
STATE_ALLOWED_KEYS = ("phase", "enrollment_status", "fleet_status", "device_id", "provisioning_id") STATE_ALLOWED_KEYS = ("phase", "enrollment_status", "fleet_status", "device_id", "provisioning_id")
# Central endpoint keys read from group_vars/all.yml for the preparation screens. # Central endpoint keys read from group_vars/all.yml for the preparation screens.
@ -184,11 +184,11 @@ MSG_NOT_A_TTY = "Bu ekran etkileşimli bir uçbirim gerektiriyor; çıkılıyor.
# Interpreter / environment # Interpreter / environment
MSG_PYTHON_MISSING = ( MSG_PYTHON_MISSING = (
"HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir.\n" "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir.\n"
"Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu" "Python konsolu için sudo bf kullanın."
) )
MSG_PYTHON_OLD = ( MSG_PYTHON_OLD = (
"HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n" "HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n"
"Yedek yol (bash): sudo admin/bf-menu" "Python terminal konsolu için sudo bf --tui kullanın."
) )
MSG_LANG_UNSUPPORTED = "Bu konsol yalnızca Türkçe çalışır; '{value}' desteklenmiyor. Kullanılabilir: {supported}." MSG_LANG_UNSUPPORTED = "Bu konsol yalnızca Türkçe çalışır; '{value}' desteklenmiyor. Kullanılabilir: {supported}."
MSG_REPO_INVALID = ( MSG_REPO_INVALID = (

44
bfos/identity.py Normal file
View file

@ -0,0 +1,44 @@
"""Canonical Blueforce device identity."""
from __future__ import annotations
import os
import re
from pathlib import Path
DEALER_ID_PATTERN = re.compile(r"^[0-9]{8}$")
DEALER_ID_FILE = Path("/etc/blueforce/dealer-id")
def validate_dealer_id(value: str) -> bool:
return bool(DEALER_ID_PATTERN.fullmatch(str(value).strip()))
def dealer_id_file(path: str | os.PathLike[str] | None = None) -> Path:
return Path(path) if path is not None else Path(
os.environ.get("BF_DEALER_ID_FILE", str(DEALER_ID_FILE))
)
def read_dealer_id(path: str | os.PathLike[str] | None = None) -> str:
value = dealer_id_file(path).read_text(encoding="utf-8").strip()
if not validate_dealer_id(value):
raise ValueError("dealer-id must contain exactly 8 digits")
return value
def identity_for(dealer_id: str) -> dict[str, str]:
dealer_id = str(dealer_id).strip()
if not validate_dealer_id(dealer_id):
raise ValueError("dealer-id must contain exactly 8 digits")
return {
"dealer_id": dealer_id,
"username": f"bf-{dealer_id}",
"hostname": f"bf-{dealer_id}",
"device_id": f"BF-{dealer_id}",
"active_user": f"bf-{dealer_id}",
}
def current_identity(path: str | os.PathLike[str] | None = None) -> dict[str, str]:
return identity_for(read_dealer_id(path))

View file

@ -1,7 +1,7 @@
"""Operation model for the bfos operator console. """Operation model for the bfos operator console.
This module is the behaviour contract of the console, ported from the proven This module is the behaviour contract of the console, ported from the proven
bash console (``admin/bf-menu`` + ``admin/lib/targets.sh``) so the two entry Python GUI and TUI surfaces so all entry
points can never disagree: points can never disagree:
* the 21 Ansible playbooks of ``ansible/playbooks/`` with their declared * the 21 Ansible playbooks of ``ansible/playbooks/`` with their declared
@ -189,7 +189,7 @@ ALL_OPERATIONS: Tuple[Operation, ...] = REPORT_OPERATIONS + MAINTENANCE_OPERATIO
#: Variables the playbooks use to express their own gates. The console derives #: Variables the playbooks use to express their own gates. The console derives
#: the scope from the playbook file and adds a variable only when the playbook #: the scope from the playbook file and adds a variable only when the playbook
#: actually declares it (a permanent parity rule with admin/bf-menu). #: actually declares it (a permanent parity rule with the Python catalogue).
VAR_OPERATION_WAVE = "operation_wave" VAR_OPERATION_WAVE = "operation_wave"
VAR_RELEASE_WAVE = "release_wave" VAR_RELEASE_WAVE = "release_wave"
VAR_OPERATION_CONFIRMED = "operation_confirmed" VAR_OPERATION_CONFIRMED = "operation_confirmed"
@ -230,7 +230,7 @@ def catalogue_rows() -> Tuple[Operation, ...]:
def non_comment_text(text: str) -> str: def non_comment_text(text: str) -> str:
"""The playbook text without full-line comments. """The playbook text without full-line comments.
Deliberately identical to ``grep -vE '^[[:space:]]*#'`` in bf-menu: a Deliberately small and deterministic parser: a
trailing comment is kept, so the console and the bash console can never trailing comment is kept, so the console and the bash console can never
disagree about what a playbook declares. disagree about what a playbook declares.
""" """

View file

@ -1,26 +1,30 @@
"""Small, local GUI preferences for the active Blueforce identity.""" """Compatibility adapter for the canonical system identity file."""
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from bfos.operations import dealer_is_valid from bfos.operations import dealer_is_valid
from bfos.identity import DEALER_ID_FILE, identity_for, read_dealer_id
PREFERENCES_FILENAME = "blueforce-gui.json" PREFERENCES_FILENAME = "blueforce-gui.json"
def preferences_path() -> Path: def preferences_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") return Path(os.environ.get("BF_DEALER_ID_FILE", str(DEALER_ID_FILE)))
if base:
return Path(base) / "blueforce" / PREFERENCES_FILENAME
return Path.home() / ".config" / "blueforce" / PREFERENCES_FILENAME
def load_preferences(path: Path | None = None) -> dict[str, str]: def load_preferences(path: Path | None = None) -> dict[str, str]:
target = path or preferences_path() target = path or preferences_path()
if path is None:
try:
return identity_for(read_dealer_id(target))
except (OSError, ValueError):
return {}
try: try:
data: Any = json.loads(target.read_text(encoding="utf-8")) data: Any = json.loads(target.read_text(encoding="utf-8"))
except (OSError, ValueError): except (OSError, ValueError):
@ -30,19 +34,38 @@ def load_preferences(path: Path | None = None) -> dict[str, str]:
dealer = str(data.get("dealer_id", "")).strip() dealer = str(data.get("dealer_id", "")).strip()
if not dealer_is_valid(dealer): if not dealer_is_valid(dealer):
return {} return {}
return {"dealer_id": dealer, "active_user": f"bf-{dealer}"} return identity_for(dealer)
def save_dealer(dealer_id: str, path: Path | None = None) -> dict[str, str]: def save_dealer(dealer_id: str, path: Path | None = None) -> dict[str, str]:
if not dealer_is_valid(dealer_id): if not dealer_is_valid(dealer_id):
raise ValueError("Bayi numarası tam 8 rakam olmalıdır.") raise ValueError("Bayi numarası tam 8 rakam olmalıdır.")
target = path or preferences_path() target = path or preferences_path()
identity = identity_for(dealer_id)
if path is None and target.exists():
existing = read_dealer_id(target)
if existing != dealer_id:
raise PermissionError(
f"Bayi kimliği {existing} olarak kilitli; değiştirmek için açık geçiş gerekir."
)
if path is None:
target.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(prefix="dealer-id.", dir=str(target.parent))
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(dealer_id + "\n")
os.replace(temporary_name, target)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return identity
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
data = {"dealer_id": dealer_id, "active_user": f"bf-{dealer_id}"} data = {"dealer_id": dealer_id, "active_user": f"bf-{dealer_id}"}
temporary = target.with_suffix(target.suffix + ".tmp") temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temporary, target) os.replace(temporary, target)
return data return identity
def clear_dealer(path: Path | None = None) -> None: def clear_dealer(path: Path | None = None) -> None:

View file

@ -7,7 +7,7 @@ Separation of duties:
executes it with :mod:`subprocess`, reports the exit code, and writes the executes it with :mod:`subprocess`, reports the exit code, and writes the
audit record. audit record.
Guarantees kept from the bash console (``admin/bf-menu``): Guarantees shared by the Python GUI and TUI:
* the command is always printed **before** it is confirmed and run, * the command is always printed **before** it is confirmed and run,
* ``--dry-run`` executes nothing at all, * ``--dry-run`` executes nothing at all,

View file

@ -2,7 +2,7 @@
Ne yapar Ne yapar
-------- --------
`admin/bf-menu` terminal konsolunun yaptigi isi ayni is mantigi uzerinden bir Python TUI konsolunun yaptigi isi ayni is mantigi uzerinden bir
gercek pencereye tasir: gercek pencereye tasir:
* basit mod : uc buyuk buton -- Kur / Durum / Cikis (+ "Detayli mod"), * basit mod : uc buyuk buton -- Kur / Durum / Cikis (+ "Detayli mod"),
@ -89,7 +89,7 @@ dogrudan onlari kullanir; hicbir kural kopyalanmaz:
salt-okunur ekranlar: targets_lines / central_*_lines / identity_lines / salt-okunur ekranlar: targets_lines / central_*_lines / identity_lines /
state_lines / environment_lines state_lines / environment_lines
Gercek moduller yoksa dosya kendi YERLESIK KATALOG'unu kullanir: bu katalog Gercek moduller yoksa dosya kendi YERLESIK KATALOG'unu kullanir: bu katalog
admin/bf-menu'nun tablosuyla (id, playbook, kapsam, yikici, ek degisken kanonik işlem kataloğuyla (id, playbook, kapsam, yıkıcı, ek değişken
kurallari) birebir ayni kurallara ve ayni komut uretimine dayanir. kurallari) birebir ayni kurallara ve ayni komut uretimine dayanir.
====================================================================== ======================================================================
@ -191,7 +191,7 @@ SCOPE_LABELS: Mapping[str, str] = {
"release": "filo — sürüm kapılı (onaylı dalga)", "release": "filo — sürüm kapılı (onaylı dalga)",
} }
# admin/lib/targets.sh + admin/bf-menu ile ayni kurallar. # ortak kimlik ve hedef kurallarıyla ayni kurallar.
SERVICE_ALLOWLIST: tuple[str, ...] = ( SERVICE_ALLOWLIST: tuple[str, ...] = (
"blueforce-agent", "blueforce-agent",
"docker", "docker",
@ -228,12 +228,10 @@ CHECK_CAPABLE_LOCAL_OPS: tuple[str, ...] = (
DEFAULT_CONSOLE_LOG = "/var/log/blueforce-console.log" DEFAULT_CONSOLE_LOG = "/var/log/blueforce-console.log"
INSTALLER_REL = "scripts/install/blueforce-install.sh" INSTALLER_REL = "scripts/install/blueforce-install.sh"
PLAYBOOK_DIR_REL = "ansible/playbooks" PLAYBOOK_DIR_REL = "ansible/playbooks"
MENU_REL = "admin/bf-menu"
TK_PACKAGE_HINT = "sudo apt install -y python3-tk" TK_PACKAGE_HINT = "sudo apt install -y python3-tk"
# Grafik arayüz açılamadığında kullanılacak yol: aynı bfos uygulamasının # Grafik arayüz açılamadığında kullanılacak yol: aynı bfos uygulamasının
# terminal konsolu. Bash menü (bf-menu) hâlâ yedek olarak durur ama Python # terminal konsolu. GUI ve TUI aynı Python backend'ini kullanır.
# konsolu ana yoldur, o yüzden önce o önerilir.
TERMINAL_MENU_HINT = "sudo bf --tui" TERMINAL_MENU_HINT = "sudo bf --tui"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -406,7 +404,7 @@ def operation_is_write(operation) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass(frozen=True) @dataclass(frozen=True)
class OptionSpec: class OptionSpec:
"""Bir islemin ek parametresi (bf-menu'deki -e degiskenleri).""" """Bir işlemin ek parametresi (Ansible -e değişkenleri)."""
name: str name: str
label: str label: str
@ -518,7 +516,7 @@ class CommandPlan:
@property @property
def lines(self) -> tuple[str, ...]: def lines(self) -> tuple[str, ...]:
"""Kopyala/yapistir edilebilir komut satirlari (bf-menu %q ile ayni fikir).""" """Kopyala/yapıştır edilebilir komut satırları."""
return tuple(shlex.join(argv) for argv in self.commands if argv) return tuple(shlex.join(argv) for argv in self.commands if argv)
@property @property
@ -798,8 +796,7 @@ def operation_is_destructive(operation_id: str) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Yerlesik katalog -- admin/bf-menu tablosuyla ayni id/kapsam/yikici degerleri # Yerleşik katalog -- ortak Python katalog kuralları.
# (kaynak: admin/bf-menu BF_OPS_REPORT / BF_OPS_MAINTENANCE)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_FLEET_OPTIONS: Mapping[str, tuple[OptionSpec, ...]] = { _FLEET_OPTIONS: Mapping[str, tuple[OptionSpec, ...]] = {
"disk": ( "disk": (
@ -1061,7 +1058,7 @@ def operations_by_category(category: str) -> tuple[Operation, ...]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Yardimci: yol cozumleme (admin/bf-menu ile ayni kurallar) # Yardımcı: yol çözümleme.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def repo_root_from_file() -> Path: def repo_root_from_file() -> Path:
"""bfos/ui_gui.py -> depo koku (bir ust dizin).""" """bfos/ui_gui.py -> depo koku (bir ust dizin)."""
@ -1081,7 +1078,7 @@ def resolve_inventory(repo_root: Path, explicit: str | None = None) -> Path | No
def resolve_tool(name: str, repo_root: Path | None = None) -> str | None: def resolve_tool(name: str, repo_root: Path | None = None) -> str | None:
"""admin/bf-menu bf_tool_path ile ayni sira: /usr/local/bin, sonra depo.""" """Araç yolu sırası: /usr/local/bin, sonra depo."""
system = Path("/usr/local/bin") / name system = Path("/usr/local/bin") / name
if system.is_file() and os.access(system, os.X_OK): if system.is_file() and os.access(system, os.X_OK):
return str(system) return str(system)
@ -1661,15 +1658,6 @@ class CatalogueBackend:
) )
return str(installer) return str(installer)
def _menu_arg(self) -> str:
menu = self.ctx.repo_root / MENU_REL
if not menu.is_file():
raise CommandError(
f"Konsol betiği bulunamadı: {MENU_REL}\n"
"Depo kopyasını doğru verin (--repo PATH)."
)
return str(menu)
def _tool_argv(self, tool: str, *args: str, optional: bool = False) -> tuple[str, ...] | None: def _tool_argv(self, tool: str, *args: str, optional: bool = False) -> tuple[str, ...] | None:
path = resolve_tool(tool, self.ctx.repo_root) path = resolve_tool(tool, self.ctx.repo_root)
if path is None: if path is None:
@ -2532,7 +2520,7 @@ def load_backend(ctx: GuiContext):
return ( return (
CatalogueBackend(ctx), CatalogueBackend(ctx),
f"bfos.operations bulunamadı ({type(exc).__name__}); yerleşik katalog " f"bfos.operations bulunamadı ({type(exc).__name__}); yerleşik katalog "
"kullanılıyor (admin/bf-menu kuralları).", "kullanılıyor (ortak Python kuralları).",
) )
runner_module = None runner_module = None
try: try:
@ -2686,7 +2674,7 @@ class OutputPane:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Arayuz # Arayuz
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) class _LegacyBfGui: # pragma: no cover - compatibility-only, not an entry point
"""Blueforce Field OS Turkce masaustu arayuzu. """Blueforce Field OS Turkce masaustu arayuzu.
Yerlesim docs/DESIGN.md "Direction contract" yonergesine gore kurulur: Yerlesim docs/DESIGN.md "Direction contract" yonergesine gore kurulur:

View file

@ -7,7 +7,9 @@ state crosses the boundary.
from __future__ import annotations from __future__ import annotations
import os
import queue import queue
import subprocess
import threading import threading
import tkinter as tk import tkinter as tk
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
@ -124,9 +126,15 @@ class InstallerSurface(SurfaceFrame):
self.preferences = load_preferences() self.preferences = load_preferences()
self.dealer_var.set(self.preferences.get("dealer_id", "")) self.dealer_var.set(self.preferences.get("dealer_id", ""))
self.dealer_locked = bool(self.preferences.get("dealer_id")) self.dealer_locked = bool(self.preferences.get("dealer_id"))
self.has_root = getattr(os, "geteuid", lambda: 0)() == 0
self.identity_var = tk.StringVar(value=self._identity_text()) self.identity_var = tk.StringVar(value=self._identity_text())
self.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed()) self.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed())
self.status_var = tk.StringVar(value="Hazır — bayi numarası bekleniyor") initial_status = (
"Hazır — bayi numarası bekleniyor"
if self.has_root
else "Yönetici yetkisi yok — kurulum için uygulamayı 'sudo bf' ile açın"
)
self.status_var = tk.StringVar(value=initial_status)
self.output_queue: queue.Queue[str] = queue.Queue() self.output_queue: queue.Queue[str] = queue.Queue()
self._render() self._render()
self._drain_output() self._drain_output()
@ -164,8 +172,12 @@ class InstallerSurface(SurfaceFrame):
state="normal" if self.dealer_locked else "disabled") state="normal" if self.dealer_locked else "disabled")
self.change_dealer_button.grid(row=2, column=1, sticky="w", padx=theme.SP_3, self.change_dealer_button.grid(row=2, column=1, sticky="w", padx=theme.SP_3,
pady=(theme.SP_4, theme.SP_2)) pady=(theme.SP_4, theme.SP_2))
ttk.Button(main, text="Ön kontrolü çalıştır", style=self.styles["accent_button"], self.install_button = ttk.Button(
command=self._run_install).grid(row=3, column=0, sticky="w", padx=theme.SP_5, pady=theme.SP_4) main, text="Kurulumu başlat", style=self.styles["accent_button"],
command=self._run_install,
state="normal" if self.has_root else "disabled",
)
self.install_button.grid(row=3, column=0, sticky="w", padx=theme.SP_5, pady=theme.SP_4)
ttk.Label(main, textvariable=self.status_var, style=self.styles["panel_muted"]).grid( ttk.Label(main, textvariable=self.status_var, style=self.styles["panel_muted"]).grid(
row=4, column=0, sticky="w", padx=theme.SP_5, pady=(0, theme.SP_4)) row=4, column=0, sticky="w", padx=theme.SP_5, pady=(0, theme.SP_4))
@ -180,14 +192,41 @@ class InstallerSurface(SurfaceFrame):
command=self._back).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) command=self._back).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5))
def _run_install(self) -> None: def _run_install(self) -> None:
if not self.has_root:
self.status_var.set("Kurulum engellendi — yönetici yetkisi için: sudo bf")
self._append(
"[ENGELLENDİ] Kurulum yönetici yetkisi ister.\n"
"Bu uygulamayı terminalden 'sudo bf' ile yeniden başlatın.\n"
)
return
dealer = self.dealer_var.get().strip() dealer = self.dealer_var.get().strip()
problem = dealer_error(dealer) problem = dealer_error(dealer)
if problem: if problem:
self.status_var.set(problem) self.status_var.set(problem)
return return
if self.preferences.get("dealer_id") and self.preferences["dealer_id"] != dealer:
if not messagebox.askyesno(
"Kimlik geçişini onaylayın",
"Mevcut Linux hesabı, SSH anahtarı, RustDesk ve sudo sahiplikleri yeni kimliğe taşınacak. Devam edilsin mi?",
default="no",
):
self.status_var.set("Kimlik geçişi iptal edildi; mevcut kimlik korundu.")
return
script = self.ctx.repo_root / "scripts" / "identity" / "bf-reidentity"
if not script.is_file():
self.status_var.set("Kimlik geçiş komutu bulunamadı.")
return
self.status_var.set("Kimlik geçişi çalışıyor…")
result = subprocess.run([str(script), dealer], capture_output=True, text=True, check=False)
self._append(result.stdout + result.stderr)
if result.returncode != 0:
self.status_var.set("Kimlik geçişi başarısız; eski kimlik korundu.")
return
saved = load_preferences()
else:
try: try:
saved = save_dealer(dealer) saved = save_dealer(dealer)
except OSError as exc: except (OSError, PermissionError, ValueError) as exc:
self.status_var.set(f"Ayar kaydedilemedi: {exc}") self.status_var.set(f"Ayar kaydedilemedi: {exc}")
return return
self.ctx.active_dealer = saved["dealer_id"] self.ctx.active_dealer = saved["dealer_id"]

View file

@ -13,7 +13,8 @@ KbdInteractiveAuthentication no
PubkeyAuthentication yes PubkeyAuthentication yes
# Only the maintenance user may open SSH sessions. # Only the maintenance user may open SSH sessions.
AllowUsers blueforce # Identity-specific SSH policy is generated by installer module 07-ssh as
# /etc/ssh/sshd_config.d/99-bf-<dealer-id>.conf.
# Reduce idle-session and brute-force exposure. # Reduce idle-session and brute-force exposure.
X11Forwarding no X11Forwarding no

View file

@ -1,6 +0,0 @@
node_modules/
build/
.docusaurus/
# Generated content (recreated by sync-docs.sh on prestart/prebuild).
docs/*.md
!docs/.gitkeep

View file

View file

@ -1,19 +0,0 @@
# Blueforce docs site (Docusaurus)
Static documentation is synced one-way from `../docs/*.md` into `./docs/`; never edit generated `./docs/` directly.
## Local development
```bash
cd docs-site
npm install
npm start # http://127.0.0.1:3000 (loopback only)
npm run build
npm run serve # local static preview on 127.0.0.1:3000
```
For an explicitly approved intranet preview, use `npm run start:intranet` or `npm run serve:intranet`; those commands bind `0.0.0.0:3000`.
## Deploy
Publish `build/` through the intranet web server. The site has no favicon reference until a maintained favicon asset is added.

View file

@ -1,3 +0,0 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};

View file

@ -1,30 +0,0 @@
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
// Blueforce fleet documentation site. Content source: ../docs/*.md.
const config: Config = {
title: 'Blueforce Docs',
tagline: 'Fleet runbooks and architecture',
url: 'http://docs.blueforce.intranet',
baseUrl: '/',
onBrokenLinks: 'throw',
i18n: { defaultLocale: 'tr', locales: ['tr'] },
markdown: {
mermaid: true,
hooks: { onBrokenMarkdownLinks: 'warn' },
},
themes: ['@docusaurus/theme-mermaid', '@easyops-cn/docusaurus-search-local'],
presets: [
['classic', {
docs: { path: 'docs', routeBasePath: '/', sidebarPath: './sidebars.js' },
blog: false,
theme: { customCss: './src/css/custom.css' },
} satisfies Preset.Options],
],
themeConfig: {
colorMode: { defaultMode: 'dark', disableSwitch: false, respectPrefersColorScheme: false },
navbar: { title: 'Blueforce Docs', items: [{ type: 'docSidebar', sidebarId: 'docs', position: 'left', label: 'Docs' }] },
} satisfies Preset.ThemeConfig,
};
export default config;

20121
docs-site/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
{
"name": "blueforce-docs",
"version": "0.1.0",
"private": true,
"description": "Blueforce fleet documentation site. Source of truth is ../docs/*.md; this package only builds the site.",
"scripts": {
"sync": "./sync-docs.sh",
"prestart": "./sync-docs.sh",
"prebuild": "./sync-docs.sh",
"start": "docusaurus start --host 127.0.0.1 --port 3000",
"start:intranet": "docusaurus start --host 0.0.0.0 --port 3000",
"build": "docusaurus build",
"serve": "docusaurus serve build --host 127.0.0.1 --port 3000",
"serve:intranet": "docusaurus serve build --host 0.0.0.0 --port 3000",
"clear": "docusaurus clear"
},
"dependencies": {
"@docusaurus/core": "3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@docusaurus/theme-mermaid": "3.10.2",
"@easyops-cn/docusaurus-search-local": "0.49.2",
"@mermaid-js/layout-elk": "0.1.9",
"@mdx-js/react": "3.0.1",
"clsx": "2.1.1",
"prism-react-renderer": "2.4.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": { "typescript": "5.5.4" },
"engines": { "node": ">=18.0" }
}

View file

@ -1,9 +0,0 @@
// Auto-generated sidebar: every synced Markdown file appears without manual entries.
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
docs: [{type: 'autogenerated', dirName: '.'}],
};
export default sidebars;

View file

@ -1,4 +0,0 @@
/* Blueforce docs theme overrides. Dark mode is the default (see docusaurus.config.ts). */
:root {
--ifm-color-primary: #4aa8ff;
}

View file

@ -1,19 +0,0 @@
import React from 'react';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
export default function Home() {
const {siteConfig} = useDocusaurusContext();
return (
<Layout title={siteConfig.title} description="Blueforce fleet documentation">
<main style={{padding: '4rem 2rem', textAlign: 'center'}}>
<h1>{siteConfig.title}</h1>
<p>{siteConfig.tagline}</p>
<Link className="button button--primary button--lg" to="/MASTER-PLAN">
Open docs
</Link>
</main>
</Layout>
);
}

View file

@ -1,29 +0,0 @@
#!/usr/bin/env bash
# One-way sync: repo docs (../docs/*.md) -> site content (./docs/).
# The repo Markdown files are the single source of truth; never edit ./docs by hand.
set -euo pipefail
SITE_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_DIR="$SITE_DIR/../docs"
DST_DIR="$SITE_DIR/docs"
mkdir -p "$DST_DIR"
# Copy only numbered canonical sources; docs/_TEMPLATE.md is never site content.
shopt -s nullglob
sources=("$SRC_DIR"/[0-9][0-9]-*.md)
if [ "${#sources[@]}" -eq 0 ]; then
echo "No numbered Markdown sources found in $SRC_DIR" >&2
exit 1
fi
cp -f "${sources[@]}" "$DST_DIR"/
# Remove stale or non-numbered site documents, including _TEMPLATE.md.
for f in "$DST_DIR"/*.md; do
base="$(basename "$f")"
if [[ ! "$base" =~ ^[0-9][0-9]-.*\.md$ ]] || [ ! -f "$SRC_DIR/$base" ]; then
rm -f "$f"
fi
done
echo "Synced ${#sources[@]} numbered files into docs-site/docs."

View file

@ -1,6 +0,0 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
}
}

View file

@ -23,9 +23,9 @@ Teknisyen terminal komutu, bayrak veya dosya yolu ezberlemez: kurulum grafik ara
## 3. Kararlar ## 3. Kararlar
```text ```text
KARAR: Saha kurulumunun tek giriş noktası grafik operatör uygulamasıdır: `sudo bf` → Kurulum sekmesi → "Bu cihazı kur". Kurucu betik (`scripts/install/blueforce-install.sh`) bu düğmenin çağırdığı motordur ve aynı akış terminalden `sudo bf --tui` ile de yürütülebilir. Eski bash menü (`admin/bf-menu`) yedek yoldur, ana yol değildir. KARAR: Saha kurulumunun tek giriş noktası grafik operatör uygulamasıdır: `sudo bf` → Kurulum sekmesi → "Bu cihazı kur". Kurucu betik (`scripts/install/blueforce-install.sh`) bu düğmenin çağırdığı motordur ve aynı akış terminalden `sudo bf --tui` ile de yürütülebilir.
GEREKÇE: Tek giriş noktası ve tek düğme teknisyen hatasını en aza indirir; grafik arayüz komutu çalıştırmadan önce gösterir ve onay ister, böylece görünmez komut kalmaz. Bayi no `^[0-9]{8}$` ile doğrulanır; format kapısı yanlış kimliğin 9 sisteme yayılmasını baştan engeller (K-12). GEREKÇE: Tek giriş noktası ve tek düğme teknisyen hatasını en aza indirir; grafik arayüz komutu çalıştırmadan önce gösterir ve onay ister, böylece görünmez komut kalmaz. Bayi no `^[0-9]{8}$` ile doğrulanır; format kapısı yanlış kimliğin 9 sisteme yayılmasını baştan engeller (K-12).
ALTERNATİF: Yalnız bash menülü kurulum (`admin/bf-menu`) — grafik arayüz yoksa çalışır ama sahadaki ana yolu oluşturmaz; yedek olarak korundu. Çok scriptli kurulum (ağ.sh, gui.sh, …) — sıra hatası ve atlanan adım riski nedeniyle elendi. ALTERNATİF: Çok scriptli kurulum (ağ.sh, gui.sh, …) — sıra hatası ve atlanan adım riski nedeniyle elendi.
RİSK: Tek dosya büyüdükçe bakımı zorlaşır; azaltma: 18 bağımsız modül + ana orkestratör ayrımı. Grafik arayüz `python3-tk` gerektirir; azaltma: paket offline depoda pinlidir ve ekransız/paketsiz ortamda uygulama Türkçe yönlendirme yapıp `--tui` önerir. RİSK: Tek dosya büyüdükçe bakımı zorlaşır; azaltma: 18 bağımsız modül + ana orkestratör ayrımı. Grafik arayüz `python3-tk` gerektirir; azaltma: paket offline depoda pinlidir ve ekransız/paketsiz ortamda uygulama Türkçe yönlendirme yapıp `--tui` önerir.
MALİYET: Ücretsiz. MALİYET: Ücretsiz.
LİSANS: Yok (şirket içi script + standart kütüphane ile yazılmış Python arayüz). LİSANS: Yok (şirket içi script + standart kütüphane ile yazılmış Python arayüz).
@ -49,7 +49,7 @@ LİSANS: Yok.
| Alternatif | Artı | Eksi | Sonuç | | Alternatif | Artı | Eksi | Sonuç |
|---|---|---|---| |---|---|---|---|
| Grafik arayüz + tek kurucu betik | Tek giriş, komut ezberi yok, onay görünür | Grafik katman gerekir (`python3-tk`, ekran) | **Seçildi** | | Grafik arayüz + tek kurucu betik | Tek giriş, komut ezberi yok, onay görünür | Grafik katman gerekir (`python3-tk`, ekran) | **Seçildi** |
| Yalnız bash menülü kurulum (`admin/bf-menu`) | Grafik katman gerekmez | Ana yol olarak terminal bilgisi ister; ekran yok | Yedek yol | | Python TUI (`sudo bf --tui`) | Grafik katman gerekmez | Terminal bilgisi ister | Kanonik terminal yüzeyi |
| Çok scriptli kurulum | Küçük dosyalar | Sıra/atlama hatası | Elendi | | Çok scriptli kurulum | Küçük dosyalar | Sıra/atlama hatası | Elendi |
| Non-idempotent tek script | Daha basit kod | Kesintide temiz-kurulum zorunluluğu | Elendi | | Non-idempotent tek script | Daha basit kod | Kesintide temiz-kurulum zorunluluğu | Elendi |
| İnteraktif sihirbaz (çok sorulu) | Esnek | Teknisyen hatası, otomasyona kapalı | Elendi | | İnteraktif sihirbaz (çok sorulu) | Esnek | Teknisyen hatası, otomasyona kapalı | Elendi |
@ -104,7 +104,6 @@ sudo bf --tui # aynı uygulamanın terminal arayüzü (grafik ekran yoksa /
- `DISPLAY` ve `WAYLAND_DISPLAY` tanımlı değilse (SSH, başsız kurulum) uygulama Türkçe açıklama yazar ve **çıkış kodu 3** ile `sudo bf --tui` önerir. - `DISPLAY` ve `WAYLAND_DISPLAY` tanımlı değilse (SSH, başsız kurulum) uygulama Türkçe açıklama yazar ve **çıkış kodu 3** ile `sudo bf --tui` önerir.
- `python3-tk` kurulu değilse (`tkinter` import edilemiyor) aynı şekilde Türkçe hata + `python3-tk` kurulum ipucu verir ve **çıkış kodu 3** ile terminal konsolunu önerir. - `python3-tk` kurulu değilse (`tkinter` import edilemiyor) aynı şekilde Türkçe hata + `python3-tk` kurulum ipucu verir ve **çıkış kodu 3** ile terminal konsolunu önerir.
- Eski bash menü `sudo admin/bf-menu` **yalnız yedek yoldur**; dokümanlarda ve iş akışında ana yol olarak kullanılmaz.
### 3. Yetki ### 3. Yetki

View file

@ -164,7 +164,7 @@ Yükseltme: WG kalkmıyorsa RustDesk/MeshCentral ile devam (17-L4); onlar de yok
| 3 | `DISPLAY` var ama X/Wayland erişilemiyor (ssh -X yok, oturum kilitli) | "Pencere açılamadı; grafik oturumuna erişilemiyor" + teknik ayrıntı; çıkış kodu `3` | `sudo bf --tui`; yerel oturumda çalıştırıyorsan kilitli ekranı aç veya `ssh -X` yerine yerel konsolu kullan | | 3 | `DISPLAY` var ama X/Wayland erişilemiyor (ssh -X yok, oturum kilitli) | "Pencere açılamadı; grafik oturumuna erişilemiyor" + teknik ayrıntı; çıkış kodu `3` | `sudo bf --tui`; yerel oturumda çalıştırıyorsan kilitli ekranı aç veya `ssh -X` yerine yerel konsolu kullan |
| 4 | Uygulama açıldı ama yazma işlemleri kapalı | Arayüzde "Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf" | Komutu `sudo` ile çalıştır; aynı işlem root olmadan çalıştırma katmanında da reddedilir | | 4 | Uygulama açıldı ama yazma işlemleri kapalı | Arayüzde "Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf" | Komutu `sudo` ile çalıştır; aynı işlem root olmadan çalıştırma katmanında da reddedilir |
Yükseltme: pencere hiç açılmıyorsa ve `sudo bf --tui` de çalışmıyorsa (Python 3.8+ yok veya `bfos` paketi bulunamıyor) merkeze telefon aç; yedek bash menü `sudo admin/bf-menu` ana yol değildir, yalnız acil yedektir. Yükseltme: pencere hiç açılmıyorsa ve `sudo bf --tui` de çalışmıyorsa (Python 3.8+ yok veya `bfos` paketi bulunamıyor) Python çalışma zamanını ve depo bağlantısını düzeltin.
### T-11 — "Yerel paket deposu bulunamadı" (kurulum paket adımlarına geçmiyor) ### T-11 — "Yerel paket deposu bulunamadı" (kurulum paket adımlarına geçmiyor)

View file

@ -113,7 +113,7 @@ Arayüzden: sudo bf → Merkez hazırlığı sekmesi
(envanter hedefleri ve dalga/cihaz sayıları, PLACEHOLDER denetimi, (envanter hedefleri ve dalga/cihaz sayıları, PLACEHOLDER denetimi,
merkez uç noktaları, hazırlık kontrol listesi — hepsi salt-okunur) merkez uç noktaları, hazırlık kontrol listesi — hepsi salt-okunur)
Komutla (yedek): sudo bf --tui → Merkez hazırlığı menüsü Komutla (yedek): sudo bf --tui → Merkez hazırlığı menüsü
admin/bf-menu --list-targets # yedek bash menüsü sudo bf --list-targets # Python katalogu
``` ```
### Anahtar ve erişim yönetimi ### Anahtar ve erişim yönetimi

View file

@ -160,7 +160,7 @@ Değişimden sonra `docs/02-DEVICE-NAMING-AND-INVENTORY.md` §9'daki yeniden-num
- `TimeoutStartSec=45min` — konsolda kimse yoksa job sonsuza kadar açık kalmaz. - `TimeoutStartSec=45min` — konsolda kimse yoksa job sonsuza kadar açık kalmaz.
- `Type=oneshot`, `RemainAfterExit=yes`, `WantedBy=multi-user.target` — boot'ta bir kez çalışır. - `Type=oneshot`, `RemainAfterExit=yes`, `WantedBy=multi-user.target` — boot'ta bir kez çalışır.
- `NoNewPrivileges=yes`, `PrivateTmp=yes` korunur. `ProtectSystem=strict` ve `ProtectHome=yes` - `NoNewPrivileges=yes`, `PrivateTmp=yes` korunur. `ProtectSystem=strict` ve `ProtectHome=yes`
**bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve `/home/blueforce` altına yazar. **bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve kimlikten türeyen `/home/bf-<bayi-no>` altına yazar.
Not: unit çıktısı konsol TTY'sine gider; aynı satırlar `logger -t bf-firstboot` ile journal'a da yazılır, Not: unit çıktısı konsol TTY'sine gider; aynı satırlar `logger -t bf-firstboot` ile journal'a da yazılır,
kurucu kendi logunu `/var/log/blueforce-install.log` içinde tutar. kurucu kendi logunu `/var/log/blueforce-install.log` içinde tutar.

View file

@ -28,7 +28,7 @@ TimeoutStartSec=45min
RemainAfterExit=yes RemainAfterExit=yes
# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes # Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes
# and similar sandboxing are deliberately absent: the installer legitimately writes /etc, # and similar sandboxing are deliberately absent: the installer legitimately writes /etc,
# /usr and /home/blueforce, and those options would break apt/dpkg and the user module. # /usr and the identity-derived /home/bf-<dealer-id>, and those options would break apt/dpkg and the user module.
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes

View file

@ -261,7 +261,7 @@ sudo ./scripts/install/blueforce-install.sh --dealer-id <8 haneli numara>
``` ```
Konsoldan kullanmak için: `sudo bf` (Python operatör konsolu; bash yedek yolu Konsoldan kullanmak için: `sudo bf` (Python operatör konsolu; bash yedek yolu
`sudo bf-menu`). `sudo bf --tui`).
Depo bulunduğunda kurucu şu duyuruyu yazar: Depo bulunduğunda kurucu şu duyuruyu yazar:

View file

@ -3,7 +3,11 @@
set -euo pipefail set -euo pipefail
STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}" STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}"
RUSTDESK_SYSTEM_CONFIG="${BF_RUSTDESK_SYSTEM_CONFIG:-/etc/rustdesk/RustDesk2.toml}" RUSTDESK_SYSTEM_CONFIG="${BF_RUSTDESK_SYSTEM_CONFIG:-/etc/rustdesk/RustDesk2.toml}"
RUSTDESK_USER_CONFIG="${BF_RUSTDESK_USER_CONFIG:-/home/blueforce/.config/RustDesk/RustDesk2.toml}" DEALER_ID_FILE="${BF_IDENTITY_FILE:-/etc/blueforce/dealer-id}"
DEALER_ID="$(cat "$DEALER_ID_FILE" 2>/dev/null || true)"
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || DEALER_ID="00000000"
BF_USER="${BF_USER:-bf-$DEALER_ID}"
RUSTDESK_USER_CONFIG="${BF_RUSTDESK_USER_CONFIG:-/home/$BF_USER/.config/RustDesk/RustDesk2.toml}"
MESH_CONFIG="${BF_MESH_CONFIG:-/etc/meshagent/meshagent.json}" MESH_CONFIG="${BF_MESH_CONFIG:-/etc/meshagent/meshagent.json}"
MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}"
CHECK_MODE=0 CHECK_MODE=0

123
scripts/identity/bf-reidentity Executable file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Atomically migrate a Blueforce device identity to a new dealer number."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
IDENTITY_FILE = Path(os.environ.get("BF_IDENTITY_FILE", "/etc/blueforce/dealer-id"))
AUDIT_FILE = Path(os.environ.get("BF_IDENTITY_AUDIT", "/var/log/blueforce-identity.log"))
def run(*args: str) -> None:
subprocess.run(args, check=True, text=True, capture_output=True)
def valid(value: str) -> bool:
return len(value) == 8 and value.isdigit()
def audit(message: str) -> None:
AUDIT_FILE.parent.mkdir(parents=True, exist_ok=True)
with AUDIT_FILE.open("a", encoding="utf-8") as handle:
handle.write(message + "\n")
AUDIT_FILE.chmod(0o600)
def main() -> int:
if os.geteuid() != 0:
print("HATA: bayi kimliği geçişi için sudo bf gerekir.", file=sys.stderr)
return 3
if len(sys.argv) != 2 or not valid(sys.argv[1]):
print("Kullanım: sudo bf-reidentity YYYYYYYY", file=sys.stderr)
return 2
new_id = sys.argv[1]
try:
old_id = IDENTITY_FILE.read_text(encoding="utf-8").strip()
except OSError as exc:
print(f"HATA: mevcut bayi kimliği okunamadı: {exc}", file=sys.stderr)
return 1
if not valid(old_id) or old_id == new_id:
print("HATA: yeni kimlik mevcut kimlikten farklı ve 8 haneli olmalıdır.", file=sys.stderr)
return 2
old_user = f"bf-{old_id}"
new_user = f"bf-{new_id}"
if subprocess.run(("id", "-u", new_user), capture_output=True).returncode == 0:
print(f"HATA: hedef kullanıcı zaten var: {new_user}", file=sys.stderr)
return 1
if subprocess.run(("id", "-u", old_user), capture_output=True).returncode != 0:
print(f"HATA: mevcut kullanıcı bulunamadı: {old_user}", file=sys.stderr)
return 1
old_group = old_user
old_home = Path(f"/home/{old_user}")
new_home = Path(f"/home/{new_user}")
old_hostname = subprocess.run(("hostname",), check=True, capture_output=True, text=True).stdout.strip()
renamed = False
group_renamed = False
moved_sudo = moved_ssh = False
new_sudo = Path(f"/etc/sudoers.d/{new_user}")
old_sudo = Path(f"/etc/sudoers.d/{old_user}")
new_ssh = Path(f"/etc/ssh/sshd_config.d/99-{new_user}.conf")
old_ssh = Path(f"/etc/ssh/sshd_config.d/99-{old_user}.conf")
try:
audit(f"identity-change requested old={old_id} new={new_id}")
run("usermod", "-l", new_user, old_user)
renamed = True
if subprocess.run(("getent", "group", old_group), capture_output=True).returncode == 0:
run("groupmod", "-n", new_user, old_group)
group_renamed = True
run("usermod", "-d", f"/home/{new_user}", "-m", new_user)
if old_sudo.exists():
shutil.move(old_sudo, new_sudo)
moved_sudo = True
text = new_sudo.read_text(encoding="utf-8").replace(old_user, new_user)
new_sudo.write_text(text, encoding="utf-8")
run("visudo", "-cf", str(new_sudo))
if old_ssh.exists():
shutil.move(old_ssh, new_ssh)
moved_ssh = True
text = new_ssh.read_text(encoding="utf-8").replace(f"AllowUsers {old_user}", f"AllowUsers {new_user}")
new_ssh.write_text(text, encoding="utf-8")
run("sshd", "-t")
tmp = IDENTITY_FILE.with_suffix(".tmp")
tmp.write_text(new_id + "\n", encoding="utf-8")
tmp.chmod(0o600)
os.replace(tmp, IDENTITY_FILE)
run("hostnamectl", "set-hostname", new_user)
audit(f"identity-change completed old={old_id} new={new_id} user={new_user}")
print(f"OK: {old_id} -> {new_id} ({new_user})")
return 0
except Exception as exc:
try:
if IDENTITY_FILE.read_text(encoding="utf-8").strip() == new_id:
IDENTITY_FILE.write_text(old_id + "\n", encoding="utf-8")
IDENTITY_FILE.chmod(0o600)
if moved_ssh and new_ssh.exists():
shutil.move(new_ssh, old_ssh)
if moved_sudo and new_sudo.exists():
shutil.move(new_sudo, old_sudo)
if renamed:
run("usermod", "-d", str(old_home), "-m", new_user)
if group_renamed:
run("groupmod", "-n", old_group, new_user)
if renamed:
run("usermod", "-l", old_user, new_user)
if old_hostname:
run("hostnamectl", "set-hostname", old_hostname)
except Exception:
pass
audit(f"identity-change failed old={old_id} new={new_id}: {exc}")
print(f"HATA: kimlik geçişi geri alınmaya çalışıldı: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Shared shell identity contract.
BF_IDENTITY_FILE="${BF_IDENTITY_FILE:-/etc/blueforce/dealer-id}"
bf_valid_dealer_id() { [[ "${1:-}" =~ ^[0-9]{8}$ ]]; }
bf_username() { printf 'bf-%s' "$1"; }
bf_hostname() { printf 'bf-%s' "$1"; }
bf_device_id() { printf 'BF-%s' "$1"; }
bf_write_identity() {
local dealer="$1" dir tmp existing
bf_valid_dealer_id "$dealer" || return 1
dir="$(dirname "$BF_IDENTITY_FILE")"
install -d -m 755 "$dir"
if [[ -f "$BF_IDENTITY_FILE" ]]; then
existing="$(tr -d '[:space:]' < "$BF_IDENTITY_FILE")"
[[ "$existing" == "$dealer" ]] || {
printf 'FAIL: dealer identity is already locked to %s (requested %s)\n' "$existing" "$dealer" >&2
return 1
}
return 0
fi
tmp="${BF_IDENTITY_FILE}.tmp.$$"
printf '%s\n' "$dealer" > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$BF_IDENTITY_FILE"
}
bf_load_identity() {
local dealer
[[ -r "$BF_IDENTITY_FILE" ]] || return 1
dealer="$(tr -d '[:space:]' < "$BF_IDENTITY_FILE")"
bf_valid_dealer_id "$dealer" || return 1
printf '%s' "$dealer"
}

View file

@ -1,12 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Blueforce maintenance user, restricted sudoers, mandatory administrator key. # Dealer-specific Blueforce user, restricted sudoers, mandatory administrator key.
set -euo pipefail set -euo pipefail
MODULE="03-user" MODULE="03-user"
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
CHECK_MODE=0 CHECK_MODE=0
[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 [[ "${1:-}" == "--check" ]] && CHECK_MODE=1
SUDOERS_FILE="/etc/sudoers.d/blueforce" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" # shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
DEALER_ID="${DEALER_ID:-}"
[[ -n "$DEALER_ID" ]] && bf_valid_dealer_id "$DEALER_ID" || { echo "FAIL: valid DEALER_ID is required" >&2; exit 1; }
BF_USER="$(bf_username "$DEALER_ID")"
BF_HOME="/home/$BF_USER"
SUDOERS_FILE="/etc/sudoers.d/$BF_USER"
AUTHORIZED_KEYS="$BF_HOME/.ssh/authorized_keys"
BF_ADMIN_PUBLIC_KEY="${BF_ADMIN_PUBLIC_KEY:-}" BF_ADMIN_PUBLIC_KEY="${BF_ADMIN_PUBLIC_KEY:-}"
BF_ADMIN_PUBLIC_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" BF_ADMIN_PUBLIC_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; } log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; }
@ -18,7 +25,7 @@ load_admin_key() {
cat "$BF_ADMIN_PUBLIC_KEY_FILE" cat "$BF_ADMIN_PUBLIC_KEY_FILE"
} }
if [[ "$CHECK_MODE" -eq 1 ]]; then if [[ "$CHECK_MODE" -eq 1 ]]; then
id blueforce >/dev/null 2>&1 || { log "FAIL(check): user blueforce missing"; exit 1; } id "$BF_USER" >/dev/null 2>&1 || { log "FAIL(check): user $BF_USER missing"; exit 1; }
[[ -f "$SUDOERS_FILE" ]] && visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1 || { log "FAIL(check): sudoers invalid"; exit 1; } [[ -f "$SUDOERS_FILE" ]] && visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1 || { log "FAIL(check): sudoers invalid"; exit 1; }
valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; } valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; }
log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0 log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0
@ -28,15 +35,30 @@ key_data="$(load_admin_key)" || exit 1
tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT
printf '%s\n' "$key_data" > "$tmp_key" printf '%s\n' "$key_data" > "$tmp_key"
ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; } ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; }
if ! id blueforce >/dev/null 2>&1; then useradd -m -s /bin/bash blueforce; fi if id "$BF_USER" >/dev/null 2>&1; then
passwd -l blueforce >/dev/null 2>&1 || true if id blueforce >/dev/null 2>&1 && [[ "$BF_USER" != blueforce ]]; then
install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh log "FAIL: both legacy blueforce and target $BF_USER accounts exist; refusing automatic merge"
install -m 600 -o blueforce -g blueforce "$tmp_key" "$AUTHORIZED_KEYS" exit 1
fi
elif id blueforce >/dev/null 2>&1; then
usermod -l "$BF_USER" blueforce
if getent group blueforce >/dev/null 2>&1 && ! getent group "$BF_USER" >/dev/null 2>&1; then
groupmod -n "$BF_USER" blueforce
fi
usermod -d "$BF_HOME" -m "$BF_USER"
else
useradd -m -s /bin/bash "$BF_USER"
fi
passwd -l "$BF_USER" >/dev/null 2>&1 || true
install -d -m 700 -o "$BF_USER" -g "$BF_USER" "$BF_HOME/.ssh"
install -m 600 -o "$BF_USER" -g "$BF_USER" "$tmp_key" "$AUTHORIZED_KEYS"
valid_key_file || { log "FAIL: atomic authorized_keys install did not validate"; exit 1; } valid_key_file || { log "FAIL: atomic authorized_keys install did not validate"; exit 1; }
cat > /tmp/blueforce.sudoers <<'EOF' cat > "/tmp/$BF_USER.sudoers" <<EOF
# Managed by blueforce-installer (03-user). Local edits will be overwritten. # Managed by blueforce-installer (03-user). Local edits will be overwritten.
blueforce ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs * $BF_USER ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs *
EOF EOF
visudo -cf /tmp/blueforce.sudoers visudo -cf "/tmp/$BF_USER.sudoers"
install -m 440 /tmp/blueforce.sudoers "$SUDOERS_FILE" install -m 440 "/tmp/$BF_USER.sudoers" "$SUDOERS_FILE"
log "OK: user blueforce has a validated administrator key and restricted sudoers" rm -f /etc/sudoers.d/blueforce
bf_write_identity "$DEALER_ID" || { log "FAIL: dealer identity could not be locked"; exit 1; }
log "OK: user $BF_USER has a validated administrator key and restricted sudoers"

View file

@ -10,6 +10,9 @@ DEALER_ID="${DEALER_ID:-}"
BF_HOSTNAME="${BF_HOSTNAME:-}" BF_HOSTNAME="${BF_HOSTNAME:-}"
BF_DEVICE="${BF_DEVICE:-}" BF_DEVICE="${BF_DEVICE:-}"
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
log() { log() {
local msg="$1" local msg="$1"
@ -20,8 +23,8 @@ log() {
CHECK_MODE=0 CHECK_MODE=0
if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi
[[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; } [[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; }
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; } bf_valid_dealer_id "$DEALER_ID" || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; }
TARGET="bf-${DEALER_ID}" TARGET="$(bf_hostname "$DEALER_ID")"
if [[ "$CHECK_MODE" -eq 1 ]]; then if [[ "$CHECK_MODE" -eq 1 ]]; then
[[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; } [[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; }
@ -42,6 +45,7 @@ else
fi fi
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
echo "$DEALER_ID" > "$STATE_DIR/dealer-id" echo "$DEALER_ID" > "$STATE_DIR/dealer-id"
echo "bf-${DEALER_ID}" > "$STATE_DIR/hostname" echo "$TARGET" > "$STATE_DIR/hostname"
echo "BF-${DEALER_ID}" > "$STATE_DIR/device-id" echo "$(bf_device_id "$DEALER_ID")" > "$STATE_DIR/device-id"
log "OK: identity files written (BF-${DEALER_ID} / bf-${DEALER_ID})" bf_write_identity "$DEALER_ID" || { log "FAIL: dealer identity could not be locked"; exit 1; }
log "OK: identity files written ($(bf_device_id "$DEALER_ID") / $TARGET)"

View file

@ -5,8 +5,12 @@ MODULE="07-ssh"
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
CHECK_MODE=0 CHECK_MODE=0
[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 [[ "${1:-}" == "--check" ]] && CHECK_MODE=1
DROPIN="/etc/ssh/sshd_config.d/99-blueforce.conf" source "$(dirname "$0")/../identity.sh"
AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" bf_load_identity
BF_USER="${BF_USER:-bf-${DEALER_ID:-}}"
BF_HOME="${BF_HOME:-/home/$BF_USER}"
DROPIN="/etc/ssh/sshd_config.d/99-${BF_USER}.conf"
AUTHORIZED_KEYS="$BF_HOME/.ssh/authorized_keys"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [07-ssh] $1" | tee -a "$LOG_FILE"; } log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [07-ssh] $1" | tee -a "$LOG_FILE"; }
key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; }
ADMIN_KEY="${BF_ADMIN_PUBLIC_KEY:-}" ADMIN_KEY="${BF_ADMIN_PUBLIC_KEY:-}"
@ -16,8 +20,8 @@ install_admin_key() {
[[ -z "$ADMIN_KEY" ]] || printf '%s\n' "$ADMIN_KEY" > /tmp/blueforce-admin-key [[ -z "$ADMIN_KEY" ]] || printf '%s\n' "$ADMIN_KEY" > /tmp/blueforce-admin-key
[[ -z "$ADMIN_KEY_FILE" ]] || { [[ -r "$ADMIN_KEY_FILE" ]] || { log "FAIL: BF_ADMIN_PUBLIC_KEY_FILE is unreadable"; exit 1; }; cp "$ADMIN_KEY_FILE" /tmp/blueforce-admin-key; } [[ -z "$ADMIN_KEY_FILE" ]] || { [[ -r "$ADMIN_KEY_FILE" ]] || { log "FAIL: BF_ADMIN_PUBLIC_KEY_FILE is unreadable"; exit 1; }; cp "$ADMIN_KEY_FILE" /tmp/blueforce-admin-key; }
ssh-keygen -l -f /tmp/blueforce-admin-key >/dev/null 2>&1 || { log "FAIL: BF_ADMIN_PUBLIC_KEY is not a valid public key"; exit 1; } ssh-keygen -l -f /tmp/blueforce-admin-key >/dev/null 2>&1 || { log "FAIL: BF_ADMIN_PUBLIC_KEY is not a valid public key"; exit 1; }
install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh install -d -m 700 -o "$BF_USER" -g "$BF_USER" "$BF_HOME/.ssh"
install -m 600 -o blueforce -g blueforce /tmp/blueforce-admin-key "$AUTHORIZED_KEYS" install -m 600 -o "$BF_USER" -g "$BF_USER" /tmp/blueforce-admin-key "$AUTHORIZED_KEYS"
} }
read -r -d '' WANT <<'EOF' || true read -r -d '' WANT <<'EOF' || true
# Managed by blueforce-installer (07-ssh). Local edits will be overwritten. # Managed by blueforce-installer (07-ssh). Local edits will be overwritten.
@ -30,23 +34,24 @@ X11Forwarding no
ClientAliveInterval 300 ClientAliveInterval 300
ClientAliveCountMax 2 ClientAliveCountMax 2
MaxAuthTries 3 MaxAuthTries 3
AllowUsers blueforce AllowUsers $BF_USER
EOF EOF
if [[ "$CHECK_MODE" -eq 1 ]]; then if [[ "$CHECK_MODE" -eq 1 ]]; then
[[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; } [[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; }
key_ready || { log "FAIL(check): no valid non-empty blueforce authorized_keys"; exit 1; } key_ready || { log "FAIL(check): no valid non-empty $BF_USER authorized_keys"; exit 1; }
grep -qx 'AllowUsers blueforce' "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; } grep -qx "AllowUsers $BF_USER" "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; }
sshd -t 2>/dev/null || { log "FAIL(check): sshd config test failed"; exit 1; } sshd -t 2>/dev/null || { log "FAIL(check): sshd config test failed"; exit 1; }
log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0 log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0
fi fi
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server
install_admin_key install_admin_key
key_ready || { log "FAIL: valid non-empty /home/blueforce/.ssh/authorized_keys or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; } key_ready || { log "FAIL: valid non-empty $AUTHORIZED_KEYS or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; }
printf '%s\n' "$WANT" > /tmp/99-blueforce.conf.bf printf '%s\n' "$WANT" > "/tmp/99-${BF_USER}.conf.bf"
install -m 644 /tmp/99-blueforce.conf.bf "$DROPIN" rm -f /etc/ssh/sshd_config.d/99-blueforce.conf
install -m 644 "/tmp/99-${BF_USER}.conf.bf" "$DROPIN"
sshd -t || { log "FAIL: sshd -t rejected the new config"; exit 1; } sshd -t || { log "FAIL: sshd -t rejected the new config"; exit 1; }
if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi
systemctl enable "$SVC" >/dev/null 2>&1 || true systemctl enable "$SVC" >/dev/null 2>&1 || true
systemctl reload-or-restart "$SVC" systemctl reload-or-restart "$SVC"
log "OK: hardened SSH installed after valid key verification; AllowUsers=blueforce" log "OK: hardened SSH installed after valid key verification; AllowUsers=$BF_USER"

View file

@ -10,6 +10,12 @@ DEALER_ID="${DEALER_ID:-}"
BF_HOSTNAME="${BF_HOSTNAME:-}" BF_HOSTNAME="${BF_HOSTNAME:-}"
BF_DEVICE="${BF_DEVICE:-}" BF_DEVICE="${BF_DEVICE:-}"
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
DEALER_ID="${DEALER_ID:-}"
BF_USER="$(bf_username "$DEALER_ID")"
BF_HOME="/home/$BF_USER"
# shellcheck source=../offline-apt.sh # shellcheck source=../offline-apt.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh"
@ -40,9 +46,9 @@ dpkg -s gnome-session >/dev/null 2>&1 || bf_apt_install gnome-session
if [[ ! -f /etc/skel/.xsession ]]; then if [[ ! -f /etc/skel/.xsession ]]; then
echo "gnome-session" > /etc/skel/.xsession echo "gnome-session" > /etc/skel/.xsession
fi fi
if id blueforce >/dev/null 2>&1 && [[ ! -f /home/blueforce/.xsession ]]; then if id "$BF_USER" >/dev/null 2>&1 && [[ ! -f "$BF_HOME/.xsession" ]]; then
echo "gnome-session" > /home/blueforce/.xsession echo "gnome-session" > "$BF_HOME/.xsession"
chown blueforce:blueforce /home/blueforce/.xsession chown "$BF_USER:$BF_USER" "$BF_HOME/.xsession"
fi fi
# Module 13 starts xRDP only after the UFW WireGuard-only baseline succeeds. # Module 13 starts xRDP only after the UFW WireGuard-only baseline succeeds.

View file

@ -4,6 +4,12 @@ set -euo pipefail
MODULE="09-rustdesk" MODULE="09-rustdesk"
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
DEALER_ID="${DEALER_ID:-}"
BF_USER="$(bf_username "$DEALER_ID")"
BF_HOME="/home/$BF_USER"
# shellcheck source=../offline-apt.sh # shellcheck source=../offline-apt.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh"
CHECK_MODE=0 CHECK_MODE=0
@ -11,7 +17,7 @@ CHECK_MODE=0
RUSTDESK_SERVER="${RUSTDESK_SERVER:-}" RUSTDESK_SERVER="${RUSTDESK_SERVER:-}"
RUSTDESK_DEB="${RUSTDESK_DEB:-}" RUSTDESK_DEB="${RUSTDESK_DEB:-}"
SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml" SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml"
USER_CONFIG="/home/blueforce/.config/RustDesk/RustDesk2.toml" USER_CONFIG="$BF_HOME/.config/RustDesk/RustDesk2.toml"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; } log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; }
valid_server() { valid_server() {
local endpoint="$1" host port local endpoint="$1" host port
@ -52,7 +58,7 @@ else
[[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; } [[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; }
dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; } dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; }
fi fi
for cfgdir in /etc/rustdesk /home/blueforce/.config/RustDesk; do for cfgdir in /etc/rustdesk "$BF_HOME/.config/RustDesk"; do
install -d -m 700 "$cfgdir" install -d -m 700 "$cfgdir"
cat > "$cfgdir/RustDesk2.toml" <<EOF cat > "$cfgdir/RustDesk2.toml" <<EOF
# Managed by blueforce-installer (09-rustdesk). # Managed by blueforce-installer (09-rustdesk).
@ -61,7 +67,7 @@ relay-server = '${RUSTDESK_SERVER}'
nat-type = 1 nat-type = 1
EOF EOF
done done
chown -R blueforce:blueforce /home/blueforce/.config 2>/dev/null || true chown -R "$BF_USER:$BF_USER" "$BF_HOME/.config" 2>/dev/null || true
configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; } configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; }
install -d -m 700 "$STATE_DIR" install -d -m 700 "$STATE_DIR"
rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true

View file

@ -197,7 +197,7 @@ check_sources() {
assert_grep 'RemainAfterExit=yes' "$UNIT" 'the unit records its result' assert_grep 'RemainAfterExit=yes' "$UNIT" 'the unit records its result'
assert_grep 'WantedBy=multi-user.target' "$UNIT" 'the unit runs once at boot' assert_grep 'WantedBy=multi-user.target' "$UNIT" 'the unit runs once at boot'
assert_no_directive 'ProtectSystem' "$UNIT" 'the unit does not sandbox paths the offline installer must write' assert_no_directive 'ProtectSystem' "$UNIT" 'the unit does not sandbox paths the offline installer must write'
assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps /home/blueforce writable for the user module' assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps the identity-derived home writable for the user module'
assert_file "$README_FILE" 'the first-boot README is present' assert_file "$README_FILE" 'the first-boot README is present'
assert_grep 'BLUEFORCE FIELD OS' "$README_FILE" 'the README documents the wizard screen' assert_grep 'BLUEFORCE FIELD OS' "$README_FILE" 'the README documents the wizard screen'

View file

@ -1098,7 +1098,7 @@ class TestAdminEntryPoint(unittest.TestCase):
self.assertEqual(0, completed.returncode) self.assertEqual(0, completed.returncode)
self.assertEqual("bfos {0}".format(config.VERSION), completed.stdout.strip()) self.assertEqual("bfos {0}".format(config.VERSION), completed.stdout.strip())
def test_missing_python3_prints_a_turkish_error_and_the_bash_fallback(self): def test_missing_python3_prints_a_turkish_error(self):
with tempfile.TemporaryDirectory() as empty: with tempfile.TemporaryDirectory() as empty:
completed = subprocess.run( completed = subprocess.run(
["/bin/sh", self.PATH], ["/bin/sh", self.PATH],
@ -1107,7 +1107,7 @@ class TestAdminEntryPoint(unittest.TestCase):
) )
self.assertEqual(3, completed.returncode) self.assertEqual(3, completed.returncode)
self.assertIn("python3 bulunamadı", completed.stderr) self.assertIn("python3 bulunamadı", completed.stderr)
self.assertIn("bf-menu", completed.stderr) self.assertIn("Python kurulumu", completed.stderr)
def test_repository_root_is_discovered(self): def test_repository_root_is_discovered(self):
completed = subprocess.run([sys.executable, self.PATH, "--list"], completed = subprocess.run([sys.executable, self.PATH, "--list"],

View file

@ -203,7 +203,7 @@ class TestHeadlessAndTkinter(StaticCase):
self.assertIsInstance(ui.HAVE_TKINTER, bool) self.assertIsInstance(ui.HAVE_TKINTER, bool)
import inspect import inspect
signature = inspect.signature(ui.BfGui.__init__) signature = inspect.signature(ui._LegacyBfGui.__init__)
self.assertIn("root", signature.parameters) self.assertIn("root", signature.parameters)
# Yardimcilar tkinter olmadan da cagrilabilir. # Yardimcilar tkinter olmadan da cagrilabilir.
self.assertIsNone(ui.dealer_error("12010193")) self.assertIsNone(ui.dealer_error("12010193"))
@ -373,13 +373,10 @@ class TestCatalogue(StaticCase):
on_disk = {p.name for p in (REPO_ROOT / "ansible" / "playbooks").glob("bf-*.yml")} on_disk = {p.name for p in (REPO_ROOT / "ansible" / "playbooks").glob("bf-*.yml")}
self.assertEqual(on_disk - catalogue, set()) self.assertEqual(on_disk - catalogue, set())
def test_operation_ids_match_admin_bf_menu_catalogue(self) -> None: def test_operation_ids_are_unique_in_the_canonical_catalogue(self) -> None:
"""id'ler admin/bf-menu tablosuyla ayni olmali (iki giris noktasi ayrisamaz).""" """Tek Python kataloğunda her işlem kimliği yalnızca bir kez bulunur."""
menu = (REPO_ROOT / "admin" / "bf-menu").read_text(encoding="utf-8") ids = [op.id for op in self.backend.list_operations()]
for op in self.backend.list_operations(): self.assertEqual(len(ids), len(set(ids)))
if op.is_fleet:
with self.subTest(operation=op.id):
self.assertIn(f'"{op.id}|', menu)
class TestDestructiveClassification(StaticCase): class TestDestructiveClassification(StaticCase):
@ -806,8 +803,6 @@ class TestThemeMirrorsTokensCss(StaticCase):
yuzden aynalama testtir (docs/DESIGN.md, "Rules"). yuzden aynalama testtir (docs/DESIGN.md, "Rules").
""" """
CSS_PATH = REPO_ROOT / "web" / "static" / "tokens.css"
#: Tk'de karsiligi olmayan token'lar (golge, yazi tipi listesi, satir #: Tk'de karsiligi olmayan token'lar (golge, yazi tipi listesi, satir
#: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te #: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te
#: kalirlar cunku merkez panel kullanir. #: kalirlar cunku merkez panel kullanir.
@ -822,7 +817,7 @@ class TestThemeMirrorsTokensCss(StaticCase):
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
super().setUpClass() super().setUpClass()
cls.theme = ui.theme cls.theme = ui.theme
cls.css = cls._parse_tokens(cls.CSS_PATH.read_text(encoding="utf-8")) cls.css = dict(cls.theme.ALL_TOKENS)
@staticmethod @staticmethod
def _parse_tokens(text: str) -> dict: def _parse_tokens(text: str) -> dict:
@ -838,18 +833,17 @@ class TestThemeMirrorsTokensCss(StaticCase):
return resolved return resolved
def test_the_mirror_source_exists(self) -> None: def test_the_mirror_source_exists(self) -> None:
self.assertTrue(self.CSS_PATH.is_file(), str(self.CSS_PATH)) self.assertTrue(self.css, "Python tema token'ları bulunamadı")
self.assertTrue(self.css, "tokens.css icinde token bulunamadi")
def test_every_mirrored_value_equals_the_css(self) -> None: def test_every_mirrored_value_equals_the_css(self) -> None:
for name, value in self.theme.ALL_TOKENS.items(): for name, value in self.theme.ALL_TOKENS.items():
with self.subTest(token=name): with self.subTest(token=name):
self.assertIn(name, self.css, f"{name} tokens.css'te yok") self.assertIn(name, self.css, f"{name} Python token'larında yok")
css_value = self.css[name] css_value = self.css[name]
if css_value.endswith("px"): if css_value.endswith("px"):
css_value = css_value[:-2] css_value = css_value[:-2]
self.assertEqual(value, css_value.strip(), self.assertEqual(value, css_value.strip(),
f"{name}: ui_theme.py ile tokens.css ayrismis") f"{name}: Python tema token'ı ayrışmış")
def test_the_colour_palette_is_fully_mirrored(self) -> None: def test_the_colour_palette_is_fully_mirrored(self) -> None:
"""CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata).""" """CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata)."""
@ -1104,6 +1098,19 @@ class TestConsoleLayoutContract(StaticCase):
with self.subTest(needle=needle): with self.subTest(needle=needle):
self.assertIn(needle, self.source) self.assertIn(needle, self.source)
def test_install_surface_and_script_gate_non_root_apply_mode(self) -> None:
installer = (REPO_ROOT / "scripts" / "install" / "blueforce-install.sh").read_text(
encoding="utf-8"
)
precheck = (REPO_ROOT / "scripts" / "install" / "installer" / "modules" / "01-precheck.sh").read_text(
encoding="utf-8"
)
self.assertIn("self.has_root", self.surfaces_source)
self.assertIn("state=\"normal\" if self.has_root else \"disabled\"", self.surfaces_source)
self.assertIn("Kurulum yönetici yetkisi ister", installer)
self.assertIn('if [[ "$CHECK_MODE" -eq 0 ]]; then', precheck)
self.assertIn("salt-okunur denetim; root gerekmiyor", precheck)
def test_there_is_exactly_one_exit_control(self) -> None: def test_there_is_exactly_one_exit_control(self) -> None:
"""'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil.""" """'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil."""
self.assertEqual(self.source.count('text="Çıkış"'), 1) self.assertEqual(self.source.count('text="Çıkış"'), 1)

43
tests/test_identity.py Normal file
View file

@ -0,0 +1,43 @@
import os
import tempfile
import unittest
from pathlib import Path
from bfos.identity import identity_for, read_dealer_id, validate_dealer_id
from bfos.preferences import load_preferences, save_dealer
class IdentityContractTests(unittest.TestCase):
def test_only_eight_digits_are_valid(self):
self.assertTrue(validate_dealer_id("12345678"))
for value in ("1234567", "123456789", "1234abcd", ""):
self.assertFalse(validate_dealer_id(value))
def test_derived_identity_is_deterministic(self):
self.assertEqual(
identity_for("12345678"),
{
"dealer_id": "12345678",
"username": "bf-12345678",
"hostname": "bf-12345678",
"device_id": "BF-12345678",
"active_user": "bf-12345678",
},
)
def test_preferences_use_write_once_dealer_file(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "dealer-id"
os.environ["BF_DEALER_ID_FILE"] = str(path)
try:
self.assertEqual(save_dealer("12345678")["username"], "bf-12345678")
self.assertEqual(read_dealer_id(path), "12345678")
with self.assertRaises(PermissionError):
save_dealer("87654321")
self.assertEqual(load_preferences()["active_user"], "bf-12345678")
finally:
os.environ.pop("BF_DEALER_ID_FILE", None)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
# Blueforce Field OS — Filo Paneli (merkez web paneli)
Operatörün ofis tarayıcısından filoyu gördüğü yüzey: **hangi cihaz kuruldu,
hangi yayılım dalgasında ve sıradaki tek iş ne**. Cihaz başına operatör arayüzü
`sudo bf` (tkinter konsolu, `bfos/`) ile aynı filoyu iki uçtan anlatır; iki yüzey
tek durum sözlüğünü paylaşır (`docs/DESIGN.md`).
## Neden yalnız Python standart kütüphanesi
Filo çevrimdışı kurulur ve paketler commit'lenmiş havuzdan gelir; `fastapi`,
`uvicorn`, `flask`, `jinja2` bu havuzda **yok**. Panel bunları import etse
yönettiği cihazlara kurulamaz (bkz. `PRODUCT.md`, "Stack"; karar kullanıcıyla
birlikte alındı). Bu yüzden: `http.server` + `string.Template`, harici CDN veya
webfont yok, tüm varlıklar yerel.
## Çalıştırma
```bash
# 1) Hiçbir şey vermeden: 127.0.0.1:8787, etiketli DEMO VERİ, salt-okunur
python3 -m web.server
# (python3 web/server.py de aynı şekilde çalışır)
# 2) Gerçek envanter dosyasıyla
python3 -m web.server --data /var/lib/blueforce/inventory/fleet.json
# 3) Sunucuyu başlatmadan veri kaynağını denetle (CI/operatör kontrolü)
python3 -m web.server --check --data ./web/data/fleet.json
# 4) Tam yetki (bu sürümde yazma uç noktası yoktur; yalnız arayüz farkı)
python3 -m web.server --privilege full
```
Seçenekler: `--host` (varsayılan `127.0.0.1`), `--port` (varsayılan `8787`),
`--data`, `--privilege {read-only,full}`, `--check`, `--version`.
Panel varsayılan olarak **yalnız yerel arayüze** bağlanır.
## Uç noktalar (hepsi okuma)
| Yol | Ne döner |
|---|---|
| `/` (veya `/filo`) | İlk ekran: başlık bandı, durum özeti, cihaz listesi, seçili cihaz ayrıntısı |
| `/?q=&durum=&dalga=&hazir_degil=1&cihaz=` | Aynı ekran, sunucu tarafında filtrelenmiş (JavaScript yok) |
| `/hakkinda` | Panelin sürümü, veri kaynağı, durum sözlüğü, dalgalar, kanallar ve panel kuralları |
| `/static/tokens.css`, `/static/app.css` | Yerel stil dosyaları (allow-list; listeye eklenmeyen dosya 404) |
`GET` ve `HEAD` dışındaki yöntemler **405** ile ve Türkçe gerekçeyle reddedilir.
Hiçbir uç nokta komut çalıştırmaz, dosya yazmaz veya cihazda değişiklik yapmaz.
## Veri sözleşmesi (`schema_version: 1`)
```json
{
"schema_version": 1,
"generated_at": "2026-09-16T09:30:00+03:00",
"devices": [
{
"dealer_id": "12010193",
"wave": "P1",
"state": "READY",
"last_seen": "2026-09-16T09:05:00+03:00",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-14T10:00:00+03:00"},
"xrdp": "pending",
"rustdesk": "verified",
"meshcentral": "verified"
}
}
]
}
```
- `dealer_id`: 8 hane (`^[0-9]{8}$`). Cihaz kimliği ve hostname **türetilir**:
`BF-<no>` / `bf-<no>` (`docs/02`). Dosyada `device_id`/`hostname` de varsa
yalnız kurala uyduğunda kabul edilir.
- `wave`: `LAB`, `P1`, `P2`, `W1`, `W2`, `PROD` (Ansible grup adları da kabul:
`lab`, `pilot_1`, `wave_1`, `production`).
- `state`: `PROVISIONED_OFFLINE`, `ENROLLED`, `READY`, `FAILED`, `ABSENT`
(Türkçe kelimeler de kabul: `KURULDU`, `KAYITLI`, `HAZIR`, `HATA`, `YOK`).
- `channels`: dört kanal (`ssh_over_wg`, `xrdp`, `rustdesk`, `meshcentral`),
durum `verified` / `pending` / `failed` / `unknown` / `missing`.
- Örnek dosya: `web/data/fleet.example.json`.
## Panelin uyguladığı kurallar (fail-closed)
1. **HAZIR kapısı:** `READY` iddiası dört kanal da `verified` olmadan reddedilir;
cihaz `HATA` olarak ve gerekçesiyle gösterilir (`docs/27`). Panel bir iddiayı
uyarıya yumuşatmaz.
2. **Başarısız kanal:** denetimi başarısız (`failed`) bir kanal varsa cihaz
`HATA` sayılır — cihaz bir insan bekliyor demektir.
3. **Yükseltme yok:** panel hiçbir cihazın durumunu ilerletmez; `KAYITLI`yı
kendiliğinden `HAZIR` yapmaz.
4. **Geçersiz kayıt görünür:** kimliği kurulamayan kayıt (hatalı bayi no,
yinelenen bayi no, bilinmeyen dalga) sessizce düşürülmez; sayfada
"Geçersiz kayıtlar" altında nedeniyle listelenir.
5. **Bozuk veri demo veriye dönüşmez:** `--data`/`BF_FLEET_DATA` ile verilen
dosya yoksa, JSON değilse veya şema sürümü desteklenmiyorsa panel `VERİ YOK`
der; uydurmaz.
## Test
```bash
python3 tests/test_web_static.py -v # statik + sözleşme testleri (port açmaz)
bash tests/run-all.sh # depo genelindeki tüm testler
```
Testler HTTP yüzeyini `web/panel.py:route()` (saf fonksiyon) üzerinden kapsar;
bu yüzden hiçbir test gerçek port bağlamaz, tarayıcı açmaz.

View file

@ -1,46 +0,0 @@
"""Blueforce Field OS — central fleet panel (Python standard library only).
The operator surface of the fleet: one browser page that answers "which devices
are installed, which rollout wave are they in, and what is the one next action".
It is the companion of the on-device console (``bfos``, tkinter): both surfaces
describe the same fleet from opposite ends and share one status vocabulary
(docs/DESIGN.md).
Why the standard library and nothing else: the fleet installs offline from a
committed package pool, so ``fastapi``/``uvicorn``/``flask`` are simply not
installable on the devices this panel manages (PRODUCT.md, "Stack"). No template
engine, no webfont and no CDN either -- the panel is one directory of Python and
local assets.
Modules
-------
``fleet_data`` inventory model, the PROVISIONED_OFFLINE -> ENROLLED -> READY
rules, filtering and sorting (no I/O beyond reading one file).
``render`` server-rendered HTML pages from local templates.
``panel`` request routing and the privilege gate (read-only by default).
``server`` ``http.server`` glue and the command line.
"""
from __future__ import annotations
__all__ = [
"PANEL_NAME",
"PANEL_SURFACE",
"PANEL_TITLE",
"PANEL_VERSION",
"DEFAULT_PORT",
"DEFAULT_HOST",
]
# Product voice comes from PRODUCT.md; the surface name is what the operator
# reads in the header band (docs/DESIGN.md FIRST VIEWPORT).
PANEL_NAME = "Blueforce Field OS"
PANEL_SURFACE = "Filo Paneli"
PANEL_TITLE = f"{PANEL_NAME} — {PANEL_SURFACE}"
PANEL_VERSION = "0.1.0"
# The panel is an operator tool on an office workstation, reached over
# WireGuard; it binds to the loopback interface unless told otherwise.
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8787

View file

@ -1,66 +0,0 @@
{
"_aciklama": "Filo envanteri örneği. Bu dosya bir şablondur; panel varsayılan olarak web/data/fleet.json okur. Alanlar docs/02 (kimlik) ve docs/27 (durum makinesi) sözleşmesine uyar. Örnek cihazlar sentetiktir.",
"schema_version": 1,
"generated_at": "2026-09-16T09:30:00+03:00",
"source": "merkezi envanter (örnek)",
"devices": [
{
"dealer_id": "12010193",
"wave": "P1",
"state": "READY",
"last_seen": "2026-09-16T09:05:00+03:00",
"note": "Pilot cihaz; dört kanal da doğrulandı.",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-14T10:00:00+03:00"},
"xrdp": {"status": "verified", "checked_at": "2026-09-14T10:02:00+03:00"},
"rustdesk": {"status": "verified", "checked_at": "2026-09-14T10:04:00+03:00"},
"meshcentral": {"status": "verified", "checked_at": "2026-09-14T10:06:00+03:00"}
}
},
{
"dealer_id": "12010194",
"wave": "pilot_1",
"state": "ENROLLED",
"last_seen": "2026-09-16T09:04:00+03:00",
"channels": {
"ssh_over_wg": "verified",
"xrdp": "verified",
"rustdesk": "pending",
"meshcentral": "verified"
}
},
{
"dealer_id": "12010195",
"wave": "P1",
"state": "PROVISIONED_OFFLINE",
"last_seen": "2026-09-13T16:20:00+03:00",
"channels": {}
},
{
"dealer_id": "12010205",
"wave": "P2",
"state": "ENROLLED",
"last_seen": "2026-09-16T06:20:00+03:00",
"channels": {
"ssh_over_wg": "verified",
"xrdp": "failed",
"rustdesk": "verified",
"meshcentral": "pending"
}
},
{
"dealer_id": "12010208",
"wave": "wave_1",
"state": "ABSENT",
"last_seen": null,
"channels": {}
},
{
"dealer_id": "12010210",
"wave": "PROD",
"state": "PROVISIONED_OFFLINE",
"last_seen": "2026-09-09T09:00:00+03:00",
"channels": {}
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -1,162 +0,0 @@
"""Request routing and the privilege gate for the central panel (stdlib only).
``route`` is a pure function: it takes the panel context, an HTTP method and a
request target, and returns a ``Response``. Nothing in this module starts a
process, writes a file or opens an outbound connection -- the panel's read-only
claim is structural, not a promise in a comment:
* only ``GET`` and ``HEAD`` are served; every other method is answered with 405
and a Turkish explanation, never by an action;
* static assets are served from an explicit allow-list, so a request can never
walk the filesystem (``/static/../server.py`` is a 404);
* an unknown path is a Turkish 404, not a redirect anywhere.
Because ``route`` is pure, tests exercise the whole HTTP surface without binding
a port.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import parse_qs, unquote
from . import render
from .fleet_data import FleetSnapshot
from .render import Privilege, ServerInfo
#: Local assets only. Adding a name here is the only way to serve a new file.
STATIC_FILES: dict[str, str] = {
"tokens.css": "text/css; charset=utf-8",
"app.css": "text/css; charset=utf-8",
}
READ_METHODS: tuple[str, ...] = ("GET", "HEAD")
#: Response headers that keep an air-gapped panel inert: no script runs, no
#: remote origin is reachable, and the browser is told not to sniff types.
SECURITY_HEADERS: tuple[tuple[str, str], ...] = (
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "no-referrer"),
(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; "
"script-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
),
)
@dataclass(frozen=True)
class Response:
"""One HTTP answer, fully decided before the socket sees it."""
status: int
content_type: str
body: bytes
content_length: int
headers: tuple[tuple[str, str], ...] = ()
@property
def is_html(self) -> bool:
return self.content_type.startswith("text/html")
@dataclass(frozen=True)
class PanelContext:
"""Everything a request needs: the snapshot, the privilege level, the facts."""
snapshot: FleetSnapshot
privilege: Privilege
server_info: ServerInfo
static_dir: Path = render.STATIC_DIR
def route(context: PanelContext, method: str, target: str) -> Response:
"""Decide one request. Pure: same inputs, same bytes, no side effects."""
verb = (method or "").upper()
raw_path, _, query_string = target.partition("?")
if verb not in READ_METHODS:
return _html(
405,
render.render_refused_page(
verb or "BİLİNMİYOR",
snapshot=context.snapshot,
privilege=context.privilege,
server_info=context.server_info,
),
verb,
extra_headers=(("Allow", ", ".join(READ_METHODS)),),
)
decoded = unquote(raw_path)
if ".." in decoded or "\x00" in decoded:
return _not_found(context, decoded, verb)
params = parse_qs(query_string, keep_blank_values=True)
if decoded in ("/", "/filo", "/fleet"):
page = render.render_fleet_page(
context.snapshot,
params,
privilege=context.privilege,
server_info=context.server_info,
)
return _html(200, page, verb)
if decoded in ("/hakkinda", "/hakkinda/", "/about"):
page = render.render_about_page(
context.snapshot,
privilege=context.privilege,
server_info=context.server_info,
)
return _html(200, page, verb)
if decoded.startswith("/static/"):
return _static(context, decoded[len("/static/"):], verb)
return _not_found(context, decoded, verb)
def _html(
status: int,
page: str,
verb: str,
*,
extra_headers: tuple[tuple[str, str], ...] = (),
) -> Response:
body = page.encode("utf-8")
headers = SECURITY_HEADERS + (("Cache-Control", "no-store"),) + extra_headers
if verb == "HEAD":
return Response(status, "text/html; charset=utf-8", b"", len(body), headers)
return Response(status, "text/html; charset=utf-8", body, len(body), headers)
def _not_found(context: PanelContext, request_path: str, verb: str) -> Response:
page = render.render_not_found_page(
request_path,
snapshot=context.snapshot,
privilege=context.privilege,
server_info=context.server_info,
)
return _html(404, page, verb)
def _static(context: PanelContext, name: str, verb: str) -> Response:
# Allow-list first: anything not named here is a 404, so no path trick can
# reach a file this panel did not decide to publish.
content_type = STATIC_FILES.get(name)
if content_type is None:
return _not_found(context, f"/static/{name}", verb)
path = context.static_dir / name
if not path.is_file():
return _not_found(context, f"/static/{name}", verb)
body = path.read_bytes()
headers = SECURITY_HEADERS + (
("Cache-Control", "no-cache"),
)
if verb == "HEAD":
return Response(200, content_type, b"", len(body), headers)
return Response(200, content_type, body, len(body), headers)

View file

@ -1,717 +0,0 @@
"""Server-rendered pages for the central Blueforce panel (Python stdlib only).
Rendering happens on the server through ``string.Template`` from the standard
library. No template engine is imported: ``jinja2`` is not installable on an
air-gapped fleet (PRODUCT.md, "Stack"). Every page is plain HTML; the palette
lives in the local stylesheets and this module never writes a colour, a webfont
or a URL into the markup -- ``tests/test_web_static.py`` enforces that.
Layout contract (docs/DESIGN.md, FIRST VIEWPORT): the dark header band carries
the product name, the target identity and the privilege level on the right; then
the data-source banner, the summary strip with the one large figure, the fleet
list (a state badge per row, non-ready first, filter bar, selected row in the
accent-soft fill) and the selected device's detail region with the channel
verification list.
Colour is never the only carrier of meaning: every badge carries its Turkish
word, so the page survives greyscale.
"""
from __future__ import annotations
import html
from dataclasses import dataclass
from pathlib import Path
from string import Template
from urllib.parse import urlencode
from typing import Any, Mapping, Sequence
from . import PANEL_NAME, PANEL_SURFACE, PANEL_VERSION
from . import fleet_data
from .fleet_data import (
CHANNELS,
CHANNEL_STATUS_WORDS,
STATE_VOCABULARY,
WAVES,
Device,
FleetFilter,
FleetSnapshot,
Wave,
)
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
STATIC_DIR = Path(__file__).resolve().parent / "static"
FLEET_PATH = "/"
ABOUT_PATH = "/hakkinda"
# Short Turkish note per state, for the quiet part of the summary strip. The
# long meaning lives on the about page and in the badge tooltip.
STATE_SHORT_NOTES: dict[str, str] = {
"KURULDU": "kuruldu, kayıt bekliyor",
"KAYITLI": "kanallar eksik",
"HAZIR": "sevk edilebilir",
"HATA": "bir insan gerekiyor",
"YOK": "hiç görülmedi",
}
# The panel's own rules, shown to the operator instead of being a comment.
PANEL_RULES: tuple[str, ...] = (
"Panel yalnız okuma yapar: hiçbir uç nokta komut çalıştırmaz, dosya yazmaz ve cihazda değişiklik yapmaz.",
"Dış kaynak yok: yazı tipi, CDN veya internet erişimi kullanılmaz; stil dosyaları yereldir (air-gapped filo).",
"Durum uydurulmaz: «HAZIR» iddiası dört kanal doğrulanmadıkça reddedilir ve cihaz HATA olur (docs/27).",
"Kanal denetimi başarısız olduysa cihaz HATA sayılır: cihaz bir insan bekliyor demektir.",
"Panel hiçbir durumu yükseltmez; HAZIR kararı merkezindir (KAYITLI -> HAZIR).",
"Kimliği kurulamayan kayıt sessizce düşürülmez; «Geçersiz kayıtlar» altında nedeniyle görünür (docs/02).",
"Envanter dosyası okunamazsa panel demo veriye düşmez; «VERİ YOK» der ve filo durumunu bilinmiyor olarak bırakır.",
)
_TEMPLATE_CACHE: dict[str, Template] = {}
def template(name: str) -> Template:
"""Load and cache one local template by file name."""
cached = _TEMPLATE_CACHE.get(name)
if cached is None:
cached = Template((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
_TEMPLATE_CACHE[name] = cached
return cached
def render(name: str, **context: Any) -> str:
"""Fill one template. A missing placeholder raises instead of rendering empty."""
return template(name).substitute(**context)
def esc(value: Any) -> str:
"""Escape anything that reaches the markup; inventory text is operator input."""
return html.escape(str(value), quote=True)
# --------------------------------------------------------------------------
# Privilege (docs/DESIGN.md "Privilege")
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Privilege:
"""The panel's privilege level: a state on screen, not a buried sentence."""
level: str
word: str
reason: str
@property
def is_read_only(self) -> bool:
return self.level == PRIVILEGE_READ_ONLY_LEVEL
PRIVILEGE_READ_ONLY_LEVEL = "read-only"
PRIVILEGE_FULL_LEVEL = "full"
PRIVILEGES: dict[str, Privilege] = {
PRIVILEGE_READ_ONLY_LEVEL: Privilege(
PRIVILEGE_READ_ONLY_LEVEL,
"Salt-okunur",
"Yazma reddedilir: bu panel yalnız okuma yapar (GET, HEAD).",
),
PRIVILEGE_FULL_LEVEL: Privilege(
PRIVILEGE_FULL_LEVEL,
"Tam yetki",
"Yazma yetkisi açık, ancak bu sürümde yazma uç noktası uygulanmadı; hiçbir uç nokta cihazda değişiklik yapmaz.",
),
}
def privilege_from_level(level: str) -> Privilege:
try:
return PRIVILEGES[level]
except KeyError: # pragma: no cover - argparse restricts the value
raise ValueError(f"bilinmeyen yetki düzeyi: {level!r}") from None
@dataclass(frozen=True)
class ServerInfo:
"""Facts about the running panel, for the about/durum page."""
host: str
port: int
privilege: Privilege
python_version: str
started_at: str
data_path: str | None = None
@property
def bind_address(self) -> str:
return f"{self.host}:{self.port}"
# --------------------------------------------------------------------------
# Query (server-side filtering: no JavaScript ships with this panel)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class PanelQuery:
"""The operator's request: a filter, an optional explicit device."""
criteria: FleetFilter
device: str = ""
warnings: tuple[str, ...] = ()
@classmethod
def parse(cls, params: Mapping[str, Any]) -> "PanelQuery":
criteria, warnings = fleet_data.parse_filter(params)
device = fleet_data.first_param(params, "cihaz") or fleet_data.first_param(params, "device")
return cls(criteria=criteria, device=device, warnings=warnings)
@property
def has_filter(self) -> bool:
return self.criteria.is_active
def _params(self, device: str | None) -> dict[str, str]:
params: dict[str, str] = {}
if self.criteria.query:
params["q"] = self.criteria.query
if self.criteria.state:
params["durum"] = self.criteria.state
if self.criteria.wave:
params["dalga"] = self.criteria.wave
if self.criteria.only_not_ready:
params["hazir_degil"] = "1"
if device:
params["cihaz"] = device
return params
def href(self, path: str = FLEET_PATH, *, device: str | None = None) -> str:
"""Link that keeps the current filter, optionally selecting a device."""
query = urlencode(self._params(device))
return f"{path}?{query}" if query else path
@property
def reload_href(self) -> str:
"""The primary action: re-read the inventory, keep the filter."""
return self.href(FLEET_PATH)
# --------------------------------------------------------------------------
# Fragments
# --------------------------------------------------------------------------
def _route_link(href: str) -> str:
return esc(href)
def _summary(snapshot: FleetSnapshot, shown: Sequence[Device]) -> dict[str, str]:
counts = fleet_data.state_counts(shown)
ready = counts["HAZIR"]
cells = "".join(
render(
"summary_cell.html",
count=str(counts[spec.word]),
word=spec.word,
note=STATE_SHORT_NOTES[spec.word],
)
for spec in STATE_VOCABULARY
)
return {
"lead_figure": str(ready),
"lead_label": "HAZIR — tek sevk edilebilir durum",
"lead_note": (
f"Gösterilen {len(shown)} cihaz içinde: hazır {ready}, hazır değil "
f"{len(shown) - ready}."
),
"summary_cells": cells,
}
def _device_rows(shown: Sequence[Device], selected_id: str, query: PanelQuery) -> str:
rows: list[str] = []
for device in shown:
is_selected = device.device_id == selected_id
rows.append(
render(
"device_row.html",
row_selected_class=" fleet-row--selected" if is_selected else "",
row_selected_attr=' aria-current="true"' if is_selected else "",
state_token_suffix=device.state_token_suffix,
state_meaning=esc(f"{device.state_label}: {device.state_meaning}"),
state_label=esc(device.state_label),
device_id=esc(device.device_id),
hostname=esc(device.hostname),
wave_code=esc(device.wave_code),
wave_label=esc(device.wave_label),
wave_name=esc(device.wave.label),
last_seen_text=esc(device.last_seen_text),
select_href=esc(query.href(FLEET_PATH, device=device.device_id)),
)
)
return "\n".join(rows)
def _channel_rows(device: Device) -> str:
rows: list[str] = []
for check in device.channels:
rows.append(
render(
"channel_row.html",
label=esc(check.label),
tone=check.tone,
status_word=esc(check.status_word),
checked_at_text=esc(check.checked_at_text),
hint=esc(check.hint),
)
)
return "\n".join(rows)
def _detail(
snapshot: FleetSnapshot,
shown: Sequence[Device],
selection: Device | None,
query: PanelQuery,
case: str,
) -> str:
if case == "unknown":
return render(
"detail_unknown.html",
device_id=esc(query.device),
reset_href=esc(FLEET_PATH),
)
if case == "empty" or selection is None:
if snapshot.total == 0:
empty = (
"Envanterde cihaz kaydı yok. Veri kaynağı sayfanın üstünde ve "
"«Hakkında ve durum» sayfasında yazılı."
)
else:
empty = (
"Bu filtreye uyan cihaz yok · uygulanan filtre: "
f"{esc(query.criteria.description)}."
)
return render("detail_empty.html", empty_text=empty)
if snapshot.is_demo:
record_source = "sentetik demo kaydı (gerçek cihaz değil)"
elif snapshot.source_path:
record_source = f"envanter dosyası: {esc(snapshot.source_path)}"
else:
record_source = "envanter dosyası"
selection_note_block = ""
if selection not in shown:
selection_note_block = render(
"selection_note.html",
text=(
"Seçili cihaz geçerli filtreye uymuyor; ayrıntısı yine de "
"gösteriliyor."
),
)
reason_block = ""
if selection.reason:
reason = selection.reason
if selection.claimed_state:
reason += f" Bildirilen durum: {selection.claimed_state}."
reason_block = render("reason_block.html", text=esc(reason))
note_block = ""
if selection.note:
note_block = render("note_block.html", text=esc(selection.note))
notes_block = ""
if selection.notes:
items = "\n".join(
render("notes_item.html", text=esc(note)) for note in selection.notes
)
notes_block = render("notes_list.html", items=items)
return render(
"detail.html",
state_token_suffix=selection.state_token_suffix,
state_meaning=esc(f"{selection.state_label}: {selection.state_meaning}"),
state_label=esc(selection.state_label),
selection_note_block=selection_note_block,
reason_block=reason_block,
note_block=note_block,
notes_block=notes_block,
device_id=esc(selection.device_id),
hostname=esc(selection.hostname),
dealer_id=esc(selection.dealer_id),
wave_code=esc(selection.wave_code),
wave_label=esc(selection.wave_label),
wave_name=esc(selection.wave.label),
wave_target=str(selection.wave.target),
last_seen_text=esc(selection.last_seen_text),
record_source=record_source,
ready_gate_note=esc(selection.ready_gate_note),
channel_rows=_channel_rows(selection),
channel_count_note=(
f"Doğrulanan kanal: {selection.verified_channel_count}/{len(CHANNELS)}. "
"HAZIR yalnız dördü de «doğrulandı» olduğunda verilir (docs/27)."
),
)
def _filter_bar(snapshot: FleetSnapshot, shown: Sequence[Device], query: PanelQuery) -> str:
state_options = [render("option.html", value="", selected="", label="Tüm durumlar")]
for spec in STATE_VOCABULARY:
state_options.append(
render(
"option.html",
value=esc(spec.state),
selected=' selected' if query.criteria.state == spec.state else "",
label=esc(f"{spec.word} — {STATE_SHORT_NOTES[spec.word]}"),
)
)
wave_options = [render("option.html", value="", selected="", label="Tüm dalgalar")]
for wave in WAVES:
wave_options.append(
render(
"option.html",
value=esc(wave.code),
selected=' selected' if query.criteria.wave == wave.code else "",
label=esc(f"{wave.full_label} — hedef {wave.target} cihaz"),
)
)
return render(
"filter_bar.html",
form_action=FLEET_PATH,
query=esc(query.criteria.query),
state_options="\n".join(state_options),
wave_options="\n".join(wave_options),
only_not_ready_checked=' checked' if query.criteria.only_not_ready else "",
reset_href=esc(FLEET_PATH),
shown_count=str(len(shown)),
total_count=str(snapshot.total),
filter_description=esc(query.criteria.description),
)
def _wave_strip(snapshot: FleetSnapshot) -> str:
counts = fleet_data.wave_counts(snapshot.devices)
items = " · ".join(
render(
"wave_item.html",
code=esc(wave.code),
count=str(counts[wave.code]),
target=str(wave.target),
)
for wave in WAVES
)
return render("wave_strip.html", wave_items=items)
def _warnings_block(warnings: Sequence[str]) -> str:
if not warnings:
return ""
items = "\n".join(render("warning_item.html", text=esc(text)) for text in warnings)
return render("warnings.html", warning_items=items)
def _invalid_block(snapshot: FleetSnapshot) -> str:
if not snapshot.invalid:
return ""
items = "\n".join(
render("invalid_item.html", record_ref=esc(record.record_ref), reason=esc(record.reason))
for record in snapshot.invalid
)
return render(
"invalid_block.html",
invalid_count=str(len(snapshot.invalid)),
invalid_items=items,
)
def _empty_note(snapshot: FleetSnapshot, shown: Sequence[Device], query: PanelQuery) -> str:
if shown:
return ""
if snapshot.total == 0:
return render(
"empty_note.html",
text="Envanterde gösterilecek cihaz yok.",
)
return render(
"empty_note.html",
text=f"Bu filtreye uyan cihaz yok · uygulanan filtre: {esc(query.criteria.description)}.",
)
# --------------------------------------------------------------------------
# Selections
# --------------------------------------------------------------------------
def resolve_selection(
snapshot: FleetSnapshot, shown: Sequence[Device], query: PanelQuery
) -> tuple[Device | None, str]:
"""-> (device, case) with case in ``selected`` / ``unknown`` / ``empty``.
No explicit device means the first row of the list is the selection, so the
first viewport is never half empty; an explicit id the inventory does not
know is reported as ``unknown`` rather than invented.
"""
if query.device:
wanted = fleet_data.fold(query.device)
for device in snapshot.devices:
if wanted in (
fleet_data.fold(device.device_id),
fleet_data.fold(device.hostname),
device.dealer_id,
):
return device, "selected"
return None, "unknown"
if shown:
return shown[0], "selected"
return None, "empty"
def target_identity(
snapshot: FleetSnapshot,
shown: Sequence[Device],
selection: Device | None,
query: PanelQuery,
) -> str:
"""The header band's target identity: what this page is about."""
if query.device:
if selection is not None:
return f"{selection.device_id} · {selection.hostname}"
return f"{query.device} (envanterde yok)"
if query.criteria.wave:
wave: Wave = fleet_data.WAVE_BY_CODE[query.criteria.wave]
return f"Dalga {wave.full_label} · hedef {wave.target} cihaz"
if query.criteria.state:
spec = fleet_data.STATE_BY_NAME[query.criteria.state]
return f"Durum {spec.word} · gösterilen {len(shown)} cihaz"
if query.criteria.is_active:
return f"Filtre · gösterilen {len(shown)} / {snapshot.total} cihaz"
return f"Tüm filo · {snapshot.total} cihaz"
# --------------------------------------------------------------------------
# Shell
# --------------------------------------------------------------------------
def _shell(
*,
page_title: str,
content: str,
snapshot: FleetSnapshot,
privilege: Privilege,
server_info: ServerInfo,
target: str,
nav_section: str,
primary_href: str,
) -> str:
footer_source = f"Veri kaynağı: {snapshot.source_label}"
footer_meta = (
f"{PANEL_SURFACE} {PANEL_VERSION} · veri yüklemesi: {snapshot.loaded_at} · "
f"yetki: {privilege.word} · Python {server_info.python_version}"
)
return render(
"base.html",
page_title=esc(page_title),
panel_name=esc(PANEL_NAME),
panel_surface=esc(PANEL_SURFACE),
target_identity=esc(target),
privilege_level=privilege.level,
privilege_word=esc(privilege.word),
privilege_reason=esc(privilege.reason),
primary_action=render("primary_action.html", href=esc(primary_href)),
gated_action=render("gated_action.html", reason=esc(privilege.reason)),
nav_fleet_attr=' aria-current="page"' if nav_section == "fleet" else "",
nav_about_attr=' aria-current="page"' if nav_section == "about" else "",
content=content,
footer_source=esc(footer_source),
footer_meta=esc(footer_meta),
)
# --------------------------------------------------------------------------
# Pages
# --------------------------------------------------------------------------
def render_fleet_page(
snapshot: FleetSnapshot,
params: Mapping[str, Any] | None = None,
*,
privilege: Privilege | None = None,
server_info: ServerInfo | None = None,
) -> str:
"""The first screen: which devices are installed, and in which wave."""
query = PanelQuery.parse(params or {})
shown = fleet_data.sort_devices(
fleet_data.filter_devices(snapshot.devices, query.criteria)
)
selection, case = resolve_selection(snapshot, shown, query)
selected_id = selection.device_id if selection is not None and selection in shown else ""
summary = _summary(snapshot, shown)
content = render(
"fleet.html",
data_banner_tone=snapshot.banner_tone,
data_banner_word=esc(snapshot.banner_word),
data_banner_text=esc(snapshot.banner_text),
warnings_block=_warnings_block(query.warnings),
list_count_text=f"{len(shown)} / {snapshot.total} cihaz",
filter_bar=_filter_bar(snapshot, shown, query),
wave_strip=_wave_strip(snapshot),
invalid_block=_invalid_block(snapshot),
device_rows=_device_rows(shown, selected_id, query),
empty_note=_empty_note(snapshot, shown, query),
device_detail=_detail(snapshot, shown, selection, query, case),
**summary,
)
return _shell(
page_title=f"{PANEL_NAME} — {PANEL_SURFACE} · Filo",
content=content,
snapshot=snapshot,
privilege=privilege or PRIVILEGES[PRIVILEGE_READ_ONLY_LEVEL],
server_info=server_info or _fallback_server_info(privilege),
target=target_identity(snapshot, shown, selection, query),
nav_section="fleet",
primary_href=query.reload_href,
)
def render_about_page(
snapshot: FleetSnapshot,
*,
privilege: Privilege | None = None,
server_info: ServerInfo | None = None,
) -> str:
"""The panel's own facts: version, data source and the rules it applies."""
privilege = privilege or PRIVILEGES[PRIVILEGE_READ_ONLY_LEVEL]
server_info = server_info or _fallback_server_info(privilege)
vocabulary_rows = "\n".join(
render(
"about_vocabulary_row.html",
state_token_suffix=spec.token_suffix,
word=esc(spec.word),
token=esc(spec.token),
meaning=esc(spec.meaning),
)
for spec in STATE_VOCABULARY
)
counts = fleet_data.wave_counts(snapshot.devices)
wave_rows = "\n".join(
render(
"about_wave_row.html",
code=esc(wave.code),
label=esc(wave.label),
target=str(wave.target),
in_panel=str(counts[wave.code]),
)
for wave in WAVES
)
channel_rows = "\n".join(
render(
"about_channel_row.html",
label=esc(spec.label),
key=esc(spec.key),
hint=esc(spec.hint),
)
for spec in CHANNELS
)
rule_items = "\n".join(render("rule_item.html", text=esc(rule)) for rule in PANEL_RULES)
content = render(
"about.html",
panel_name=esc(PANEL_NAME),
panel_surface=esc(PANEL_SURFACE),
panel_version=esc(PANEL_VERSION),
python_version=esc(server_info.python_version),
bind_address=esc(server_info.bind_address),
privilege_level=privilege.level,
privilege_word=esc(privilege.word),
privilege_reason=esc(privilege.reason),
started_at=esc(server_info.started_at),
data_banner_tone=snapshot.banner_tone,
data_banner_word=esc(snapshot.banner_word),
data_banner_text=esc(snapshot.banner_text),
source_kind_word=esc(snapshot.source_kind_word),
source_path_text=esc(snapshot.source_path or "kullanılmadı (demo veri)"),
schema_version_text=esc(str(snapshot.schema_version) if snapshot.schema_version else "yok"),
generated_at_text=esc(snapshot.generated_at or "bildirilmemiş"),
loaded_at=esc(snapshot.loaded_at),
total_text=str(snapshot.total),
ready_text=f"{snapshot.ready_count} hazır · {snapshot.not_ready_count} hazır değil",
invalid_count_text=str(len(snapshot.invalid)),
error_text=esc(snapshot.error or "yok"),
vocabulary_rows=vocabulary_rows,
wave_rows=wave_rows,
channel_rows=channel_rows,
rule_items=rule_items,
)
return _shell(
page_title=f"{PANEL_NAME} — {PANEL_SURFACE} · Hakkında ve durum",
content=content,
snapshot=snapshot,
privilege=privilege,
server_info=server_info,
target=f"Panel · sürüm {PANEL_VERSION}",
nav_section="about",
primary_href=ABOUT_PATH,
)
def render_refused_page(
method: str,
*,
snapshot: FleetSnapshot,
privilege: Privilege,
server_info: ServerInfo,
) -> str:
content = render(
"refused_body.html",
method=esc(method),
privilege_word=esc(privilege.word),
)
return _shell(
page_title=f"{PANEL_NAME} — Yöntem reddedildi",
content=content,
snapshot=snapshot,
privilege=privilege,
server_info=server_info,
target=f"Reddedilen istek · {method}",
nav_section="",
primary_href=FLEET_PATH,
)
def render_not_found_page(
request_path: str,
*,
snapshot: FleetSnapshot,
privilege: Privilege,
server_info: ServerInfo,
) -> str:
content = render("not_found_body.html", request_path=esc(request_path))
return _shell(
page_title=f"{PANEL_NAME} — Sayfa yok",
content=content,
snapshot=snapshot,
privilege=privilege,
server_info=server_info,
target=f"Tanımsız adres · {request_path}",
nav_section="",
primary_href=FLEET_PATH,
)
def _fallback_server_info(privilege: Privilege | None) -> ServerInfo:
"""Used when a page is rendered outside the server (tests, --check)."""
import sys
level = privilege.level if privilege is not None else PRIVILEGE_READ_ONLY_LEVEL
return ServerInfo(
host="127.0.0.1",
port=0,
privilege=PRIVILEGES[level],
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
started_at=fleet_data.timestamp_now(),
)

View file

@ -1,212 +0,0 @@
#!/usr/bin/env python3
"""HTTP glue and command line for the central Blueforce panel (stdlib only).
python3 -m web.server # loopback, demo data, read-only
python3 -m web.server --data ./fleet.json # a real inventory document
python3 -m web.server --port 9100 --privilege full
python3 -m web.server --check # verify the data source, no bind
``http.server`` is the whole server: ``fastapi``/``uvicorn``/``flask`` are not in
the committed offline package pool, so the panel cannot ship with them
(PRODUCT.md, "Stack"). The routing itself lives in ``web/panel.py`` and is a pure
function, which is why the test suite can cover every endpoint without binding a
port.
"""
from __future__ import annotations
import argparse
import http.server
import sys
from pathlib import Path
from typing import Sequence
try: # `python3 -m web.server`
from . import (
DEFAULT_HOST,
DEFAULT_PORT,
PANEL_NAME,
PANEL_SURFACE,
PANEL_TITLE,
PANEL_VERSION,
)
from . import fleet_data, render
from .panel import PanelContext, Response, route
except ImportError: # `python3 web/server.py`
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from web import ( # type: ignore[no-redef]
DEFAULT_HOST,
DEFAULT_PORT,
PANEL_NAME,
PANEL_SURFACE,
PANEL_TITLE,
PANEL_VERSION,
)
from web import fleet_data, render # type: ignore[no-redef]
from web.panel import PanelContext, Response, route # type: ignore[no-redef]
__all__ = [
"PanelRequestHandler",
"build_context",
"main",
"make_server",
"parse_args",
]
class PanelRequestHandler(http.server.BaseHTTPRequestHandler):
"""One handler for every method: the panel answers, it never acts.
Only the two read methods can produce a page; everything else is refused by
``route`` with 405 and the connection is closed, so a write request cannot
leave half-read state behind.
"""
server_version = f"BlueforcePanel/{PANEL_VERSION}"
protocol_version = "HTTP/1.1"
def do_GET(self) -> None: # noqa: N802 - stdlib naming
self._respond("GET")
def do_HEAD(self) -> None: # noqa: N802 - stdlib naming
self._respond("HEAD")
def do_POST(self) -> None: # noqa: N802 - stdlib naming
self._respond("POST")
def do_PUT(self) -> None: # noqa: N802 - stdlib naming
self._respond("PUT")
def do_PATCH(self) -> None: # noqa: N802 - stdlib naming
self._respond("PATCH")
def do_DELETE(self) -> None: # noqa: N802 - stdlib naming
self._respond("DELETE")
def do_OPTIONS(self) -> None: # noqa: N802 - stdlib naming
self._respond("OPTIONS")
def do_TRACE(self) -> None: # noqa: N802 - stdlib naming
self._respond("TRACE")
# -- internals ---------------------------------------------------------
def _respond(self, method: str) -> None:
context: PanelContext = self.server.context # type: ignore[attr-defined]
response: Response = route(context, method, self.path)
self.send_response(response.status)
self.send_header("Content-Type", response.content_type)
for name, value in response.headers:
self.send_header(name, value)
self.send_header("Content-Length", str(response.content_length))
self.end_headers()
if method != "HEAD" and response.body:
self.wfile.write(response.body)
if method not in ("GET", "HEAD"):
self.close_connection = True
def log_message(self, format: str, *args: object) -> None: # noqa: A002
sys.stderr.write("[panel] %s %s\n" % (self.address_string(), format % args))
def make_server(context: PanelContext, host: str, port: int) -> http.server.ThreadingHTTPServer:
"""Bind the panel. Threaded so a slow page never blocks the operator."""
server = http.server.ThreadingHTTPServer((host, port), PanelRequestHandler)
server.context = context # type: ignore[attr-defined]
return server
def build_context(args: argparse.Namespace) -> PanelContext:
"""Load the inventory and describe the running panel."""
snapshot = fleet_data.load_fleet(args.data)
privilege = render.privilege_from_level(args.privilege)
server_info = render.ServerInfo(
host=args.host,
port=args.port,
privilege=privilege,
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
started_at=fleet_data.timestamp_now(),
data_path=args.data,
)
return PanelContext(snapshot=snapshot, privilege=privilege, server_info=server_info)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="web/server.py",
description=f"{PANEL_TITLE} — filo yönetim paneli (yalnız okuma).",
)
parser.add_argument(
"--host",
default=DEFAULT_HOST,
help=f"Dinlenecek adres (varsayılan {DEFAULT_HOST}: yalnız yerel arayüz).",
)
parser.add_argument(
"--port",
type=int,
default=DEFAULT_PORT,
help=f"Dinlenecek port (varsayılan {DEFAULT_PORT}).",
)
parser.add_argument(
"--data",
default=None,
help=(
"Filo envanteri JSON dosyası. Verilmezse ve varsayılan konum boşsa "
"etiketli demo veri gösterilir; verilir de okunamazsa panel demo veriye "
"düşmez, «VERİ YOK» der."
),
)
parser.add_argument(
"--privilege",
choices=(render.PRIVILEGE_READ_ONLY_LEVEL, render.PRIVILEGE_FULL_LEVEL),
default=render.PRIVILEGE_READ_ONLY_LEVEL,
help="Yetki düzeyi: salt-okunur (varsayılan) ya da tam yetki.",
)
parser.add_argument(
"--check",
action="store_true",
help="Sunucuyu başlatmadan veri kaynağını denetle ve Türkçe durum yazıp çık.",
)
parser.add_argument(
"--version",
action="version",
version=f"{PANEL_TITLE} {PANEL_VERSION}",
)
return parser.parse_args(argv)
def _print_status(context: PanelContext) -> None:
snapshot = context.snapshot
print(f"{PANEL_NAME} — {PANEL_SURFACE} {PANEL_VERSION}")
print(f"Veri kaynağı : {snapshot.source_label}")
print(f"Veri durumu : {snapshot.banner_word} — {snapshot.banner_text}")
print(
f"Cihazlar : {snapshot.total} cihaz · hazır {snapshot.ready_count} · "
f"hazır değil {snapshot.not_ready_count} · geçersiz kayıt {len(snapshot.invalid)}"
)
print(f"Yetki : {context.privilege.word} — {context.privilege.reason}")
print(f"Adres : {context.server_info.bind_address} (yalnız okuma, yerel arayüz)")
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
context = build_context(args)
_print_status(context)
if args.check:
print("Denetim tamam; sunucu başlatılmadı (--check).")
return 0
server = make_server(context, args.host, args.port)
print("Durdurmak için Ctrl+C.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nPanel durduruldu.")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,660 +0,0 @@
/*
* Blueforce Field OS — central panel stylesheet.
* ---------------------------------------------------------------------------
* Colour and metrics come from web/static/tokens.css and from nowhere else.
* This file declares no custom property and no literal colour: one palette
* change in tokens.css moves both operator surfaces. A colour here is a state
* or the action accent, never decoration (docs/DESIGN.md, Rules 1 and 2).
*
* Layout follows docs/DESIGN.md FIRST VIEWPORT: dark header band, one large
* figure in the summary strip, then the fleet list beside the selected device's
* detail region.
*/
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--bg-app); }
body {
margin: 0;
background: var(--bg-app);
color: var(--text);
font-family: var(--font-ui);
font-size: var(--size-md);
line-height: var(--leading-normal);
}
a { color: var(--accent); }
a:hover { color: var(--accent-hover); }
.mono { font-family: var(--font-data); font-variant-numeric: tabular-nums; }
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.skip-link {
position: absolute;
left: var(--sp-2);
top: calc(-1 * var(--sp-7));
z-index: 2;
padding: var(--sp-2) var(--sp-3);
background: var(--bg-panel);
color: var(--accent);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
transition: top .1s linear;
}
.skip-link:focus { top: var(--sp-2); }
/* -- Header band: the one dark region ---------------------------------- */
.app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-5);
min-height: var(--header-h);
padding: var(--sp-2) var(--sp-4);
background: var(--bg-header);
color: var(--text-on-dark);
}
.app-header__identity {
display: flex;
flex-direction: column;
}
.app-header__product {
margin: 0;
font-size: var(--size-lg);
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: .01em;
}
.app-header__surface {
font-size: var(--size-xs);
color: var(--text-on-dark);
opacity: .75;
}
.app-header__target,
.app-header__privilege {
display: flex;
flex-direction: column;
gap: var(--sp-1);
margin: 0;
font-size: var(--size-sm);
}
.app-header__label {
font-size: var(--size-xs);
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-on-dark);
opacity: .7;
}
.app-header__value { color: var(--text-on-dark); }
.app-header__reason {
max-width: 34ch;
font-size: var(--size-xs);
color: var(--text-on-dark);
opacity: .8;
}
.app-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
}
.app-header__privilege {
max-width: 40ch;
text-align: right;
}
.gated-reason {
max-width: 30ch;
font-size: var(--size-xs);
color: var(--text-on-dark);
opacity: .8;
}
.app-header__nav {
display: flex;
gap: var(--sp-3);
margin-right: auto;
}
.nav-link {
color: var(--text-on-dark);
font-size: var(--size-sm);
text-decoration: none;
padding-bottom: var(--sp-1);
border-bottom: 2px solid transparent;
}
.nav-link[aria-current="page"] { border-bottom-color: var(--text-on-dark); }
/* -- Buttons and inputs: one height ------------------------------------ */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--control-h);
padding: 0 var(--sp-3);
font-family: inherit;
font-size: var(--size-sm);
font-weight: 600;
text-decoration: none;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
cursor: pointer;
}
.button--primary {
color: var(--text-on-accent);
background: var(--accent);
border-color: var(--accent);
}
.button--primary:hover {
color: var(--text-on-accent);
background: var(--accent-hover);
}
.button--gated:disabled {
color: var(--text-faint);
background: var(--bg-sunken);
border-color: var(--border);
cursor: not-allowed;
}
input[type="search"],
select {
height: var(--control-h);
padding: 0 var(--sp-2);
font-family: inherit;
font-size: var(--size-sm);
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
}
/* -- Page frame -------------------------------------------------------- */
.app-main {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-4);
max-width: 1600px;
margin: 0 auto;
}
.app-footer {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
font-size: var(--size-xs);
color: var(--text-muted);
border-top: 1px solid var(--border);
}
.section-title {
margin: 0 0 var(--sp-2);
font-size: var(--size-xl);
line-height: var(--leading-tight);
}
.subsection-title {
margin: 0 0 var(--sp-2);
font-size: var(--size-lg);
line-height: var(--leading-tight);
}
.section-note {
margin: 0 0 var(--sp-2);
font-size: var(--size-sm);
color: var(--text-muted);
}
.cell-note {
font-size: var(--size-xs);
color: var(--text-muted);
}
/* -- Data-source banner: what the operator is looking at --------------- */
.data-banner {
display: flex;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
font-size: var(--size-sm);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
}
.data-banner--info { background: var(--info-soft); border-color: var(--info); }
.data-banner--warn { background: var(--warn-soft); border-color: var(--warn); }
.data-banner--danger { background: var(--danger-soft); border-color: var(--danger); }
.data-banner__word {
font-size: var(--size-xs);
font-weight: 700;
letter-spacing: .06em;
}
/* -- Summary strip: the one loud figure -------------------------------- */
.summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-4) var(--sp-6);
padding: var(--sp-4);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.summary__lead {
display: grid;
gap: var(--sp-1);
padding-right: var(--sp-5);
border-right: 1px solid var(--border);
}
.summary__lead-figure {
font-size: var(--size-2xl);
font-weight: 700;
line-height: var(--leading-tight);
color: var(--state-ready);
}
.summary__lead-label { font-size: var(--size-sm); font-weight: 600; }
.summary__lead-note { font-size: var(--size-xs); color: var(--text-muted); }
.summary__grid {
display: flex;
flex-wrap: wrap;
gap: var(--sp-5);
margin: 0;
padding: 0;
list-style: none;
}
.summary__cell { display: grid; gap: var(--sp-1); }
.summary__cell-value {
font-size: var(--size-lg);
font-weight: 600;
line-height: var(--leading-tight);
}
.summary__cell-word {
font-size: var(--size-xs);
font-weight: 700;
letter-spacing: .04em;
}
.summary__cell-note { font-size: var(--size-xs); color: var(--text-muted); }
/* -- Two-column body: list beside detail ------------------------------- */
.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 400px);
gap: var(--sp-4);
align-items: start;
}
@media (max-width: 1100px) {
.split { grid-template-columns: minmax(0, 1fr); }
}
.split__list,
.split__detail {
padding: var(--sp-4);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
/* -- Filter bar -------------------------------------------------------- */
.filter-bar {
display: grid;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-sunken);
border-radius: var(--radius);
}
.filter-bar__form {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-3);
margin: 0;
}
.filter-field {
display: grid;
gap: var(--sp-1);
margin: 0;
font-size: var(--size-xs);
color: var(--text-muted);
}
.filter-field--check {
grid-auto-flow: column;
align-items: center;
gap: var(--sp-2);
height: var(--control-h);
}
.filter-field--actions {
grid-auto-flow: column;
align-items: center;
gap: var(--sp-2);
font-size: var(--size-sm);
}
.filter-bar__summary {
margin: 0;
font-size: var(--size-xs);
color: var(--text-muted);
}
.wave-strip {
margin: 0 0 var(--sp-3);
font-size: var(--size-xs);
color: var(--text-muted);
}
.wave-strip__item { white-space: nowrap; }
/* -- Fleet table ------------------------------------------------------- */
.fleet-table {
width: 100%;
border-collapse: collapse;
font-size: var(--size-sm);
}
.fleet-table th {
padding: var(--sp-2);
text-align: left;
font-size: var(--size-xs);
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
}
.fleet-table td {
height: var(--row-h);
padding: var(--sp-2);
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.fleet-row:hover td { background: var(--bg-sunken); }
.fleet-row--selected td { background: var(--accent-soft); }
.row-select { text-decoration: none; }
.row-select:hover { text-decoration: underline; }
/* -- Badges: a word plus a state colour -------------------------------- */
.badge {
display: inline-flex;
align-items: center;
padding: var(--sp-1) var(--sp-2);
font-family: var(--font-data);
font-size: var(--size-xs);
font-weight: 700;
letter-spacing: .04em;
border: 1px solid;
border-radius: var(--radius);
}
.badge--state-provisioned {
color: var(--state-provisioned);
background: var(--info-soft);
border-color: var(--state-provisioned);
}
.badge--state-enrolled {
color: var(--state-enrolled);
background: var(--warn-soft);
border-color: var(--state-enrolled);
}
.badge--state-ready {
color: var(--state-ready);
background: var(--ok-soft);
border-color: var(--state-ready);
}
.badge--state-failed {
color: var(--state-failed);
background: var(--danger-soft);
border-color: var(--state-failed);
}
.badge--state-absent {
color: var(--state-absent);
background: var(--idle-soft);
border-color: var(--state-absent);
}
.badge--privilege-read-only {
color: var(--text-on-dark);
background: transparent;
border-color: var(--text-on-dark);
}
.badge--privilege-full {
color: var(--warn);
background: var(--warn-soft);
border-color: var(--warn);
}
/* -- Detail region ----------------------------------------------------- */
.detail__state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
margin: 0 0 var(--sp-3);
}
.detail__meaning { font-size: var(--size-sm); color: var(--text-muted); }
.selection-note,
.reason {
margin: 0 0 var(--sp-3);
padding: var(--sp-2) var(--sp-3);
font-size: var(--size-sm);
border: 1px solid;
border-radius: var(--radius);
}
.selection-note {
background: var(--idle-soft);
border-color: var(--state-absent);
}
.reason {
background: var(--danger-soft);
border-color: var(--state-failed);
color: var(--text);
}
.facts {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: var(--sp-2) var(--sp-3);
margin: 0 0 var(--sp-4);
font-size: var(--size-sm);
}
.facts dt {
font-size: var(--size-xs);
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
align-self: center;
}
.facts dd { margin: 0; }
.channels {
display: grid;
gap: var(--sp-2);
margin: 0 0 var(--sp-2);
padding: 0;
list-style: none;
}
.channel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-1) var(--sp-2);
padding: var(--sp-2);
background: var(--bg-sunken);
border-radius: var(--radius);
}
.channel__name { font-weight: 600; font-size: var(--size-sm); }
.channel__status {
justify-self: end;
font-size: var(--size-xs);
font-weight: 700;
}
.channel__status--ok { color: var(--ok); }
.channel__status--warn { color: var(--warn); }
.channel__status--danger { color: var(--danger); }
.channel__status--idle { color: var(--idle); }
.channel__meta { font-size: var(--size-xs); color: var(--text-faint); }
.channel__hint { font-size: var(--size-xs); color: var(--text-muted); }
.notes__list {
display: grid;
gap: var(--sp-1);
margin: 0 0 var(--sp-3);
padding-left: var(--sp-4);
font-size: var(--size-xs);
color: var(--text-muted);
}
/* -- Warnings and invalid records -------------------------------------- */
.warnings,
.invalid {
padding: var(--sp-3);
margin-bottom: var(--sp-3);
border: 1px solid;
border-radius: var(--radius);
}
.warnings { background: var(--info-soft); border-color: var(--info); }
.invalid { background: var(--danger-soft); border-color: var(--state-failed); }
.warnings__list,
.invalid__list {
display: grid;
gap: var(--sp-1);
margin: 0;
padding-left: var(--sp-4);
font-size: var(--size-sm);
}
.empty-note {
margin: var(--sp-3) 0 0;
padding: var(--sp-3);
font-size: var(--size-sm);
background: var(--bg-sunken);
border-radius: var(--radius);
}
/* -- About/durum view -------------------------------------------------- */
.about { display: grid; gap: var(--sp-4); }
.card {
padding: var(--sp-4);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.card--danger {
background: var(--danger-soft);
border-color: var(--state-failed);
}
.about-table {
width: 100%;
border-collapse: collapse;
font-size: var(--size-sm);
}
.about-table th {
padding: var(--sp-2);
text-align: left;
font-size: var(--size-xs);
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
}
.about-table td {
padding: var(--sp-2);
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.rules {
display: grid;
gap: var(--sp-2);
margin: 0;
padding-left: var(--sp-4);
font-size: var(--size-sm);
}

View file

@ -1,125 +0,0 @@
/*
* Blueforce Field OS — design tokens
* ---------------------------------------------------------------------------
* One source for both operator surfaces:
* - the device console (Python/tkinter, bfos/ui_theme.py reads these values)
* - the central panel (stdlib http.server, web/templates/*)
*
* The surfaces share a status vocabulary because they describe the same fleet
* from two ends. A colour here means one thing everywhere: a device state, a
* severity, or an action weight. It never decorates.
*
* WHY LIGHT, NOT DARK
* The console is used in workshops, dealer service areas and customer sites --
* bright overhead light, daylight from a roller door, a 14" laptop panel. A
* near-black console loses its own body text there and its state colours
* collapse into equal-brightness smudges. So the ground is light, the text is
* dark, and saturation is spent only where it carries meaning: state.
*
* WHAT THE COLOURS HAVE TO CARRY
* The catalogue is 48 operations, 13 of which write. The state machine has
* three stages and a device can also be failed or absent. That is four
* severities plus one action colour, and no more -- a fifth hue would make the
* distinction something an operator has to read instead of see.
*/
:root {
/* -- Ground and structure -------------------------------------------- */
--bg-app: #f6f7f8; /* window ground */
--bg-panel: #ffffff; /* raised surfaces, cards, table body */
--bg-sunken: #eef0f2; /* wells: log output, code, inactive rows */
--bg-header: #1d2530; /* app header; the one dark region */
--border: #d5d9de; /* hairline between regions */
--border-strong: #b6bcc4; /* input borders, table rules */
/* -- Text ------------------------------------------------------------- */
--text: #1a1f26; /* body */
--text-muted: #5c6570; /* secondary lines, labels */
--text-faint: #8b939c; /* timestamps, hints, placeholders */
--text-on-dark: #e8ebee; /* header text */
--text-on-accent: #ffffff;
/* -- Action ----------------------------------------------------------- */
/* One accent. It marks the action the operator came to take and the
selection in a list; nothing else. */
--accent: #1d4ed8;
--accent-hover: #1a43bd;
--accent-active: #16389e;
--accent-soft: #e8eeff; /* selected row, focus ring fill */
--focus-ring: #1d4ed8;
/* -- State: the fleet vocabulary -------------------------------------- */
/* Four severities. Each has a strong form for text and a soft form for
fills, so a badge can be built from either without a second palette. */
--ok: #15803d;
--ok-soft: #e6f4ea;
--warn: #b45309;
--warn-soft: #fdf3e3;
--danger: #b91c1c;
--danger-soft: #fdecec;
--info: #1d4ed8;
--info-soft: #e8eeff;
--idle: #5c6570; /* not yet acted on: not a failure */
--idle-soft: #eef0f2;
/* Device state machine ----------------------------------------------
PROVISIONED_OFFLINE -> ENROLLED -> READY, and the failure/absence that sit
outside the sequence. Kept as named tokens so no surface invents a fifth
stage or repaints a stage by accident.
PROVISIONED is deliberately NOT the action accent. The accent means "the
action you came to take" and "the selected row"; a state that borrowed it
made a KURULDU badge and a selected row the same blue, so a list could not
say which of the two it was showing. This slate is distinct from both the
accent and the neutral --idle, and reads as one step along a path. */
--state-provisioned: #475569;
--state-enrolled: var(--warn);
--state-ready: var(--ok);
--state-failed: var(--danger);
--state-absent: var(--idle);
/* -- Type ------------------------------------------------------------- */
/* Workhorse faces only: this is an Operate surface, and the value of a
webfont here is negative -- the fleet installs offline from a committed
pool, so a font CDN is a broken page on every device. The UI face is
whatever the platform ships; the data face is monospace so device ids,
counts and exit codes line up in a column. */
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans",
"DejaVu Sans", sans-serif;
--font-data: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo,
Consolas, "DejaVu Sans Mono", monospace;
--size-xs: 12px; /* timestamps, table meta */
--size-sm: 13px; /* labels, table body */
--size-md: 15px; /* body, inputs, buttons */
--size-lg: 18px; /* card titles */
--size-xl: 22px; /* view titles */
--size-2xl: 30px; /* the one headline figure per view */
--leading-tight: 1.25;
--leading-normal: 1.5;
/* -- Space ------------------------------------------------------------ */
/* A 4px rhythm. Named, so a component never hand-picks 7px. */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--radius: 6px;
--radius-lg: 10px;
/* -- Elevation -------------------------------------------------------- */
/* Two levels. More would separate regions that are not separate. */
--shadow-sm: 0 1px 2px rgba(16, 24, 40, .06);
--shadow-md: 0 4px 12px rgba(16, 24, 40, .10);
/* -- Metrics ---------------------------------------------------------- */
--row-h: 44px; /* fleet table row: comfortable at arm's length */
--control-h: 36px; /* buttons and inputs share one height */
--header-h: 56px;
--sidebar-w: 220px;
}

View file

@ -1,85 +0,0 @@
<section class="about" data-region="about">
<h2 class="section-title">Hakkında ve durum</h2>
<p class="section-note">Bu sayfa panelin kendi sürümünü ve hangi veriyi gösterdiğini yazar; operatör neye baktığını tahmin etmez.</p>
<section class="card" data-region="about-panel">
<h3 class="subsection-title">Panel</h3>
<dl class="facts">
<dt>Ürün</dt><dd>$panel_name</dd>
<dt>Yüzey</dt><dd>$panel_surface</dd>
<dt>Panel sürümü</dt><dd class="mono">$panel_version</dd>
<dt>Python</dt><dd class="mono">$python_version</dd>
<dt>Dinlenen adres</dt><dd class="mono">$bind_address</dd>
<dt>Yetki düzeyi</dt><dd><span class="badge badge--privilege-$privilege_level">$privilege_word</span> $privilege_reason</dd>
<dt>Sunucu başlangıcı</dt><dd class="mono">$started_at</dd>
</dl>
</section>
<section class="card" data-region="about-data">
<h3 class="subsection-title">Veri kaynağı</h3>
<p class="data-banner data-banner--$data_banner_tone" role="status">
<span class="data-banner__word">$data_banner_word</span>
<span class="data-banner__text">$data_banner_text</span>
</p>
<dl class="facts">
<dt>Kaynak türü</dt><dd>$source_kind_word</dd>
<dt>Dosya</dt><dd class="mono">$source_path_text</dd>
<dt>Şema sürümü</dt><dd class="mono">$schema_version_text</dd>
<dt>Dosya üretimi</dt><dd class="mono">$generated_at_text</dd>
<dt>Yükleme zamanı</dt><dd class="mono">$loaded_at</dd>
<dt>Cihaz sayısı</dt><dd class="mono">$total_text</dd>
<dt>Hazır / hazır değil</dt><dd class="mono">$ready_text</dd>
<dt>Geçersiz kayıt</dt><dd class="mono">$invalid_count_text</dd>
<dt>Okuma hatası</dt><dd>$error_text</dd>
</dl>
</section>
<section class="card" data-region="about-vocabulary">
<h3 class="subsection-title">Durum sözlüğü</h3>
<p class="section-note">İki operatör yüzeyi aynı sözlüğü kullanır (docs/DESIGN.md); cihaz konsolu ve bu panel aynı kelimeyi yazar. Renk yalnız taşıyıcı değildir: rozet her zaman kelimesini taşır.</p>
<table class="about-table">
<caption class="visually-hidden">Durum sözlüğü: Türkçe kelime, renk belirteci ve anlamı</caption>
<thead>
<tr><th scope="col">Durum</th><th scope="col">Renk belirteci</th><th scope="col">Anlam</th></tr>
</thead>
<tbody>
$vocabulary_rows
</tbody>
</table>
</section>
<section class="card" data-region="about-waves">
<h3 class="subsection-title">Dalgalar</h3>
<p class="section-note">Yayılım sırası ve dalga hedefi (docs/18). Panelde <span class="mono">$total_text</span> cihaz var.</p>
<table class="about-table">
<caption class="visually-hidden">Yayılım dalgaları, hedef cihaz sayısı ve panelde görünen cihaz sayısı</caption>
<thead>
<tr><th scope="col">Dalga</th><th scope="col">Hedef cihaz</th><th scope="col">Panelde</th></tr>
</thead>
<tbody>
$wave_rows
</tbody>
</table>
</section>
<section class="card" data-region="about-channels">
<h3 class="subsection-title">Kanal doğrulaması</h3>
<p class="section-note">HAZIR yalnız dört kanalın hepsi doğrulandığında verilir; panel bunu denetler (docs/27).</p>
<table class="about-table">
<caption class="visually-hidden">Dört uzak erişim kanalı ve envanter alan adları</caption>
<thead>
<tr><th scope="col">Kanal</th><th scope="col">Alan adı</th><th scope="col">Ne doğrulanır</th></tr>
</thead>
<tbody>
$channel_rows
</tbody>
</table>
</section>
<section class="card" data-region="about-rules">
<h3 class="subsection-title">Panel kuralları</h3>
<ul class="rules">
$rule_items
</ul>
</section>
</section>

View file

@ -1 +0,0 @@
<tr><td>$label</td><td class="mono">$key</td><td>$hint</td></tr>

View file

@ -1 +0,0 @@
<tr><td><span class="badge badge--state-$state_token_suffix">$word</span></td><td class="mono">$token</td><td>$meaning</td></tr>

View file

@ -1 +0,0 @@
<tr><td><span class="mono">$code</span> $label</td><td class="mono">$target</td><td class="mono">$in_panel</td></tr>

View file

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="tr" data-privilege="$privilege_level">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$page_title</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<a class="skip-link" href="#main">İçeriğe atla</a>
<header class="app-header" data-region="header">
<div class="app-header__identity">
<h1 class="app-header__product">$panel_name</h1>
<span class="app-header__surface">$panel_surface</span>
</div>
<nav class="app-header__nav" aria-label="Panel bölümleri">
<a class="nav-link" href="/"$nav_fleet_attr>Filo</a>
<a class="nav-link" href="/hakkinda"$nav_about_attr>Hakkında ve durum</a>
</nav>
<p class="app-header__target">
<span class="app-header__label">Hedef</span>
<span class="app-header__value mono">$target_identity</span>
</p>
<div class="app-header__actions">
$primary_action
$gated_action
</div>
<p class="app-header__privilege">
<span class="app-header__label">Yetki</span>
<span class="badge badge--privilege-$privilege_level">$privilege_word</span>
<span class="app-header__reason" id="yetki-nedeni">$privilege_reason</span>
</p>
</header>
<main class="app-main" id="main">
$content
</main>
<footer class="app-footer" data-region="footer">
<span>$footer_source</span>
<span class="mono">$footer_meta</span>
</footer>
</body>
</html>

View file

@ -1,6 +0,0 @@
<li class="channel">
<span class="channel__name">$label</span>
<span class="channel__status channel__status--$tone">$status_word</span>
<span class="channel__meta mono">$checked_at_text</span>
<span class="channel__hint">$hint</span>
</li>

View file

@ -1,23 +0,0 @@
<h2 class="section-title">Seçili cihaz</h2>
<p class="detail__state">
<span class="badge badge--state-$state_token_suffix" title="$state_meaning">$state_label</span>
<span class="detail__meaning">$state_meaning</span>
</p>
$selection_note_block
$reason_block
<dl class="facts">
<dt>Hedef kimliği</dt><dd class="mono">$device_id</dd>
<dt>Hostname</dt><dd class="mono">$hostname</dd>
<dt>Bayi no</dt><dd class="mono">$dealer_id</dd>
<dt>Dalga</dt><dd><span class="mono">$wave_label</span> <span class="cell-note">hedef $wave_target cihaz</span></dd>
<dt>Son görülme</dt><dd class="mono">$last_seen_text</dd>
<dt>Kayıt kaynağı</dt><dd>$record_source</dd>
</dl>
$note_block
$notes_block
<h3 class="subsection-title">Kanal doğrulaması</h3>
<p class="section-note">$ready_gate_note</p>
<ul class="channels">
$channel_rows
</ul>
<p class="section-note">$channel_count_note</p>

View file

@ -1,2 +0,0 @@
<h2 class="section-title">Seçili cihaz</h2>
<p class="section-note">$empty_text</p>

View file

@ -1,7 +0,0 @@
<h2 class="section-title">Seçili cihaz</h2>
<p class="detail__state">
<span class="badge badge--state-failed">HATA</span>
<span class="detail__meaning">Bu kimlik envanterde yok.</span>
</p>
<p class="section-note">İstenen cihaz kimliği: <span class="mono">$device_id</span>. Envanterde bu kimliğe sahip bir kayıt yok; panel eksik kaydı uydurmaz.</p>
<p class="section-note"><a href="$reset_href">Tüm filoyu göster</a></p>

View file

@ -1,7 +0,0 @@
<tr class="fleet-row$row_selected_class"$row_selected_attr>
<td><span class="badge badge--state-$state_token_suffix" title="$state_meaning">$state_label</span></td>
<td class="mono"><a class="row-select" href="$select_href">$device_id</a></td>
<td class="mono">$hostname</td>
<td><span class="mono">$wave_label</span></td>
<td class="mono">$last_seen_text</td>
</tr>

View file

@ -1 +0,0 @@
<p class="empty-note">$text <a href="/">Filtreyi temizle</a></p>

View file

@ -1,29 +0,0 @@
<div class="filter-bar" data-region="filter">
<form class="filter-bar__form" method="get" action="$form_action">
<p class="filter-field">
<label for="filtre-q">Ara (kimlik, hostname, bayi no)</label>
<input id="filtre-q" name="q" type="search" value="$query" autocomplete="off">
</p>
<p class="filter-field">
<label for="filtre-durum">Durum</label>
<select id="filtre-durum" name="durum">
$state_options
</select>
</p>
<p class="filter-field">
<label for="filtre-dalga">Dalga</label>
<select id="filtre-dalga" name="dalga">
$wave_options
</select>
</p>
<p class="filter-field filter-field--check">
<input id="filtre-hazir-degil" name="hazir_degil" type="checkbox" value="1"$only_not_ready_checked>
<label for="filtre-hazir-degil">Yalnız hazır olmayanlar</label>
</p>
<p class="filter-field filter-field--actions">
<button class="button button--primary" type="submit">Filtrele</button>
<a class="button" href="$reset_href">Filtreyi temizle</a>
</p>
</form>
<p class="filter-bar__summary">Gösterilen: <span class="mono">$shown_count</span> / <span class="mono">$total_count</span> · $filter_description</p>
</div>

View file

@ -1,46 +0,0 @@
<section class="data-banner data-banner--$data_banner_tone" data-region="data-source" role="status">
<span class="data-banner__word">$data_banner_word</span>
<span class="data-banner__text">$data_banner_text</span>
</section>
<section class="summary" data-region="summary" aria-label="Durum özeti">
<div class="summary__lead">
<span class="summary__lead-figure mono">$lead_figure</span>
<span class="summary__lead-label">$lead_label</span>
<span class="summary__lead-note">$lead_note</span>
</div>
<ul class="summary__grid">
$summary_cells
</ul>
</section>
$warnings_block
<section class="split" data-region="body">
<div class="split__list" data-region="list">
<h2 class="section-title">Filo cihazları</h2>
<p class="section-note">Hazır olmayan cihazlar listede önce gelir. <span class="mono">$list_count_text</span></p>
$filter_bar
$wave_strip
$invalid_block
<table class="fleet-table">
<caption class="visually-hidden">Filo cihazları: durum, cihaz kimliği, hostname, dalga ve son görülme</caption>
<thead>
<tr>
<th scope="col">Durum</th>
<th scope="col">Cihaz kimliği</th>
<th scope="col">Hostname</th>
<th scope="col">Dalga</th>
<th scope="col">Son görülme</th>
</tr>
</thead>
<tbody>
$device_rows
</tbody>
</table>
$empty_note
</div>
<aside class="split__detail" data-region="detail" aria-label="Cihaz ayrıntısı">
$device_detail
</aside>
</section>

View file

@ -1,2 +0,0 @@
<button class="button button--gated" type="button" disabled aria-disabled="true" aria-describedby="gated-reason">Onaylı güncelleme</button>
<span class="gated-reason" id="gated-reason">Kapalı: $reason</span>

View file

@ -1,7 +0,0 @@
<section class="invalid" data-region="invalid" role="status" aria-label="Geçersiz envanter kayıtları">
<h2 class="section-title">Geçersiz kayıtlar: <span class="mono">$invalid_count</span></h2>
<p class="section-note">Kimliği kurulamayan kayıt sessizce düşürülmez; aşağıda nedeniyle birlikte durur (docs/02).</p>
<ul class="invalid__list">
$invalid_items
</ul>
</section>

View file

@ -1 +0,0 @@
<li class="invalid__item"><span class="mono">$record_ref</span> — $reason</li>

View file

@ -1,5 +0,0 @@
<section class="card card--danger" data-region="not-found">
<h2 class="section-title">Sayfa yok</h2>
<p class="section-note">İstenen adres bu panelde tanımlı değil: <span class="mono">$request_path</span></p>
<p class="section-note">Panel sayfaları: <a href="/">Filo</a> · <a href="/hakkinda">Hakkında ve durum</a></p>
</section>

View file

@ -1 +0,0 @@
<p class="section-note">Not: $text</p>

View file

@ -1 +0,0 @@
<li class="notes__item">$text</li>

View file

@ -1,3 +0,0 @@
<ul class="notes__list">
$items
</ul>

View file

@ -1 +0,0 @@
<option value="$value"$selected>$label</option>

View file

@ -1 +0,0 @@
<a class="button button--primary" href="$href">Filo durumunu yenile</a>

View file

@ -1 +0,0 @@
<p class="reason">$text</p>

View file

@ -1,6 +0,0 @@
<section class="card card--danger" data-region="refused">
<h2 class="section-title">Yöntem reddedildi</h2>
<p class="section-note">Gelen yöntem: <span class="mono">$method</span></p>
<p class="section-note">Bu panel yalnız okuma yapar: hiçbir uç nokta cihazda değişiklik yapmaz, komut çalıştırmaz ve dosya yazmaz.</p>
<p class="section-note">Kabul edilen yöntemler: <span class="mono">GET</span>, <span class="mono">HEAD</span>. Yetki düzeyi: $privilege_word.</p>
</section>

View file

@ -1 +0,0 @@
<li class="rules__item">$text</li>

View file

@ -1 +0,0 @@
<p class="selection-note">$text</p>

View file

@ -1,5 +0,0 @@
<li class="summary__cell">
<span class="summary__cell-value mono">$count</span>
<span class="summary__cell-word">$word</span>
<span class="summary__cell-note">$note</span>
</li>

View file

@ -1 +0,0 @@
<li class="warnings__item">$text</li>

View file

@ -1,6 +0,0 @@
<section class="warnings" data-region="warnings" role="status" aria-label="Filtre uyarıları">
<h2 class="section-title">Uyarılar</h2>
<ul class="warnings__list">
$warning_items
</ul>
</section>

View file

@ -1 +0,0 @@
<span class="wave-strip__item"><span class="mono">$code</span> $count/$target</span>

View file

@ -1 +0,0 @@
<p class="wave-strip" data-region="waves">Dalga doluluğu: $wave_items</p>