From 8cb0c7206593b5221e5eea53a18f48a95c11d5b2 Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Thu, 17 Sep 2026 14:35:34 +0300 Subject: [PATCH] Enhance installation scripts with root privilege checks and user guidance - Added a check in `blueforce-install.sh` to ensure the script is run with root privileges, providing user-friendly error messages and guidance for launching with sudo or using check mode. - Updated `01-precheck.sh` to conditionally require root privileges based on the check mode, allowing for non-root execution in read-only mode. - Modified test scripts to reflect changes in the installation process, ensuring accurate reporting of the Python console's presence and adjusting expectations accordingly. --- repomix-output.xml | 38585 +++++++--------- scripts/install/blueforce-install.sh | 12 + .../install/installer/modules/01-precheck.sh | 6 +- tests/check-admin-static.sh | 4 +- 4 files changed, 16778 insertions(+), 21829 deletions(-) diff --git a/repomix-output.xml b/repomix-output.xml index f7aae01..9a05983 100644 --- a/repomix-output.xml +++ b/repomix-output.xml @@ -43,12 +43,9 @@ The content is organized as follows: admin/ lib/ creds.sh - targets.sh - tui.sh bf bf-bootstrap.sh bf-creds - bf-menu README.md ansible/ inventory/ @@ -112,11 +109,15 @@ bfos/ __main__.py cli.py config.py + identity.py operations.py + preferences.py runner.py tui.py ui_gui.py + ui_surfaces.py ui_theme.py + ui_widgets.py config/ docker/ .gitkeep @@ -182,22 +183,10 @@ docs/ 31-LEARNING-GUIDE.md 32-REPOSITORY-AUDIT-AND-CONFLICTS.md 33-OPERATOR-CONSOLE.md + 34-CONSOLE-VISUAL-CONCEPT.md + 35-GUI-SURFACES.md DESIGN.md -docs-site/ - src/ - css/ - custom.css - pages/ - index.tsx - .gitignore - .gitkeep - babel.config.js - docusaurus.config.ts - package.json - README.md - sidebars.js - sync-docs.sh - tsconfig.json + UI-REMAINING-WORK.md migration/ README.md monitoring/ @@ -1609,6 +1598,8 @@ scripts/ bf-remote-status bf-status bf-support-bundle + identity/ + bf-reidentity install/ installer/ modules/ @@ -1630,6 +1621,7 @@ scripts/ 16-gui.sh 17-healthcheck.sh 18-final-check.sh + identity.sh offline-apt.sh optional/ anydesk/ @@ -1662,53 +1654,9 @@ tests/ semaphore-community-limits.md test_bfos_gui_static.py test_bfos.py - test_web_static.py + test_identity.py test-field-os-lifecycle.sh test-firstboot-static.sh -web/ - data/ - fleet.example.json - static/ - app.css - tokens.css - templates/ - about_channel_row.html - about_vocabulary_row.html - about_wave_row.html - about.html - base.html - channel_row.html - detail_empty.html - detail_unknown.html - detail.html - device_row.html - empty_note.html - filter_bar.html - fleet.html - gated_action.html - invalid_block.html - invalid_item.html - not_found_body.html - note_block.html - notes_item.html - notes_list.html - option.html - primary_action.html - reason_block.html - refused_body.html - rule_item.html - selection_note.html - summary_cell.html - warning_item.html - warnings.html - wave_item.html - wave_strip.html - __init__.py - fleet_data.py - panel.py - README.md - render.py - server.py .gitattributes .gitignore IMPLEMENTATION-REPORT.md @@ -1721,5516 +1669,6 @@ SUMMARY.md This section contains the contents of the repository's files. - -"""bfos/ui_theme.py -- design tokens for the device console. - -The values in this file MIRROR ``web/static/tokens.css``, which is the single -source of truth for both operator surfaces (the device console and the central -panel). Nothing here invents a colour: a colour means a device state, a -severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md, -"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the -CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py`` -parses ``tokens.css`` and compares the two files, so a token changed on one -surface and not the other fails the suite instead of drifting. - -Why light, not dark -------------------- -The console runs 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 (``BG_APP``), the panels are -white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and -saturation is spent only where it carries meaning. - -Faces ------ -No webfont, and no font download: the fleet installs offline, so the UI face is -whatever the platform ships and the data face is monospace (ids, counts, exit -codes, timestamps line up in a column). ``apply_theme`` picks the first family -the platform actually has, from a candidate list -- never a missing font name, -which is a silently different layout. - -Sizes and the 4px rhythm ------------------------- -Type sizes and space steps are kept in the token's own unit (CSS px) so the -mirror is exact. Tk font sizes are points, so ``point_size`` converts with the -96 dpi relation the tokens assume; padding and row heights are already pixels -in Tk, so they are used as ``SP_*`` values directly. - -Usage ------ - theme = ui_theme.apply_theme(root) # once, before any widget is built - ttk.Label(parent, style=theme.panel_label) -""" - -from __future__ import annotations - -from typing import Dict, Mapping, Sequence - -try: # pragma: no cover - environment dependent - from tkinter import ttk - - HAVE_TKINTER = True -except Exception: # pragma: no cover - environment dependent - ttk = None # type: ignore[assignment] - HAVE_TKINTER = False - - -# --------------------------------------------------------------------------- -# Ground and structure -- tokens.css: "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: the action the operator came to take, and the -# selected row. Nothing else. -# --------------------------------------------------------------------------- -ACCENT = "#1d4ed8" -ACCENT_HOVER = "#1a43bd" -ACCENT_ACTIVE = "#16389e" -ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill -FOCUS_RING = "#1d4ed8" - -# --------------------------------------------------------------------------- -# State severities -- four, each with a strong form for text and a soft form -# for fills, so a badge is built from one palette without inventing a second. -# --------------------------------------------------------------------------- -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, plus the -# failure and the absence that sit outside the sequence. Named so no surface -# repaints a stage by accident. -# -# PROVISIONED is deliberately NOT the action accent (tokens.css states the -# reason): the accent means "the action you came to take" and "the selected -# row", so a KURULDU badge built from it made a state and a selection the same -# blue. It is a slate of its own, one step along the path and distinct from the -# neutral --idle. -# --------------------------------------------------------------------------- -STATE_PROVISIONED = "#475569" -STATE_ENROLLED = WARN -STATE_READY = OK -STATE_FAILED = DANGER -STATE_ABSENT = IDLE - -# --------------------------------------------------------------------------- -# Type (px, as the tokens declare them) -# --------------------------------------------------------------------------- -SIZE_XS = 12 -SIZE_SM = 13 -SIZE_MD = 15 -SIZE_LG = 18 -SIZE_XL = 22 -SIZE_2XL = 30 - -LEADING_TIGHT = 1.25 -LEADING_NORMAL = 1.5 - -# --------------------------------------------------------------------------- -# Space -- a 4px rhythm, named so a component never hand-picks 7px -# --------------------------------------------------------------------------- -SP_1 = 4 -SP_2 = 8 -SP_3 = 12 -SP_4 = 16 -SP_5 = 24 -SP_6 = 32 -SP_7 = 48 - -RADIUS = 6 -RADIUS_LG = 10 - -# --------------------------------------------------------------------------- -# Metrics -# --------------------------------------------------------------------------- -ROW_H = 44 # fleet table row: comfortable at arm's length -CONTROL_H = 36 # buttons and inputs share one height -HEADER_H = 56 -SIDEBAR_W = 220 - -# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names -# are kept so the two token sets stay comparable and the absence is explicit. -SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)" -SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)" - -# --------------------------------------------------------------------------- -# Fonts -- candidates in preference order, resolved against the platform -# --------------------------------------------------------------------------- -FONT_UI_CANDIDATES: Sequence[str] = ( - "Noto Sans", - "DejaVu Sans", - "Ubuntu", - "Liberation Sans", - "Segoe UI", - "Helvetica", -) -FONT_DATA_CANDIDATES: Sequence[str] = ( - "DejaVu Sans Mono", - "Liberation Mono", - "Noto Sans Mono", - "Consolas", - "Menlo", - "Courier New", -) -FONT_UI_FALLBACK = "TkDefaultFont" -FONT_DATA_FALLBACK = "TkFixedFont" - -#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula: -#: rounding is per size so the console keeps the token's steps instead of -#: collapsing two of them onto the same point size. -POINT_SIZES: Mapping[int, int] = { - SIZE_XS: 9, - SIZE_SM: 10, - SIZE_MD: 11, - SIZE_LG: 14, - SIZE_XL: 16, - SIZE_2XL: 22, -} - -# --------------------------------------------------------------------------- -# The mirror table -- CSS custom property -> value. Kept as data so the parity -# test reads the same list the code uses. -# --------------------------------------------------------------------------- -TOKENS: Mapping[str, str] = { - "--bg-app": BG_APP, - "--bg-panel": BG_PANEL, - "--bg-sunken": BG_SUNKEN, - "--bg-header": BG_HEADER, - "--border": BORDER, - "--border-strong": BORDER_STRONG, - "--text": TEXT, - "--text-muted": TEXT_MUTED, - "--text-faint": TEXT_FAINT, - "--text-on-dark": TEXT_ON_DARK, - "--text-on-accent": TEXT_ON_ACCENT, - "--accent": ACCENT, - "--accent-hover": ACCENT_HOVER, - "--accent-active": ACCENT_ACTIVE, - "--accent-soft": ACCENT_SOFT, - "--focus-ring": FOCUS_RING, - "--ok": OK, - "--ok-soft": OK_SOFT, - "--warn": WARN, - "--warn-soft": WARN_SOFT, - "--danger": DANGER, - "--danger-soft": DANGER_SOFT, - "--info": INFO, - "--info-soft": INFO_SOFT, - "--idle": IDLE, - "--idle-soft": IDLE_SOFT, - "--state-provisioned": STATE_PROVISIONED, - "--state-enrolled": STATE_ENROLLED, - "--state-ready": STATE_READY, - "--state-failed": STATE_FAILED, - "--state-absent": STATE_ABSENT, -} - -#: Space tokens, mirrored by name (the parity test compares these too). -SPACE_TOKENS: Mapping[str, int] = { - "--sp-1": SP_1, - "--sp-2": SP_2, - "--sp-3": SP_3, - "--sp-4": SP_4, - "--sp-5": SP_5, - "--sp-6": SP_6, - "--sp-7": SP_7, -} - -#: Type-size tokens, mirrored by name. -SIZE_TOKENS: Mapping[str, int] = { - "--size-xs": SIZE_XS, - "--size-sm": SIZE_SM, - "--size-md": SIZE_MD, - "--size-lg": SIZE_LG, - "--size-xl": SIZE_XL, - "--size-2xl": SIZE_2XL, -} - -#: Metric tokens, mirrored by name. -METRIC_TOKENS: Mapping[str, int] = { - "--row-h": ROW_H, - "--control-h": CONTROL_H, - "--header-h": HEADER_H, - "--sidebar-w": SIDEBAR_W, -} - -#: Every mirrored value, by CSS token name (used by the parity test). -ALL_TOKENS: Mapping[str, str] = { - **TOKENS, - **{name: str(value) for name, value in SPACE_TOKENS.items()}, - **{name: str(value) for name, value in SIZE_TOKENS.items()}, - **{name: str(value) for name, value in METRIC_TOKENS.items()}, -} - -# --------------------------------------------------------------------------- -# Severity vocabulary -- the fill/text pair a badge or a row is built from -# --------------------------------------------------------------------------- -SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle") - -SEVERITY_FILL: Mapping[str, str] = { - "ok": OK_SOFT, - "warn": WARN_SOFT, - "danger": DANGER_SOFT, - "info": INFO_SOFT, - "idle": IDLE_SOFT, -} -SEVERITY_TEXT: Mapping[str, str] = { - "ok": OK, - "warn": WARN, - "danger": DANGER, - "info": INFO, - "idle": IDLE, -} - -#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md). -STATE_WORDS: Mapping[str, str] = { - "provisioned": "KURULDU", - "enrolled": "KAYITLI", - "ready": "HAZIR", - "failed": "HATA", - "absent": "YOK", -} -STATE_FILL: Mapping[str, str] = { - # The tokens give no soft form for provisioned; the console borrows the - # sunken well fill rather than inventing a hue, so a KURULDU badge cannot be - # mistaken for the accent-soft selection (see tokens.css, device state). - "provisioned": BG_SUNKEN, - "enrolled": WARN_SOFT, - "ready": OK_SOFT, - "failed": DANGER_SOFT, - "absent": IDLE_SOFT, -} -STATE_TEXT_COLOUR: Mapping[str, str] = { - "provisioned": STATE_PROVISIONED, - "enrolled": STATE_ENROLLED, - "ready": STATE_READY, - "failed": STATE_FAILED, - "absent": STATE_ABSENT, -} - -#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege"). -PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"} - -#: ttk style names, resolved once by apply_theme and read by the view. -_FAMILIES: Dict[str, str] = {} - - -# --------------------------------------------------------------------------- -# Small helpers -# --------------------------------------------------------------------------- -def point_size(px: int) -> int: - """Token px -> Tk point size (see POINT_SIZES).""" - return POINT_SIZES.get(px, max(7, round(px * 0.75))) - - -def ui_family() -> str: - """The UI face resolved by :func:`apply_theme` (platform default before).""" - return _FAMILIES.get("ui", FONT_UI_FALLBACK) - - -def data_family() -> str: - """The monospace face resolved by :func:`apply_theme`.""" - return _FAMILIES.get("data", FONT_DATA_FALLBACK) - - -def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple: - """A Tk font tuple in the platform UI face.""" - if weight == "normal": - return (ui_family(), point_size(size)) - return (ui_family(), point_size(size), weight) - - -def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple: - """A Tk font tuple in the data (monospace) face.""" - if weight == "normal": - return (data_family(), point_size(size)) - return (data_family(), point_size(size), weight) - - -def state_style(state: str) -> str: - """ttk style name for a state badge ("KURULDU" ...).""" - return f"State{state.capitalize()}.Badge.TLabel" - - -#: State key -> badge style, built from STATE_WORDS so the word and the colour -#: can never be paired up wrongly on one surface. -STATE_BADGE_STYLE: Mapping[str, str] = { - key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS -} - -#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else). -PRIVILEGE_BADGE_STYLE: Mapping[str, str] = { - "full": "Privilege.Badge.TLabel", - "read_only": "ReadOnly.Badge.TLabel", -} - - -def severity_style(severity: str) -> str: - """ttk style name for a severity pill (ok / warn / danger / info / idle).""" - return f"{severity.capitalize()}.Status.TLabel" - - -def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str: - """First family the platform actually has; the Tk default otherwise.""" - try: - from tkinter import font as tkfont - - available = {name.lower() for name in tkfont.families(root)} - except Exception: # pragma: no cover - font database unavailable - return fallback - for name in candidates: - if name.lower() in available: - return name - return fallback - - -def _configure(style, name: str, **options) -> None: - """Configure a ttk style, ignoring options the platform's theme rejects. - - ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs - 9.x): a missing element option raises TclError. Losing one border colour is - a cosmetic difference; crashing the console on startup is not. - """ - try: - style.configure(name, **options) - except Exception: - for key, value in options.items(): - try: - style.configure(name, **{key: value}) - except Exception: - continue - - -def _map(style, name: str, **options) -> None: - """State map for a ttk style, same tolerance as :func:`_configure`.""" - try: - style.map(name, **options) - except Exception: - for key, value in options.items(): - try: - style.map(name, **{key: value}) - except Exception: - continue - - -# --------------------------------------------------------------------------- -# The one setup function, called once at startup -# --------------------------------------------------------------------------- -def apply_theme(root) -> Mapping[str, str]: - """Install the token set on ``root``'s ttk styles; returns the style names. - - Idempotent and cheap: the GUI calls it once before building any widget, and - a second call simply re-applies the same values (tests may build several - roots in one process). - """ - if not HAVE_TKINTER: # pragma: no cover - environment dependent - raise RuntimeError("tkinter yok: tema uygulanamaz") - assert ttk is not None # narrowing for the type checker - - _FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK) - _FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK) - - try: - root.configure(bg=BG_APP) - except Exception: # pragma: no cover - non-Tk root - pass - - style = ttk.Style(root) - # clam honours background/bordercolor on the widget elements we restyle; - # the 'default' theme on Linux ignores most of them, which is how a - # console ends up grey whatever the tokens say. - try: - if "clam" in style.theme_names(): - style.theme_use("clam") - except Exception: # pragma: no cover - theme list unavailable - pass - - base_font = ui_font(SIZE_MD) - _configure( - style, ".", - background=BG_APP, - foreground=TEXT, - font=base_font, - bordercolor=BORDER, - lightcolor=BORDER, - darkcolor=BORDER, - troughcolor=BG_SUNKEN, - focuscolor=FOCUS_RING, - ) - - # -- frames: the ground, the raised panel, the well, the one dark band -- - _configure(style, "TFrame", background=BG_APP, borderwidth=0) - _configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1, - relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) - _configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1, - relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) - _configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0) - _configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1, - relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT, - darkcolor=WARN_SOFT) - _configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1, - relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT, - darkcolor=INFO_SOFT) - _configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1, - relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT, - darkcolor=ACCENT_SOFT) - - # -- labels ------------------------------------------------------------- - _configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font) - _configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT) - _configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT) - _configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED, - font=ui_font(SIZE_SM)) - _configure(style, "PanelMuted.TLabel", background=BG_PANEL, - foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) - _configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT, - font=ui_font(SIZE_XS)) - _configure(style, "PanelFaint.TLabel", background=BG_PANEL, - foreground=TEXT_FAINT, font=ui_font(SIZE_XS)) - _configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, - font=ui_font(SIZE_SM), anchor="e") - _configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT, - font=ui_font(SIZE_LG, "bold")) - _configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, - font=ui_font(SIZE_XS, "bold")) - # The one loud figure per view. - _configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT, - font=ui_font(SIZE_2XL, "bold")) - _configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK, - font=ui_font(SIZE_SM)) - _configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN, - font=ui_font(SIZE_SM)) - _configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO, - font=ui_font(SIZE_SM)) - _configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER, - font=ui_font(SIZE_SM)) - _configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN, - font=ui_font(SIZE_SM, "bold")) - _configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK, - font=ui_font(SIZE_SM, "bold")) - _configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER, - font=ui_font(SIZE_SM, "bold")) - # Dialogs sit on the app ground, not on a panel. - _configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT, - font=ui_font(SIZE_MD)) - _configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT, - font=ui_font(SIZE_LG, "bold")) - _configure(style, "DialogMuted.TLabel", background=BG_APP, - foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) - # Header (dark) region. - _configure(style, "HeaderTitle.TLabel", background=BG_HEADER, - foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold")) - _configure(style, "HeaderMeta.TLabel", background=BG_HEADER, - foreground=TEXT_FAINT, font=ui_font(SIZE_SM)) - _configure(style, "HeaderTarget.TLabel", background=BG_HEADER, - foreground=TEXT_ON_DARK, font=data_font(SIZE_SM)) - _configure(style, "HeaderFigure.TLabel", background=BG_HEADER, - foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold")) - - # -- badges: state word + colour, never colour alone ------------------- - _configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE, - font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1)) - for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"), - ("ready", "Ready"), ("failed", "Failed"), - ("absent", "Absent")): - _configure( - style, f"State{word_style}.Badge.TLabel", - background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key], - font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1), - ) - _configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK, - font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) - _configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN, - font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) - _configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT, - foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) - _configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT, - foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) - _configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK, - font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) - _configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN, - font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) - _configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO, - font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) - _configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE, - font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) - - # -- status pill (footer): the console's own state --------------------- - _configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE, - font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2)) - for severity in SEVERITIES: - _configure( - style, f"{severity.capitalize()}.Status.TLabel", - background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity], - font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2), - ) - - # -- inputs: one height with the buttons --------------------------------- - entry_opts = dict( - fieldbackground=BG_PANEL, - background=BG_PANEL, - foreground=TEXT, - bordercolor=BORDER_STRONG, - lightcolor=BORDER_STRONG, - darkcolor=BORDER_STRONG, - insertcolor=TEXT, - padding=(SP_2, SP_1), - font=ui_font(SIZE_MD), - ) - _configure(style, "TEntry", **entry_opts) - _map(style, "TEntry", - bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)], - foreground=[("disabled", TEXT_FAINT)], - fieldbackground=[("disabled", BG_SUNKEN)]) - _configure(style, "Invalid.TEntry", **entry_opts) - _configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER, - darkcolor=DANGER) - _map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)]) - - _configure(style, "TCombobox", **entry_opts) - _configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT, - selectforeground=TEXT, padding=(SP_2, SP_1)) - _map(style, "TCombobox", - fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)], - foreground=[("disabled", TEXT_FAINT)], - bordercolor=[("focus", FOCUS_RING)]) - - _configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT, - font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1)) - _map(style, "TCheckbutton", - background=[("active", BG_APP)], - foreground=[("disabled", TEXT_FAINT)], - indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)]) - _configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT, - font=ui_font(SIZE_SM), padding=(SP_1, SP_1)) - _map(style, "Panel.TCheckbutton", - background=[("active", BG_PANEL), ("disabled", BG_PANEL)], - foreground=[("disabled", TEXT_FAINT)]) - - # -- buttons: hairline secondary, one accent primary, danger for - # destructive work ------------------------------------------------ - _configure(style, "TButton", background=BG_PANEL, foreground=TEXT, - bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL, - font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid", - focusthickness=1, focuscolor=FOCUS_RING, anchor="center") - _map(style, "TButton", - background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN), - ("active", ACCENT_SOFT)], - foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)], - bordercolor=[("focus", FOCUS_RING)], - lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)], - darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)]) - _configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT, - bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT) - _map(style, "Accent.TButton", - background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), - ("active", ACCENT_HOVER)], - foreground=[("disabled", TEXT_FAINT)], - bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)], - lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], - darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) - _configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT, - bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER) - _map(style, "Danger.TButton", - background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT), - ("active", DANGER_SOFT)], - foreground=[("disabled", TEXT_FAINT), ("active", DANGER), - ("pressed", DANGER)], - bordercolor=[("focus", DANGER)], - lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)], - darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)]) - # Simple mode: the three big buttons, one of them the action accent. - _configure(style, "Big.TButton", font=ui_font(SIZE_LG), - padding=(SP_5, SP_4), anchor="w") - _configure(style, "Accent.Big.TButton", background=ACCENT, - foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT, - darkcolor=ACCENT) - _map(style, "Accent.Big.TButton", - background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), - ("active", ACCENT_HOVER)], - foreground=[("disabled", TEXT_FAINT)], - lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], - darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) - _configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER) - - # -- table: the fleet/operation list ------------------------------------ - _configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL, - foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER, - darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H) - _configure(style, "Treeview.Heading", background=BG_SUNKEN, - foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"), - relief="flat", padding=(SP_2, SP_2)) - _map(style, "Treeview", - background=[("selected", ACCENT_SOFT)], - foreground=[("selected", TEXT)]) - _map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)]) - - # -- tabs, separators, scrollbars -------------------------------------- - _configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER, - lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0)) - _configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED, - font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER) - _map(style, "TNotebook.Tab", - background=[("selected", BG_PANEL), ("active", BG_PANEL)], - foreground=[("selected", TEXT), ("active", TEXT)], - expand=[("selected", (0, 0, 0, 0))]) - _configure(style, "TSeparator", background=BORDER) - for orient in ("Vertical", "Horizontal"): - _configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN, - troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED, - lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN) - _map(style, f"{orient}.TScrollbar", - background=[("active", BORDER_STRONG)]) - - return { - "frame": "TFrame", - "panel": "Panel.TFrame", - "sunken": "Sunken.TFrame", - "header": "Header.TFrame", - "warn_band": "Warn.TFrame", - "info_band": "Info.TFrame", - "label": "TLabel", - "panel_label": "Panel.TLabel", - "panel_title": "PanelTitle.TLabel", - "muted": "Muted.TLabel", - "panel_muted": "PanelMuted.TLabel", - "faint": "Faint.TLabel", - "panel_faint": "PanelFaint.TLabel", - "field_label": "FieldLabel.TLabel", - "section": "Section.TLabel", - "figure": "Figure.TLabel", - "ok_label": "Ok.TLabel", - "warn_label": "Warn.TLabel", - "info_label": "InfoText.TLabel", - "danger_label": "Danger.TLabel", - "app_warn": "AppWarn.TLabel", - "panel_ok": "PanelOk.TLabel", - "panel_danger": "PanelDanger.TLabel", - "header_title": "HeaderTitle.TLabel", - "header_meta": "HeaderMeta.TLabel", - "header_target": "HeaderTarget.TLabel", - "header_figure": "HeaderFigure.TLabel", - "badge": "Badge.TLabel", - "badge_info": "Info.Badge.TLabel", - "badge_neutral": "Neutral.Badge.TLabel", - "badge_destructive": "Type.Destructive.Badge.TLabel", - "badge_readonly": "Type.ReadOnly.Badge.TLabel", - "badge_open": "Open.Badge.TLabel", - "badge_closed": "Closed.Badge.TLabel", - "privilege": "Privilege.Badge.TLabel", - "readonly": "ReadOnly.Badge.TLabel", - "status": "Status.TLabel", - "button": "TButton", - "accent_button": "Accent.TButton", - "danger_button": "Danger.TButton", - "big_button": "Big.TButton", - "big_accent_button": "Accent.Big.TButton", - "quiet_button": "Quiet.TButton", - "entry": "TEntry", - "invalid_entry": "Invalid.TEntry", - "panel_check": "Panel.TCheckbutton", - "check": "TCheckbutton", - "dialog": "Dialog.TLabel", - "dialog_title": "DialogTitle.TLabel", - "dialog_muted": "DialogMuted.TLabel", - } - - -def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial - """The danger colour used for a focused destructive control. - - A named function (rather than a bare literal) so the fifth-hue rule stays - visible: focus on a dangerous action reuses the danger token. - """ - return DANGER - - - -#!/usr/bin/env python3 -"""web panel — static and contract tests (stdlib unittest). - -No port is bound, no browser is opened and no server process is started: the -whole HTTP surface is covered through ``web.panel.route()``, which is a pure -function, and every rendering claim is checked on the rendered bytes. - -What these tests defend (the things that could silently regress): - - * the Turkish status vocabulary still matches docs/DESIGN.md exactly, so the - device console and the central panel cannot drift apart; - * every user-visible string is non-empty, Turkish and free of placeholders; - * no external URL, no webfont, no hex colour and no new colour token anywhere - in the templates or stylesheets: colour comes from tokens.css only; - * the panel imports nothing outside the standard library and can therefore be - installed on the air-gapped fleet it manages; - * only GET/HEAD are served, static assets are allow-listed, and a full sweep - over every endpoint leaves the filesystem untouched; - * the first viewport is ordered and populated as docs/DESIGN.md requires. - -Run: - python3 tests/test_web_static.py -v -""" - -from __future__ import annotations - -import ast -import contextlib -import io -import json -import re -import sys -import tempfile -import unittest -from pathlib import Path -from typing import Any - -REPO_ROOT = Path(__file__).resolve().parents[1] -WEB_DIR = REPO_ROOT / "web" -TEMPLATES_DIR = WEB_DIR / "templates" -STATIC_DIR = WEB_DIR / "static" -TOKENS_CSS = STATIC_DIR / "tokens.css" -DESIGN_DOC = REPO_ROOT / "docs" / "DESIGN.md" - -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - -import web # noqa: E402 (path set above) -from web import fleet_data, panel, render, server # noqa: E402 - -# The panel must be the one in this checkout, not some unrelated "web" package. -assert Path(web.__file__).resolve().parent == WEB_DIR, f"yanlis paket: {web.__file__}" - -NOW = "2026-09-16T22:00:00+03:00" -STATE_WORDS = ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK") - -# Everything an air-gapped, framework-free surface must never contain. -FORBIDDEN_ASSET_MARKERS = ( - "http://", - "https://", - "//cdn", - "cdn.jsdelivr", - "cdnjs", - "unpkg", - "fonts.googleapis", - "fonts.gstatic", - "@import", - "@font-face", - "src=", - "xlink:href=", -) -FORBIDDEN_IMPORT_ROOTS = ("fastapi", "flask", "jinja2", "uvicorn", "django", "werkzeug", "starlette", "requests") -FORBIDDEN_IMPORTS = ("urllib.request", "http.client", "socket", "subprocess", "shutil", "ftplib") -FORBIDDEN_SOURCE_MARKERS = ("os.system(", "subprocess.", "urllib.request", "requests.", "eval(", "exec(") - -HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{3,8}\b") -CUSTOM_PROPERTY = re.compile(r"^\s*--[A-Za-z0-9-]+\s*:", re.M) -# A custom-property declaration, e.g. `--state-ready: var(--ok);` -CUSTOM_PROPERTY_DECL = re.compile(r"^\s*--([A-Za-z0-9-]+)\s*:\s*([^;]+);", re.M) -# A single `var(--name)` reference inside a value. -VAR_REFERENCE = re.compile(r"var\(\s*--([A-Za-z0-9-]+)\s*\)") -DEFINED_TOKEN = re.compile(r"^\s*(--[a-z0-9-]+)\s*:", re.M) -USED_TOKEN = re.compile(r"var\((--[a-z0-9-]+)\)") -TEXT_TAGS = ("td", "th", "span", "h1", "h2", "h3", "p", "li", "option", "dd", "dt", "button", "a", "caption", "label") -ELEMENT = re.compile(r"<(%s)\b[^>]*>(.*?)" % "|".join(TEXT_TAGS), re.S) -ROW_BADGE = re.compile(r']*>([^<]*)') - - -# -------------------------------------------------------------------------- -# helpers -# -------------------------------------------------------------------------- - - -def token_values(text: str) -> dict[str, str]: - """Every custom-property declaration in a stylesheet, name -> raw value.""" - return { - name: value.strip() - for name, value in CUSTOM_PROPERTY_DECL.findall(text) - } - - -def resolved_colour(name: str, values: dict[str, str], limit: int = 12) -> str | None: - """Follow ``var(--x)`` aliases to the colour a token actually paints. - - Comparing raw declarations is not enough to prove two tokens differ: a - mutation writing ``--state-provisioned: var(--accent)`` leaves the strings - unequal while painting exactly the same blue. Only the resolved value is - evidence. - """ - seen: set[str] = set() - while limit > 0: - value = values.get(name) - if value is None: - return None - reference = VAR_REFERENCE.fullmatch(value) - if reference is None: - return value - name = reference.group(1) - if name in seen: - return value # a cycle: report what was declared rather than loop - seen.add(name) - limit -= 1 - return values.get(name) - - -def design_vocabulary() -> list[tuple[str, str]]: - """Read the status vocabulary table out of docs/DESIGN.md.""" - text = DESIGN_DOC.read_text(encoding="utf-8") - row = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*`(--state-[a-z-]+)`\s*\|", re.M) - return row.findall(text) - - -def text_files() -> list[Path]: - """Templates and stylesheets: everything the browser receives from disk.""" - return sorted(TEMPLATES_DIR.glob("*.html")) + sorted(STATIC_DIR.glob("*.css")) - - -def empty_elements(markup: str) -> list[str]: - """Visible elements that carry no text at all (an empty label is a bug).""" - problems: list[str] = [] - for match in ELEMENT.finditer(markup): - inner = re.sub(r"<[^>]+>", "", match.group(2)) - if not inner.strip(): - problems.append(match.group(0)[:140]) - return problems - - -def tree_fingerprint(root: Path) -> tuple: - """Sizes and mtimes of every source file, ignoring interpreter caches.""" - entries = [] - for path in sorted(root.rglob("*")): - if "__pycache__" in path.parts or not path.is_file(): - continue - stat = path.stat() - entries.append((str(path.relative_to(root)), stat.st_size, stat.st_mtime_ns)) - return tuple(entries) - - -class PanelCase(unittest.TestCase): - """Shared fixture: the labelled demo snapshot and both privilege levels.""" - - @classmethod - def setUpClass(cls) -> None: - cls.snapshot = fleet_data.demo_snapshot(NOW) - cls.read_only = render.privilege_from_level("read-only") - cls.full = render.privilege_from_level("full") - cls.info = render.ServerInfo( - host="127.0.0.1", - port=8787, - privilege=cls.read_only, - python_version="3.13.0", - started_at=NOW, - ) - cls.context = panel.PanelContext( - snapshot=cls.snapshot, privilege=cls.read_only, server_info=cls.info - ) - full_info = render.ServerInfo( - host="127.0.0.1", - port=8787, - privilege=cls.full, - python_version="3.13.0", - started_at=NOW, - ) - cls.full_context = panel.PanelContext( - snapshot=cls.snapshot, privilege=cls.full, server_info=full_info - ) - - # -- request helpers --------------------------------------------------- - def get(self, target: str, method: str = "GET", context: panel.PanelContext | None = None): - return panel.route(context or self.context, method, target) - - def page(self, target: str = "/", method: str = "GET", context: panel.PanelContext | None = None) -> str: - response = self.get(target, method, context) - self.assertEqual(response.status, 200, f"{target}: {response.status} (beklenen 200)") - return response.body.decode("utf-8") - - def list_region(self, page: str) -> str: - """The device table only, so about/detail badges never leak into a scan.""" - start = page.index('data-region="list"') - end = page.index('data-region="detail"') - return page[start:end] - - def row_words(self, page: str) -> list[str]: - return [word for _, word in ROW_BADGE.findall(self.list_region(page))] - - def snapshot_from(self, document: object, *, now: str = NOW, filename: str = "fleet.json"): - """Write a temporary inventory document and load it through the loader.""" - directory = tempfile.TemporaryDirectory(prefix="bf-panel-test-") - self.addCleanup(directory.cleanup) - path = Path(directory.name) / filename - if isinstance(document, str): - path.write_text(document, encoding="utf-8") - else: - path.write_text(json.dumps(document, ensure_ascii=False), encoding="utf-8") - return fleet_data.load_fleet(path, env={}, now=now) - - -# -------------------------------------------------------------------------- -# vocabulary and design contract -# -------------------------------------------------------------------------- - - -class TestVocabularyMatchesDesign(PanelCase): - def test_vocabulary_matches_docs_design_exactly(self) -> None: - """One word per state, shared with the device console; no fifth word.""" - self.assertEqual( - design_vocabulary(), - [(spec.word, spec.token) for spec in fleet_data.STATE_VOCABULARY], - ) - - def test_design_document_still_has_the_five_words(self) -> None: - words = [word for word, _ in design_vocabulary()] - self.assertEqual(words, list(STATE_WORDS)) - - def test_every_word_is_upper_case_turkish_and_non_empty(self) -> None: - for spec in fleet_data.STATE_VOCABULARY: - with self.subTest(state=spec.state): - self.assertTrue(spec.word.strip(), spec.state) - self.assertEqual(spec.word, spec.word.upper()) - self.assertTrue(spec.meaning.strip(), spec.word) - self.assertNotIn("None", spec.meaning) - - def test_tokens_exist_in_tokens_css(self) -> None: - css = TOKENS_CSS.read_text(encoding="utf-8") - for spec in fleet_data.STATE_VOCABULARY: - with self.subTest(state=spec.state): - self.assertIn(f"{spec.token}:", css) - self.assertIn(f"{spec.soft_token}:", css) - - def test_non_ready_states_sort_before_ready(self) -> None: - self.assertEqual(fleet_data.STATE_ORDER[-1], "READY") - self.assertEqual(set(fleet_data.STATE_ORDER), set(fleet_data.STATE_BY_NAME)) - - def test_demo_data_exercises_every_state_word(self) -> None: - counts = self.snapshot.counts - self.assertEqual(list(counts), list(STATE_WORDS)) - for word, count in counts.items(): - with self.subTest(word=word): - self.assertGreaterEqual(count, 1, f"{word} demo veride yok; sözlük test edilemez") - - def test_demo_data_exercises_every_wave(self) -> None: - counts = fleet_data.wave_counts(self.snapshot.devices) - self.assertEqual(list(counts), [wave.code for wave in fleet_data.WAVES]) - for code, count in counts.items(): - with self.subTest(wave=code): - self.assertGreaterEqual(count, 1) - - def test_waves_match_the_rollout_document(self) -> None: - """docs/18: LAB(2) -> P1(5) -> P2(20) -> W1(50) -> W2(100) -> PROD.""" - self.assertEqual( - [(wave.code, wave.target) for wave in fleet_data.WAVES], - [("LAB", 2), ("P1", 5), ("P2", 20), ("W1", 50), ("W2", 100), ("PROD", 523)], - ) - - def test_four_documented_channels(self) -> None: - self.assertEqual( - [channel.label for channel in fleet_data.CHANNELS], - ["SSH-over-WG", "xRDP", "RustDesk", "MeshCentral"], - ) - self.assertEqual( - [channel.key for channel in fleet_data.CHANNELS], - ["ssh_over_wg", "xrdp", "rustdesk", "meshcentral"], - ) - - def test_channel_status_words_are_turkish_and_non_empty(self) -> None: - for status, word in fleet_data.CHANNEL_STATUS_WORDS.items(): - with self.subTest(status=status): - self.assertTrue(word.strip()) - self.assertNotIn("None", word) - self.assertEqual(set(fleet_data.CHANNEL_STATUS_WORDS), set(fleet_data.CHANNEL_STATUSES)) - - -# -------------------------------------------------------------------------- -# no external assets, no invented colour -# -------------------------------------------------------------------------- - - -class TestAssetsAndColour(PanelCase): - def test_no_external_url_or_remote_asset_in_any_template_or_stylesheet(self) -> None: - for path in text_files(): - text = path.read_text(encoding="utf-8").lower() - for marker in FORBIDDEN_ASSET_MARKERS: - with self.subTest(file=path.name, marker=marker): - self.assertNotIn(marker, text) - - def test_no_javascript_ships_with_the_panel(self) -> None: - for path in text_files(): - text = path.read_text(encoding="utf-8").lower() - with self.subTest(file=path.name): - self.assertNotIn(" None: - for path in text_files(): - text = path.read_text(encoding="utf-8") - if path == TOKENS_CSS: - self.assertTrue(HEX_COLOUR.search(text), "tokens.css palet içermiyor") - continue - with self.subTest(file=path.name): - self.assertEqual(HEX_COLOUR.findall(text), [], f"{path.name} hex renk içeriyor") - - def test_templates_and_app_css_declare_no_custom_property(self) -> None: - for path in text_files(): - if path == TOKENS_CSS: - continue - text = path.read_text(encoding="utf-8") - with self.subTest(file=path.name): - self.assertEqual(CUSTOM_PROPERTY.findall(text), [], f"{path.name} belirteç tanımlıyor") - - def test_every_colour_token_used_is_defined_in_tokens_css(self) -> None: - defined = set(DEFINED_TOKEN.findall(TOKENS_CSS.read_text(encoding="utf-8"))) - self.assertTrue(defined) - used: dict[str, str] = {} - for path in text_files(): - if path == TOKENS_CSS: - continue - for token in USED_TOKEN.findall(path.read_text(encoding="utf-8")): - used[token] = path.name - unknown = {token: name for token, name in used.items() if token not in defined} - self.assertEqual(unknown, {}, f"tokens.css'te tanımsız belirteç: {unknown}") - self.assertTrue(used, "hiç belirteç kullanılmıyor") - - def test_the_action_accent_is_not_a_device_state_colour(self) -> None: - """The accent means "the action you came to take" and "the selected row". - - A state that borrows it makes a KURULDU badge and a selected row the same - blue, so the list can no longer say which of the two it is showing. Aliases - are resolved before comparing: a token may chain (``--state-ready`` to - ``--ok``), and ``var(--accent)`` is the accent whatever the string says. - """ - values = token_values(TOKENS_CSS.read_text(encoding="utf-8")) - self.assertIn("accent", values, "tokens.css'te --accent yok") - accent = resolved_colour("accent", values) - states = {n: v for n, v in values.items() if n.startswith("state-")} - self.assertTrue(states, "tokens.css'te cihaz durumu belirteci yok") - for name in states: - with self.subTest(token=name): - resolved = resolved_colour(name, values) - self.assertIsNotNone(resolved, f"--{name} çözümlenemedi") - self.assertNotEqual( - resolved, accent, - f"--{name} ile --accent aynı rengi veriyor ({resolved}); " - "seçili satır ile durum rozeti ayırt edilemez", - ) - - def test_wave_is_rendered_once_and_not_as_the_bare_code_plus_full_label(self) -> None: - """``detail.html`` printed ``$wave_code`` and then ``$wave_label``, and the - label already begins with the code, so one device read "P2 P2 — PILOT-2" - (the list row was the same). A wave cell must name each wave once. - """ - device = next( - d for d in self.snapshot.devices if d.wave_code == "P2" - ) - target = f"/?cihaz={device.device_id}" - body = panel.route(self.context, "GET", target).body.decode("utf-8") - self.assertNotIn("$wave_name", body, "şablon değişkeni değiştirilmedi") - self.assertNotIn("$wave_code", body, "şablon değişkeni değiştirilmedi") - self.assertNotIn(device.wave_code * 2, body, f"dalga kodu iki kez basılıyor: {device.wave_code}") - self.assertIn(device.wave_label, body, "dalga tam etiketi görünmüyor") - - def test_only_local_stylesheets_are_linked(self) -> None: - base = (TEMPLATES_DIR / "base.html").read_text(encoding="utf-8") - linked = re.findall(r']*href="([^"]+)"', base) - self.assertEqual(linked, ["/static/tokens.css", "/static/app.css"]) - - def test_templates_are_utf8_with_turkish_characters(self) -> None: - for path in sorted(TEMPLATES_DIR.glob("*.html")): - text = path.read_text(encoding="utf-8") - with self.subTest(file=path.name): - self.assertTrue(text.strip()) - base = (TEMPLATES_DIR / "base.html").read_text(encoding="utf-8") - self.assertIn('', base) - - def test_state_badge_styles_use_the_shared_tokens(self) -> None: - css = (STATIC_DIR / "app.css").read_text(encoding="utf-8") - for spec in fleet_data.STATE_VOCABULARY: - with self.subTest(state=spec.state): - self.assertIn(f".badge--state-{spec.token_suffix}", css) - self.assertIn("var(--accent-soft)", css) - self.assertIn(".fleet-row--selected", css) - - -# -------------------------------------------------------------------------- -# standard library only: the fleet installs offline -# -------------------------------------------------------------------------- - - -class TestStdlibOnly(PanelCase): - def web_sources(self) -> list[Path]: - return sorted(WEB_DIR.glob("*.py")) - - def test_web_modules_exist(self) -> None: - names = {path.name for path in self.web_sources()} - self.assertEqual( - names, {"__init__.py", "fleet_data.py", "render.py", "panel.py", "server.py"} - ) - - def test_every_import_resolves_to_the_standard_library(self) -> None: - stdlib = set(sys.stdlib_module_names) - for path in self.web_sources(): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for node in ast.walk(tree): - if isinstance(node, ast.Import): - roots = [alias.name.split(".")[0] for alias in node.names] - elif isinstance(node, ast.ImportFrom): - if node.level: - continue - roots = [(node.module or "").split(".")[0]] - else: - continue - for root in roots: - with self.subTest(file=path.name, module=root): - self.assertIn(root, stdlib | {"web"}) - - def test_no_third_party_web_framework_is_referenced(self) -> None: - for path in self.web_sources(): - text = path.read_text(encoding="utf-8").lower() - for marker in FORBIDDEN_IMPORT_ROOTS: - with self.subTest(file=path.name, marker=marker): - self.assertNotIn(f"import {marker}", text) - self.assertNotIn(f"from {marker}", text) - - def test_no_outbound_client_and_no_process_execution(self) -> None: - for path in self.web_sources(): - text = path.read_text(encoding="utf-8") - for marker in FORBIDDEN_IMPORTS: - with self.subTest(file=path.name, marker=marker): - self.assertNotIn(f"import {marker}", text) - lowered = text.lower() - for marker in FORBIDDEN_SOURCE_MARKERS: - with self.subTest(file=path.name, marker=marker): - self.assertNotIn(marker, lowered) - - def test_route_is_a_pure_function_with_a_small_signature(self) -> None: - import inspect - - signature = inspect.signature(panel.route) - self.assertEqual(list(signature.parameters), ["context", "method", "target"]) - - def test_server_module_takes_host_and_port_from_the_command_line(self) -> None: - args = server.parse_args([]) - self.assertEqual(args.host, "127.0.0.1") - self.assertEqual(args.host, web.DEFAULT_HOST) - self.assertEqual(args.port, web.DEFAULT_PORT) - self.assertEqual(args.privilege, "read-only") - self.assertEqual(args.data, None) - - def test_check_mode_reports_without_binding_a_port(self) -> None: - buffer = io.StringIO() - with contextlib.redirect_stdout(buffer): - code = server.main(["--check"]) - self.assertEqual(code, 0) - output = buffer.getvalue() - self.assertIn(web.PANEL_VERSION, output) - self.assertIn("Veri kaynağı", output) - self.assertIn("Yetki", output) - self.assertIn("sunucu başlatılmadı", output) - - def test_version_and_privilege_flags(self) -> None: - with contextlib.redirect_stdout(io.StringIO()): - with self.assertRaises(SystemExit) as raised: - server.parse_args(["--version"]) - self.assertEqual(raised.exception.code, 0) - with contextlib.redirect_stderr(io.StringIO()): - with self.assertRaises(SystemExit): - server.parse_args(["--privilege", "root"]) - - def test_data_flag_selects_the_inventory_file(self) -> None: - snapshot = self.snapshot_from(self.example_document()) - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "fleet.json" - path.write_text(self.example_document(), encoding="utf-8") - context = server.build_context(server.parse_args(["--data", str(path)])) - self.assertTrue(context.snapshot.is_file) - self.assertEqual(context.snapshot.total, snapshot.total) - self.assertEqual(context.server_info.data_path, str(path)) - - def example_document(self) -> str: - return (WEB_DIR / "data" / "fleet.example.json").read_text(encoding="utf-8") - - def test_example_document_matches_the_panel_schema(self) -> None: - """The shipped example must load cleanly, or it teaches the wrong schema.""" - snapshot = self.snapshot_from(self.example_document()) - self.assertTrue(snapshot.is_file, snapshot.error or "örnek belge okunamadı") - self.assertEqual(snapshot.invalid, ()) - self.assertGreaterEqual(snapshot.total, 5) - self.assertGreater(snapshot.ready_count, 0) - - -# -------------------------------------------------------------------------- -# loader: state machines, refusal paths, provenance -# -------------------------------------------------------------------------- - - -class TestLoader(PanelCase): - READY_RECORD: dict[str, Any] = { - "dealer_id": "12010193", - "wave": "P1", - "state": "READY", - "channels": { - "ssh_over_wg": "verified", - "xrdp": "verified", - "rustdesk": "verified", - "meshcentral": "verified", - }, - } - - def document(self, *records: object) -> dict: - return {"schema_version": 1, "generated_at": NOW, "devices": list(records)} - - def test_missing_default_file_falls_back_to_labelled_demo(self) -> None: - snapshot = fleet_data.load_fleet(None, env={}, now=NOW, default_path="/yok/fleet.json") - self.assertTrue(snapshot.is_demo) - self.assertEqual(snapshot.total, len(fleet_data.DEMO_DEVICES)) - self.assertIn("demo", snapshot.source_label.lower()) - self.assertEqual(snapshot.banner_word, "DEMO VERİ") - self.assertIn("sentetik", snapshot.banner_text) - - def test_missing_explicit_file_is_an_error_and_does_not_become_demo(self) -> None: - snapshot = fleet_data.load_fleet("/yok/boyle/bir/dosya.json", env={}, now=NOW) - self.assertTrue(snapshot.is_error) - self.assertEqual(snapshot.total, 0) - self.assertEqual(snapshot.banner_word, "VERİ YOK") - self.assertIn("bulunamadı", snapshot.error or "") - self.assertIn("Demo veriye düşülmedi", snapshot.banner_text) - - def test_environment_variable_selects_the_document(self) -> None: - document = json.dumps(self.document(self.READY_RECORD)) - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "fleet.json" - path.write_text(document, encoding="utf-8") - snapshot = fleet_data.load_fleet(None, env={fleet_data.DATA_PATH_ENV: str(path)}, now=NOW) - self.assertTrue(snapshot.is_file) - self.assertEqual(snapshot.total, 1) - - def test_identity_is_derived_from_the_dealer_number(self) -> None: - snapshot = self.snapshot_from(self.document(self.READY_RECORD)) - device = snapshot.devices[0] - self.assertEqual(device.device_id, "BF-12010193") - self.assertEqual(device.hostname, "bf-12010193") - self.assertEqual(device.dealer_id, "12010193") - self.assertEqual(snapshot.schema_version, 1) - self.assertEqual(snapshot.generated_at, NOW) - - def test_wave_aliases_resolve_to_the_rollout_codes(self) -> None: - for raw, expected in ( - ("pilot_1", "P1"), - ("LAB", "LAB"), - ("wave_2", "W2"), - ("production", "PROD"), - ): - with self.subTest(wave=raw): - record = dict(self.READY_RECORD, wave=raw) - snapshot = self.snapshot_from(self.document(record)) - self.assertEqual(snapshot.devices[0].wave_code, expected) - - def test_turkish_state_words_are_accepted_as_input(self) -> None: - for raw, expected in (("HAZIR", "READY"), ("Hazır", "READY"), ("hazir", "READY")): - with self.subTest(state=raw): - record = dict(self.READY_RECORD, state=raw) - snapshot = self.snapshot_from(self.document(record)) - self.assertEqual(snapshot.devices[0].state, expected) - - def test_broken_json_is_an_error_that_names_the_document(self) -> None: - snapshot = self.snapshot_from('{"schema_version": 1, "devices": [') - self.assertTrue(snapshot.is_error) - self.assertIn("JSON", snapshot.error or "") - - def test_unsupported_schema_version_is_refused(self) -> None: - for version in (2, 99): - with self.subTest(version=version): - document = {"schema_version": version, "devices": []} - snapshot = self.snapshot_from(document) - self.assertTrue(snapshot.is_error) - self.assertIn("şema sürümü", snapshot.error or "") - - def test_missing_schema_version_or_devices_list_is_refused(self) -> None: - for document in ({"devices": []}, {"schema_version": 1}): - with self.subTest(document=document): - snapshot = self.snapshot_from(document) - self.assertTrue(snapshot.is_error) - - def test_bad_dealer_number_is_shown_not_dropped(self) -> None: - record = dict(self.READY_RECORD, dealer_id="1201019") - snapshot = self.snapshot_from(self.document(record)) - self.assertEqual(snapshot.total, 0) - self.assertEqual(len(snapshot.invalid), 1) - reason = snapshot.invalid[0].reason - self.assertIn("8 hane", reason) - self.assertIn("1201019", reason) - - def test_duplicate_dealer_number_is_refused(self) -> None: - snapshot = self.snapshot_from(self.document(self.READY_RECORD, dict(self.READY_RECORD))) - self.assertEqual(snapshot.total, 1) - self.assertEqual(len(snapshot.invalid), 1) - self.assertIn("Yinelenen", snapshot.invalid[0].reason) - - def test_unknown_wave_is_refused_with_a_turkish_reason(self) -> None: - record = dict(self.READY_RECORD, wave="WAVE-9") - snapshot = self.snapshot_from(self.document(record)) - self.assertEqual(snapshot.total, 0) - self.assertIn("Bilinmeyen dalga", snapshot.invalid[0].reason) - - def test_mismatching_device_id_is_refused(self) -> None: - record = dict(self.READY_RECORD, device_id="BF-12010999") - snapshot = self.snapshot_from(self.document(record)) - self.assertEqual(snapshot.total, 0) - self.assertIn("kurala uymuyor", snapshot.invalid[0].reason) - - def test_unknown_state_becomes_hata_with_the_reason(self) -> None: - record = dict(self.READY_RECORD, state="BOZUK") - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertEqual(device.state_label, "HATA") - self.assertEqual(device.claimed_state, "BOZUK") - self.assertIn("tanınmıyor", device.reason or "") - - def test_missing_state_becomes_hata_and_is_never_invented(self) -> None: - record = {key: value for key, value in self.READY_RECORD.items() if key != "state"} - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertEqual(device.state_label, "HATA") - self.assertIn("Durum alanı yok", device.reason or "") - - def test_ready_claim_without_verified_channels_is_refused(self) -> None: - """docs/27: READY is refused unless every channel reports verified.""" - record = dict(self.READY_RECORD) - record["channels"] = dict(self.READY_RECORD["channels"], xrdp="pending") - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertEqual(device.state_label, "HATA") - self.assertEqual(device.claimed_state, "READY") - self.assertIn("HAZIR iddiası reddedildi", device.reason or "") - self.assertIn("xRDP", device.reason or "") - self.assertIn("xRDP", device.unverified_channels) - - def test_ready_claim_with_every_channel_verified_is_ready(self) -> None: - snapshot = self.snapshot_from(self.document(self.READY_RECORD)) - device = snapshot.devices[0] - self.assertEqual(device.state_label, "HAZIR") - self.assertEqual(device.verified_channel_count, 4) - self.assertEqual(device.unverified_channels, ()) - self.assertIn("dört kanalın dördü de doğrulandı", device.ready_gate_note) - - def test_failed_channel_marks_the_device_hata(self) -> None: - record = dict(self.READY_RECORD, state="ENROLLED") - record["channels"] = dict(self.READY_RECORD["channels"], rustdesk="failed") - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertEqual(device.state_label, "HATA") - self.assertIn("başarısız", device.reason or "") - - def test_panel_never_promotes_a_device(self) -> None: - record = dict(self.READY_RECORD, state="ENROLLED") - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertEqual(device.state_label, "KAYITLI") - self.assertTrue(any("yükseltmez" in note for note in device.notes), device.notes) - - def test_missing_channels_are_all_reported_as_missing(self) -> None: - record = {"dealer_id": "12010193", "wave": "P1", "state": "PROVISIONED_OFFLINE"} - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertEqual(len(device.channels), 4) - self.assertEqual({check.status for check in device.channels}, {"missing"}) - self.assertEqual({check.status_word for check in device.channels}, {"yok"}) - self.assertTrue(any("Kanallar bildirilmemiş" in note for note in device.notes)) - - def test_unknown_channel_keys_are_reported_not_silently_ignored(self) -> None: - record = dict(self.READY_RECORD) - record["channels"] = dict(self.READY_RECORD["channels"], telnet="verified") - snapshot = self.snapshot_from(self.document(record)) - device = snapshot.devices[0] - self.assertTrue(any("telnet" in note for note in device.notes), device.notes) - self.assertEqual(len(device.channels), 4) - - def test_loader_is_deterministic(self) -> None: - document = self.document(self.READY_RECORD) - first = self.snapshot_from(document) - second = self.snapshot_from(document) - self.assertEqual(first.devices, second.devices) - - -# -------------------------------------------------------------------------- -# ordering and filtering -# -------------------------------------------------------------------------- - - -class TestOrderingAndFiltering(PanelCase): - def sorted_demo(self): - return fleet_data.sort_devices(self.snapshot.devices) - - def test_non_ready_devices_come_first(self) -> None: - flags = [device.is_ready for device in self.sorted_demo()] - self.assertEqual(flags, sorted(flags), "hazır cihaz hazır olmayanın önünde") - self.assertIn(True, flags) - self.assertIn(False, flags) - - def test_failed_devices_are_at_the_top(self) -> None: - ordered = self.sorted_demo() - self.assertEqual(ordered[0].state_label, "HATA") - - def test_sort_keeps_every_device_exactly_once(self) -> None: - ordered = self.sorted_demo() - self.assertEqual(len(ordered), self.snapshot.total) - self.assertEqual( - {device.device_id for device in ordered}, - {device.device_id for device in self.snapshot.devices}, - ) - - def test_query_filter_matches_identity_hostname_and_dealer(self) -> None: - for needle in ("BF-12010193", "bf-12010193", "12010193"): - with self.subTest(needle=needle): - criteria, warnings = fleet_data.parse_filter({"q": [needle]}) - shown = fleet_data.filter_devices(self.snapshot.devices, criteria) - self.assertEqual(len(shown), 1, needle) - self.assertEqual(shown[0].device_id, "BF-12010193") - self.assertEqual(warnings, ()) - - def test_filter_query_is_case_insensitive(self) -> None: - criteria, _ = fleet_data.parse_filter({"q": ["bf-1201019"]}) - shown = fleet_data.filter_devices(self.snapshot.devices, criteria) - self.assertEqual( - {device.device_id for device in shown}, - {"BF-12010193", "BF-12010194", "BF-12010195"}, - ) - - def test_state_filter_accepts_word_machine_name_and_folded_input(self) -> None: - for raw in ("HAZIR", "ready", "hazir", "Hazır"): - with self.subTest(raw=raw): - criteria, warnings = fleet_data.parse_filter({"durum": [raw]}) - self.assertEqual(criteria.state, "READY") - self.assertEqual(warnings, ()) - shown = fleet_data.filter_devices(self.snapshot.devices, criteria) - self.assertTrue(shown) - self.assertEqual({device.state_label for device in shown}, {"HAZIR"}) - - def test_wave_filter_accepts_repo_group_names(self) -> None: - for raw, code in (("pilot_1", "P1"), ("W1", "W1"), ("production", "PROD")): - with self.subTest(raw=raw): - criteria, _ = fleet_data.parse_filter({"dalga": [raw]}) - self.assertEqual(criteria.wave, code) - shown = fleet_data.filter_devices(self.snapshot.devices, criteria) - self.assertTrue(shown) - self.assertEqual({device.wave_code for device in shown}, {code}) - - def test_unknown_filter_values_are_ignored_with_a_turkish_warning(self) -> None: - criteria, warnings = fleet_data.parse_filter({"durum": ["MOR"], "dalga": ["W9"]}) - self.assertEqual(criteria.state, "") - self.assertEqual(criteria.wave, "") - self.assertEqual(len(warnings), 2) - for warning in warnings: - self.assertIn("yok sayıldı", warning) - page = self.page("/?durum=MOR&dalga=W9") - self.assertIn("yok sayıldı", page) - self.assertIn("Uyarılar", page) - - def test_only_not_ready_filter(self) -> None: - criteria, _ = fleet_data.parse_filter({"hazir_degil": ["1"]}) - shown = fleet_data.filter_devices(self.snapshot.devices, criteria) - self.assertTrue(shown) - self.assertTrue(all(not device.is_ready for device in shown)) - - def test_counts_are_zero_filled_and_add_up(self) -> None: - counts = fleet_data.state_counts(self.snapshot.devices) - self.assertEqual(list(counts), list(STATE_WORDS)) - self.assertEqual(sum(counts.values()), self.snapshot.total) - self.assertEqual(counts, self.snapshot.counts) - self.assertEqual(self.snapshot.ready_count + self.snapshot.not_ready_count, self.snapshot.total) - - def test_filter_matches_the_rendered_count(self) -> None: - criteria, _ = fleet_data.parse_filter({"durum": ["YOK"]}) - shown = fleet_data.filter_devices(self.snapshot.devices, criteria) - page = self.page("/?durum=YOK") - self.assertEqual(len(self.row_words(page)), len(shown)) - self.assertTrue(all(word == "YOK" for word in self.row_words(page))) - - -# -------------------------------------------------------------------------- -# the first viewport -# -------------------------------------------------------------------------- - - -class TestFleetPage(PanelCase): - def test_served_as_utf8_turkish_html(self) -> None: - response = self.get("/") - self.assertEqual(response.status, 200) - self.assertEqual(response.content_type, "text/html; charset=utf-8") - markup = response.body.decode("utf-8") - self.assertIn('', markup) - # Turkish characters must survive the byte round trip, not just the string. - self.assertIn("doğrulandı".encode("utf-8"), response.body) - self.assertEqual(response.content_length, len(response.body)) - - def test_first_viewport_regions_are_ordered(self) -> None: - page = self.page("/") - positions = [ - page.index('data-region="header"'), - page.index('data-region="summary"'), - page.index('data-region="list"'), - page.index('data-region="detail"'), - ] - self.assertEqual(positions, sorted(positions), "bölge sırası DESIGN.md'ye uymuyor") - - def test_header_carries_product_target_and_privilege(self) -> None: - page = self.page("/") - header = page[: page.index('data-region="summary"')] - self.assertIn(web.PANEL_NAME, header) - self.assertIn(web.PANEL_SURFACE, header) - self.assertIn("Hedef", header) - self.assertIn(f"Tüm filo · {self.snapshot.total} cihaz", header) - self.assertIn("Yetki", header) - self.assertIn("Salt-okunur", header) - self.assertIn(self.read_only.reason, header) - - def test_privilege_level_is_the_right_most_header_readout(self) -> None: - """docs/DESIGN.md: 'privilege level on the right -- never a buried sentence'.""" - page = self.page("/") - header = page[: page.index('data-region="summary"')] - privilege = header.index("app-header__privilege") - self.assertGreater(privilege, header.index("app-header__identity")) - self.assertGreater(privilege, header.index("app-header__target")) - self.assertGreater(privilege, header.index("app-header__actions")) - - def test_primary_action_sits_in_the_header_not_below_the_fold(self) -> None: - page = self.page("/") - self.assertLess( - page.index("Filo durumunu yenile"), - page.index('data-region="summary"'), - "birincil eylem başlık bandında değil", - ) - self.assertIn('class="button button--primary"', page) - - def test_unprivileged_write_action_is_disabled_with_the_reason_on_the_control(self) -> None: - page = self.page("/") - self.assertIn('disabled aria-disabled="true" aria-describedby="gated-reason"', page) - self.assertIn('id="gated-reason"', page) - self.assertIn(self.read_only.reason, page) - - def test_every_state_word_appears_in_the_summary_strip(self) -> None: - page = self.page("/") - summary = page[page.index('data-region="summary"') : page.index('data-region="list"')] - for word in STATE_WORDS: - with self.subTest(word=word): - self.assertIn(word, summary) - - def test_summary_strip_has_exactly_one_large_figure(self) -> None: - page = self.page("/") - self.assertEqual(page.count("summary__lead-figure"), 1) - self.assertEqual( - page.count("summary__cell-value"), len(fleet_data.STATE_VOCABULARY) - ) - counts = self.snapshot.counts - summary = page[page.index('data-region="summary"') : page.index('data-region="list"')] - self.assertIn(f'>{counts["HAZIR"]}<', summary) - - def test_every_row_carries_a_state_word_in_monospace_columns(self) -> None: - page = self.page("/") - self.assertEqual(len(self.row_words(page)), self.snapshot.total) - for device in self.snapshot.devices: - with self.subTest(device=device.device_id): - self.assertIn( - f'' - f"{device.device_id}", - page, - ) - self.assertIn(f'class="badge badge--state-{device.state_token_suffix}"', page) - self.assertIn(f'{device.hostname}', page) - - def test_wave_column_names_the_wave_once_in_monospace(self) -> None: - """The Dalga column is a data cell, and it names each wave exactly once.""" - region = self.list_region(self.page("/")) - rows = re.findall(r'(BF-[0-9]{8})", row) - if match is None: - self.fail(f"satırda cihaz kimliği yok: {row[:120]}") - device = by_id[match.group(1)] - cells = re.findall(r"]*>(.*?)", row, flags=re.S) - self.assertEqual(len(cells), 5, row[:200]) - self.assertEqual( - cells[3], f'{device.wave.label}', row[:200] - ) - - def test_rows_are_sorted_non_ready_first(self) -> None: - words = self.row_words(self.page("/")) - ready_flags = [ - fleet_data.STATE_BY_WORD[word].state == "READY" for word in words - ] - self.assertEqual(ready_flags, sorted(ready_flags)) - self.assertEqual(ready_flags[0], False) - self.assertEqual(ready_flags[-1], True) - - def test_selected_row_uses_the_accent_soft_fill(self) -> None: - page = self.page("/") - self.assertEqual(page.count("fleet-row--selected"), 1) - selected_row = page[page.index("fleet-row--selected") :] - selected_row = selected_row[: selected_row.index("")] - detail = page[page.index('data-region="detail"') :] - device_id = re.search(r">(BF-[0-9]{8})", selected_row).group(1) - self.assertIn(device_id, detail) - css = (STATIC_DIR / "app.css").read_text(encoding="utf-8") - self.assertIn(".fleet-row--selected td { background: var(--accent-soft); }", css) - - def test_default_selection_is_the_first_row_of_the_list(self) -> None: - page = self.page("/") - first_row = self.list_region(page) - first_row = first_row[first_row.index("") :] - device_id = re.search(r">(BF-[0-9]{8})", first_row).group(1) - detail = page[page.index('data-region="detail"') :] - self.assertIn(device_id, detail) - self.assertIn("Hedef kimliği", detail) - - def test_detail_region_shows_state_identity_wave_and_channels(self) -> None: - page = self.page("/?cihaz=BF-12010193") - detail = page[page.index('data-region="detail"') :] - device = next(d for d in self.snapshot.devices if d.device_id == "BF-12010193") - self.assertIn(device.state_label, detail) - self.assertIn(device.device_id, detail) - self.assertIn(device.hostname, detail) - self.assertIn(device.wave_code, detail) - self.assertIn("Kanal doğrulaması", detail) - for channel in fleet_data.CHANNELS: - with self.subTest(channel=channel.label): - self.assertIn(channel.label, detail) - self.assertIn("Doğrulanan kanal: 4/4", detail) - self.assertIn("doğrulandı", detail) - - def test_channel_status_words_are_shown_in_turkish(self) -> None: - """Every channel status word reaches the screen, not only a colour.""" - cases = { - "BF-12010193": ("doğrulandı",), # all four verified - "BF-12010194": ("bekliyor",), # RustDesk pending - "BF-12010205": ("başarısız", "bilinmiyor"), # failed + unknown - "BF-12010195": ("yok",), # never checked - } - for device_id, words in cases.items(): - with self.subTest(device=device_id): - page = self.page(f"/?cihaz={device_id}") - detail = page[page.index('data-region="detail"') :] - for word in words: - self.assertIn(f">{word}", detail) - - def test_ready_device_detail_confirms_the_gate(self) -> None: - page = self.page("/?cihaz=BF-12010193") - self.assertIn("dört kanalın dördü de doğrulandı", page) - self.assertNotIn("HAZIR için eksik kanallar", page) - - def test_not_ready_device_detail_names_the_blocking_channels(self) -> None: - page = self.page("/?cihaz=BF-12010194") - self.assertIn("HAZIR için eksik kanallar: RustDesk", page) - self.assertIn("Doğrulanan kanal: 3/4", page) - - def test_hostname_selects_the_same_device_as_device_id(self) -> None: - by_id = self.page("/?cihaz=BF-12010193") - by_host = self.page("/?cihaz=bf-12010193") - self.assertEqual(self.row_words(by_id), self.row_words(by_host)) - self.assertIn("BF-12010193", by_host[by_host.index('data-region="detail"') :]) - - def test_unknown_device_id_is_reported_and_not_invented(self) -> None: - page = self.page("/?cihaz=BF-99999999") - detail = page[page.index('data-region="detail"') :] - self.assertIn("BF-99999999", detail) - self.assertIn("envanterde yok", detail.lower()) - self.assertIn("uydurmaz", detail) - - def test_filter_is_reflected_in_the_list_and_the_header(self) -> None: - page = self.page("/?durum=HAZIR") - self.assertEqual(set(self.row_words(page)), {"HAZIR"}) - self.assertIn("Durum HAZIR", page) - self.assertIn(f"Gösterilen: {self.snapshot.ready_count}", page) - - def test_selection_outside_the_filter_is_flagged_not_hidden(self) -> None: - page = self.page("/?durum=HAZIR&cihaz=BF-12010205") - detail = page[page.index('data-region="detail"') :] - self.assertIn("BF-12010205", detail) - self.assertIn("geçerli filtreye uymuyor", detail) - - def test_empty_result_gets_a_turkish_note_and_keeps_the_header(self) -> None: - page = self.page("/?durum=YOK&dalga=PROD") - self.assertEqual(self.row_words(page), []) - self.assertIn("Bu filtreye uyan cihaz yok", page) - self.assertIn("Filtreyi temizle", page) - self.assertIn('data-region="header"', page) - - def test_wave_strip_shows_every_wave_against_its_target(self) -> None: - page = self.page("/") - counts = fleet_data.wave_counts(self.snapshot.devices) - for wave in fleet_data.WAVES: - with self.subTest(wave=wave.code): - self.assertIn( - f'{wave.code} {counts[wave.code]}/{wave.target}', - page, - ) - - def test_demo_data_is_labelled_on_the_page(self) -> None: - page = self.page("/") - self.assertIn("DEMO VERİ", page) - self.assertIn("sentetik", page) - self.assertIn("hiçbir cihazı temsil etmez", page) - self.assertIn("Veri kaynağı: sentetik demo veri", page) - - def test_inventory_file_source_is_labelled_on_the_page(self) -> None: - document = { - "schema_version": 1, - "generated_at": NOW, - "devices": [ - { - "dealer_id": "12010193", - "wave": "P1", - "state": "ENROLLED", - "channels": {"ssh_over_wg": "verified"}, - } - ], - } - snapshot = self.snapshot_from(document) - context = panel.PanelContext( - snapshot=snapshot, privilege=self.read_only, server_info=self.info - ) - page = self.page("/", context=context) - self.assertIn("ENVANTER", page) - self.assertIn(snapshot.source_path, page) - self.assertNotIn("DEMO VERİ", page) - - def test_error_source_says_veri_yok_and_never_shows_demo_data(self) -> None: - snapshot = fleet_data.load_fleet("/yok/boyle/bir/dosya.json", env={}, now=NOW) - context = panel.PanelContext( - snapshot=snapshot, privilege=self.read_only, server_info=self.info - ) - page = self.page("/", context=context) - self.assertIn("VERİ YOK", page) - self.assertIn("Demo veriye düşülmedi", page) - self.assertNotIn("DEMO VERİ", page) - self.assertEqual(self.row_words(page), []) - self.assertIn("Envanterde gösterilecek cihaz yok", page) - - def test_invalid_records_are_surfaced_with_their_reason(self) -> None: - document = { - "schema_version": 1, - "devices": [{"dealer_id": "1201019", "wave": "P1", "state": "READY"}], - } - snapshot = self.snapshot_from(document) - context = panel.PanelContext( - snapshot=snapshot, privilege=self.read_only, server_info=self.info - ) - page = self.page("/", context=context) - self.assertIn("Geçersiz kayıtlar", page) - self.assertIn("8 hane", page) - - def test_every_visible_string_is_non_empty(self) -> None: - for target in ("/", "/?durum=HAZIR", "/?cihaz=BF-99999999", "/hakkinda"): - with self.subTest(target=target): - self.assertEqual(empty_elements(self.page(target)), []) - - def test_error_pages_keep_the_frame_and_stay_readable(self) -> None: - for target, status in (("/yok", 404),): - with self.subTest(target=target): - response = self.get(target) - self.assertEqual(response.status, status) - markup = response.body.decode("utf-8") - self.assertEqual(empty_elements(markup), []) - self.assertIn('data-region="header"', markup) - - def test_no_placeholder_or_python_none_leaks_into_the_page(self) -> None: - for target in ("/", "/hakkinda", "/?cihaz=BF-12010193", "/?durum=MOR"): - with self.subTest(target=target): - page = self.page(target) - self.assertNotIn("$", page) - self.assertNotIn("None", page) - self.assertNotIn("KeyError", page) - - def test_page_renders_without_a_live_server_context(self) -> None: - """render_fleet_page is usable on its own (tests, --check, future CLI).""" - page = render.render_fleet_page(self.snapshot) - self.assertIn(' None: - page = self.page("/hakkinda") - self.assertIn(web.PANEL_VERSION, page) - self.assertIn(render.PANEL_VERSION, page) - self.assertIn("Panel sürümü", page) - self.assertIn("127.0.0.1:8787", page) - self.assertIn("Salt-okunur", page) - self.assertIn(self.read_only.reason, page) - self.assertIn(self.info.python_version, page) - - def test_reports_the_data_source_and_its_limits(self) -> None: - page = self.page("/hakkinda") - self.assertIn("Veri kaynağı", page) - self.assertIn("sentetik demo veri", page) - self.assertIn("kullanılmadı (demo veri)", page) - self.assertIn(f"{self.snapshot.ready_count} hazır", page) - self.assertIn("Okuma hatası", page) - self.assertIn("yok", page) - - def test_lists_the_vocabulary_with_tokens_and_meanings(self) -> None: - page = self.page("/hakkinda") - for spec in fleet_data.STATE_VOCABULARY: - with self.subTest(state=spec.state): - self.assertIn(spec.word, page) - self.assertIn(spec.token, page) - self.assertIn(spec.meaning, page) - - def test_lists_every_wave_and_channel(self) -> None: - page = self.page("/hakkinda") - for wave in fleet_data.WAVES: - with self.subTest(wave=wave.code): - self.assertIn(wave.code, page) - for channel in fleet_data.CHANNELS: - with self.subTest(channel=channel.key): - self.assertIn(channel.key, page) - self.assertIn(channel.label, page) - - def test_states_the_panel_rules(self) -> None: - page = self.page("/hakkinda") - self.assertGreaterEqual(len(render.PANEL_RULES), 5) - for rule in render.PANEL_RULES: - with self.subTest(rule=rule[:30]): - self.assertTrue(rule.strip()) - self.assertIn(rule[:24], page) - self.assertIn("Panel kuralları", page) - - def test_full_privilege_level_is_distinct_and_honest(self) -> None: - page = self.page("/", context=self.full_context) - self.assertIn("badge--privilege-full", page) - self.assertIn("Tam yetki", page) - self.assertIn("yazma uç noktası uygulanmadı", page) - self.assertNotIn("badge--privilege-read-only", page) - - def test_about_page_has_no_external_url(self) -> None: - page = self.page("/hakkinda") - self.assertNotIn("http://", page) - self.assertNotIn("https://", page) - - -# -------------------------------------------------------------------------- -# routing and the privilege gate -# -------------------------------------------------------------------------- - - -class TestRoutingAndGate(PanelCase): - def test_only_read_methods_are_served(self) -> None: - for method in ("POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"): - with self.subTest(method=method): - response = self.get("/", method) - self.assertEqual(response.status, 405) - markup = response.body.decode("utf-8") - self.assertIn("Yöntem reddedildi", markup) - self.assertIn(method, markup) - self.assertIn("yalnız okuma yapar", markup) - headers = dict(response.headers) - self.assertEqual(headers.get("Allow"), "GET, HEAD") - - def test_write_methods_are_refused_even_with_full_privilege(self) -> None: - response = self.get("/", "POST", self.full_context) - self.assertEqual(response.status, 405) - - def test_head_returns_the_same_length_without_a_body(self) -> None: - head = self.get("/", "HEAD") - get = self.get("/", "GET") - self.assertEqual(head.status, 200) - self.assertEqual(head.body, b"") - self.assertEqual(head.content_length, get.content_length) - self.assertEqual(head.content_type, get.content_type) - - def test_static_assets_are_served_from_disk_byte_for_byte(self) -> None: - for name in ("tokens.css", "app.css"): - with self.subTest(name=name): - response = self.get(f"/static/{name}") - self.assertEqual(response.status, 200) - self.assertEqual(response.content_type, "text/css; charset=utf-8") - self.assertEqual(response.body, (STATIC_DIR / name).read_bytes()) - - def test_unknown_static_asset_is_a_404(self) -> None: - response = self.get("/static/gizli.txt") - self.assertEqual(response.status, 404) - self.assertIn("Sayfa yok", response.body.decode("utf-8")) - - def test_path_traversal_is_refused(self) -> None: - for target in ( - "/static/../server.py", - "/static/..%2fserver.py", - "/static/%2e%2e/fleet_data.py", - "/../web/server.py", - "/static/.%2e/%2e%2e/etc/passwd", - ): - with self.subTest(target=target): - response = self.get(target) - self.assertEqual(response.status, 404) - body = response.body.decode("utf-8") - self.assertNotIn("BaseHTTPRequestHandler", body) - self.assertNotIn("route(context", body) - - def test_unknown_path_is_a_turkish_404(self) -> None: - response = self.get("/boyle-bir-sayfa-yok") - self.assertEqual(response.status, 404) - markup = response.body.decode("utf-8") - self.assertIn("Sayfa yok", markup) - self.assertIn("/boyle-bir-sayfa-yok", markup) - - def test_security_headers_keep_the_panel_local_and_script_free(self) -> None: - headers = dict(self.get("/").headers) - self.assertEqual(headers["X-Content-Type-Options"], "nosniff") - self.assertEqual(headers["Referrer-Policy"], "no-referrer") - csp = headers["Content-Security-Policy"] - self.assertIn("default-src 'self'", csp) - self.assertIn("script-src 'none'", csp) - self.assertIn("form-action 'self'", csp) - self.assertNotIn("http", csp) - self.assertEqual(headers["Cache-Control"], "no-store") - - def test_route_is_pure_and_repeatable(self) -> None: - targets = ("/", "/hakkinda", "/static/app.css", "/?durum=HAZIR&q=1201", "/yok", "/static/../x") - for target in targets: - with self.subTest(target=target): - first = self.get(target) - second = self.get(target) - self.assertEqual(first, second) - - def test_bogus_query_values_do_not_crash_the_page(self) -> None: - for target in ( - "/?q=&durum=&dalga=&cihaz=&hazir_degil=abc", - "/?durum=READY&durum=YOK", - "/?q=%C4%B0stanbul%20%2F%20test", - "/?cihaz=" + "x" * 500, - ): - with self.subTest(target=target[:40]): - response = self.get(target) - self.assertEqual(response.status, 200) - self.assertEqual(empty_elements(response.body.decode("utf-8")), []) - - def test_a_full_sweep_of_every_endpoint_writes_nothing(self) -> None: - """Read-only is a property of the surface, not a promise in a docstring.""" - before = tree_fingerprint(WEB_DIR) - requests = ( - ("GET", "/"), - ("GET", "/filo"), - ("GET", "/fleet"), - ("GET", "/hakkinda"), - ("GET", "/hakkinda/"), - ("GET", "/about"), - ("GET", "/static/tokens.css"), - ("GET", "/static/app.css"), - ("GET", "/?q=1201&durum=HAZIR&dalga=P1&hazir_degil=1&cihaz=BF-12010193"), - ("GET", "/yok"), - ("POST", "/"), - ("PUT", "/hakkinda"), - ("DELETE", "/static/app.css"), - ("HEAD", "/"), - ) - for method, target in requests: - with self.subTest(method=method, target=target): - response = self.get(target, method) - self.assertIn(response.status, (200, 404, 405)) - self.assertEqual(tree_fingerprint(WEB_DIR), before) - - -if __name__ == "__main__": - unittest.main(verbosity=2) - - - -{ - "_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": {} - } - ] -} - - - -/* - * 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); -} - - - -$label$key$hint - - - -$word$token$meaning - - - -$code $label$target$in_panel - - - -
-

Hakkında ve durum

-

Bu sayfa panelin kendi sürümünü ve hangi veriyi gösterdiğini yazar; operatör neye baktığını tahmin etmez.

- -
-

Panel

-
-
Ürün
$panel_name
-
Yüzey
$panel_surface
-
Panel sürümü
$panel_version
-
Python
$python_version
-
Dinlenen adres
$bind_address
-
Yetki düzeyi
$privilege_word $privilege_reason
-
Sunucu başlangıcı
$started_at
-
-
- -
-

Veri kaynağı

-

- $data_banner_word - $data_banner_text -

-
-
Kaynak türü
$source_kind_word
-
Dosya
$source_path_text
-
Şema sürümü
$schema_version_text
-
Dosya üretimi
$generated_at_text
-
Yükleme zamanı
$loaded_at
-
Cihaz sayısı
$total_text
-
Hazır / hazır değil
$ready_text
-
Geçersiz kayıt
$invalid_count_text
-
Okuma hatası
$error_text
-
-
- -
-

Durum sözlüğü

-

İ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.

- - - - - - -$vocabulary_rows - -
Durum sözlüğü: Türkçe kelime, renk belirteci ve anlamı
DurumRenk belirteciAnlam
-
- -
-

Dalgalar

-

Yayılım sırası ve dalga hedefi (docs/18). Panelde $total_text cihaz var.

- - - - - - -$wave_rows - -
Yayılım dalgaları, hedef cihaz sayısı ve panelde görünen cihaz sayısı
DalgaHedef cihazPanelde
-
- -
-

Kanal doğrulaması

-

HAZIR yalnız dört kanalın hepsi doğrulandığında verilir; panel bunu denetler (docs/27).

- - - - - - -$channel_rows - -
Dört uzak erişim kanalı ve envanter alan adları
KanalAlan adıNe doğrulanır
-
- -
-

Panel kuralları

-
    -$rule_items -
-
-
-
- - - - - - - -$page_title - - - - - -
-
-

$panel_name

- $panel_surface -
- -

- Hedef - $target_identity -

-
- $primary_action - $gated_action -
-

- Yetki - $privilege_word - $privilege_reason -

-
-
-$content -
-
- $footer_source - $footer_meta -
- - -
- - -
  • - $label - $status_word - $checked_at_text - $hint -
  • -
    - - -

    Seçili cihaz

    -

    $empty_text

    -
    - - -

    Seçili cihaz

    -

    - HATA - Bu kimlik envanterde yok. -

    -

    İstenen cihaz kimliği: $device_id. Envanterde bu kimliğe sahip bir kayıt yok; panel eksik kaydı uydurmaz.

    -

    Tüm filoyu göster

    -
    - - -

    Seçili cihaz

    -

    - $state_label - $state_meaning -

    -$selection_note_block -$reason_block -
    -
    Hedef kimliği
    $device_id
    -
    Hostname
    $hostname
    -
    Bayi no
    $dealer_id
    -
    Dalga
    $wave_label hedef $wave_target cihaz
    -
    Son görülme
    $last_seen_text
    -
    Kayıt kaynağı
    $record_source
    -
    -$note_block -$notes_block -

    Kanal doğrulaması

    -

    $ready_gate_note

    -
      -$channel_rows -
    -

    $channel_count_note

    -
    - - - - $state_label - $device_id - $hostname - $wave_name - $last_seen_text - - - - -

    $text Filtreyi temizle

    -
    - - -
    -
    -

    - - -

    -

    - - -

    -

    - - -

    -

    - - -

    -

    - - Filtreyi temizle -

    -
    -

    Gösterilen: $shown_count / $total_count · $filter_description

    -
    -
    - - -
    - $data_banner_word - $data_banner_text -
    - -
    -
    - $lead_figure - $lead_label - $lead_note -
    -
      -$summary_cells -
    -
    - -$warnings_block - -
    -
    -

    Filo cihazları

    -

    Hazır olmayan cihazlar listede önce gelir. $list_count_text

    -$filter_bar -$wave_strip -$invalid_block - - - - - - - - - - - - -$device_rows - -
    Filo cihazları: durum, cihaz kimliği, hostname, dalga ve son görülme
    DurumCihaz kimliğiHostnameDalgaSon görülme
    -$empty_note -
    - -
    -
    - - - -Kapalı: $reason - - - -
    -

    Geçersiz kayıtlar: $invalid_count

    -

    Kimliği kurulamayan kayıt sessizce düşürülmez; aşağıda nedeniyle birlikte durur (docs/02).

    -
      -$invalid_items -
    -
    -
    - - -
  • $record_ref — $reason
  • -
    - - -
    -

    Sayfa yok

    -

    İstenen adres bu panelde tanımlı değil: $request_path

    -

    Panel sayfaları: Filo · Hakkında ve durum

    -
    -
    - - -

    Not: $text

    -
    - - -
  • $text
  • -
    - - -
      -$items -
    -
    - - - - - - -Filo durumunu yenile - - - -

    $text

    -
    - - -
    -

    Yöntem reddedildi

    -

    Gelen yöntem: $method

    -

    Bu panel yalnız okuma yapar: hiçbir uç nokta cihazda değişiklik yapmaz, komut çalıştırmaz ve dosya yazmaz.

    -

    Kabul edilen yöntemler: GET, HEAD. Yetki düzeyi: $privilege_word.

    -
    -
    - - -
  • $text
  • -
    - - -

    $text

    -
    - - -
  • - $count - $word - $note -
  • -
    - - -
  • $text
  • -
    - - -
    -

    Uyarılar

    -
      -$warning_items -
    -
    -
    - - -$code $count/$target - - - -

    Dalga doluluğu: $wave_items

    -
    - - -"""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 - - - -"""Fleet inventory model for the central Blueforce panel (Python stdlib only). - -Read-only by construction: this module reads one inventory JSON document (or -falls back to clearly labelled synthetic demo data), turns it into the device -records the panel renders, and does nothing else. It never writes to disk, never -opens a network connection and never invents a state. - -Product rules this module enforces, because they are the fleet's rules -(docs/02 §9, docs/18 §9, docs/27 §9): - -* one identity per dealer number, derived, never guessed: ``BF-`` for the - human surface and ``bf-`` for the hostname, ``^[0-9]{8}$``; -* the state machine is ``PROVISIONED_OFFLINE -> ENROLLED -> READY`` with - ``FAILED`` and ``ABSENT`` outside the sequence, and the panel never promotes a - device -- a stage claim the centre has not made stays unmade here; -* ``READY`` is refused while any of the four channels is unverified: such a - record is surfaced as ``HATA`` with the reason, not as a claim the fleet - cannot back (docs/27); -* a channel check that failed means the device needs a person, so the device is - ``HATA``; -* a record that cannot be identified is never dropped silently -- it leaves an - ``InvalidRecord`` that the panel shows. - -Vocabulary, Turkish words and colour tokens are taken from docs/DESIGN.md; -tests/test_web_static.py asserts this module still matches that document, so the -two operator surfaces cannot drift apart. -""" - -from __future__ import annotations - -import json -import os -import re -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any, Iterable, Mapping, Sequence - -# -------------------------------------------------------------------------- -# Identity (docs/02) -# -------------------------------------------------------------------------- - -DEALER_PATTERN = re.compile(r"^[0-9]{8}$") -DEVICE_ID_PREFIX = "BF-" -HOSTNAME_PREFIX = "bf-" - -# -------------------------------------------------------------------------- -# Status vocabulary (docs/DESIGN.md "Status vocabulary") -- one word per state, -# Turkish, used identically by the device console and this panel. -# -------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class StateSpec: - """One state of the fleet vocabulary, as docs/DESIGN.md defines it.""" - - state: str - """Machine name used by the state machine: PROVISIONED_OFFLINE, ENROLLED...""" - - word: str - """The Turkish word an operator reads. Both surfaces show this word.""" - - token: str - """Colour token in web/static/tokens.css. Never a literal colour here.""" - - soft_token: str - """Soft (fill) form of the same colour, for badge backgrounds.""" - - meaning: str - """Turkish one-liner: what the state means to the operator.""" - - @property - def token_suffix(self) -> str: - """``--state-provisioned`` -> ``provisioned`` (used in CSS class names).""" - return self.token[len("--state-"):] - - -STATE_VOCABULARY: tuple[StateSpec, ...] = ( - StateSpec( - "PROVISIONED_OFFLINE", - "KURULDU", - "--state-provisioned", - "--info-soft", - "Çevrimdışı kuruldu; merkeze kayıtlı değil.", - ), - StateSpec( - "ENROLLED", - "KAYITLI", - "--state-enrolled", - "--warn-soft", - "Kayıtlı; dört kanalın hepsi doğrulanmadı.", - ), - StateSpec( - "READY", - "HAZIR", - "--state-ready", - "--ok-soft", - "Dört kanal da doğrulandı; tek sevk edilebilir durum.", - ), - StateSpec( - "FAILED", - "HATA", - "--state-failed", - "--danger-soft", - "Bir denetim başarısız; cihaz bir insan bekliyor.", - ), - StateSpec( - "ABSENT", - "YOK", - "--state-absent", - "--idle-soft", - "Erişilemiyor ya da hiç görülmedi.", - ), -) - -STATE_BY_NAME: dict[str, StateSpec] = {spec.state: spec for spec in STATE_VOCABULARY} - -#: Lookup by the Turkish word, for the surfaces that read a word back. -STATE_BY_WORD: dict[str, StateSpec] = {spec.word: spec for spec in STATE_VOCABULARY} - -# Sorted so that non-ready devices surface first (docs/DESIGN.md: "the fleet -# table sorts non-ready first, so the list is readable in greyscale"). FAILED -# and ABSENT need a person; READY -- the only shippable state -- is last. -STATE_ORDER: tuple[str, ...] = ( - "FAILED", - "ABSENT", - "PROVISIONED_OFFLINE", - "ENROLLED", - "READY", -) - -# -------------------------------------------------------------------------- -# Rollout waves (docs/18 §9) -# -------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class Wave: - """One rollout wave: ``LAB(2) -> P1(5) -> P2(20) -> W1(50) -> W2(100) -> PROD``.""" - - code: str - label: str - target: int - order: int - aliases: tuple[str, ...] = () - - @property - def full_label(self) -> str: - return f"{self.code} — {self.label}" if self.label != self.code else self.code - - -WAVES: tuple[Wave, ...] = ( - Wave("LAB", "LAB", 2, 0, ("lab",)), - Wave("P1", "PILOT-1", 5, 1, ("pilot_1", "pilot-1", "pilot1")), - Wave("P2", "PILOT-2", 20, 2, ("pilot_2", "pilot-2", "pilot2")), - Wave("W1", "WAVE-1", 50, 3, ("wave_1", "wave-1", "wave1")), - Wave("W2", "WAVE-2", 100, 4, ("wave_2", "wave-2", "wave2")), - Wave("PROD", "PROD", 523, 5, ("production", "prod")), -) - -WAVE_BY_CODE: dict[str, Wave] = {wave.code: wave for wave in WAVES} - -# -------------------------------------------------------------------------- -# Channels (docs/27: READY requires every channel verified) -# -------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ChannelSpec: - """One of the four remote-access channels every device must verify.""" - - key: str - label: str - hint: str - - -CHANNELS: tuple[ChannelSpec, ...] = ( - ChannelSpec("ssh_over_wg", "SSH-over-WG", "WireGuard üzerinden SSH (K-13)"), - ChannelSpec("xrdp", "xRDP", "GNOME masaüstü oturumu (K-22)"), - ChannelSpec("rustdesk", "RustDesk", "RustDesk OSS istemcisi"), - ChannelSpec("meshcentral", "MeshCentral", "MeshCentral ajanı"), -) - -CHANNEL_STATUSES: tuple[str, ...] = ("verified", "pending", "failed", "unknown", "missing") - -# Turkish word per channel status. The panel never shows a bare colour: the word -# is what carries the meaning (docs/DESIGN.md rule 2). -CHANNEL_STATUS_WORDS: dict[str, str] = { - "verified": "doğrulandı", - "pending": "bekliyor", - "failed": "başarısız", - "unknown": "bilinmiyor", - "missing": "yok", -} - -# Severity class for the local stylesheet. Tokens only, no colours in Python. -CHANNEL_STATUS_TONES: dict[str, str] = { - "verified": "ok", - "pending": "warn", - "failed": "danger", - "unknown": "idle", - "missing": "idle", -} - -# -------------------------------------------------------------------------- -# Document contract -# -------------------------------------------------------------------------- - -SUPPORTED_SCHEMA_VERSION = 1 -DATA_PATH_ENV = "BF_FLEET_DATA" -DEFAULT_DATA_PATH = Path(__file__).resolve().parent / "data" / "fleet.json" - -DEMO_SOURCE_LABEL = "sentetik demo veri (gerçek filo envanteri bağlı değil)" - -# Synthetic records for a first look at the panel without a live fleet. They go -# through the same parse path as a real document, and the UI labels every page -# that renders them -- demo data must never be mistaken for fleet state. -DEMO_DEVICES: tuple[Mapping[str, Any], ...] = ( - { - "dealer_id": "12010101", - "wave": "LAB", - "state": "READY", - "channels": { - "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-15T09:10:00+03:00"}, - "xrdp": {"status": "verified", "checked_at": "2026-09-15T09:12:00+03:00"}, - "rustdesk": {"status": "verified", "checked_at": "2026-09-15T09:15:00+03:00"}, - "meshcentral": {"status": "verified", "checked_at": "2026-09-15T09:18:00+03:00"}, - }, - "last_seen": "2026-09-16T08:40:00+03:00", - }, - { - "dealer_id": "12010102", - "wave": "LAB", - "state": "ENROLLED", - "channels": { - "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T08:02:00+03:00"}, - "xrdp": {"status": "pending"}, - "rustdesk": {"status": "verified", "checked_at": "2026-09-16T08:05:00+03:00"}, - "meshcentral": {"status": "pending"}, - }, - "last_seen": "2026-09-16T08:41:00+03:00", - }, - { - "dealer_id": "12010193", - "wave": "pilot_1", - "state": "READY", - "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"}, - }, - "last_seen": "2026-09-16T09:05:00+03:00", - }, - { - "dealer_id": "12010194", - "wave": "P1", - "state": "ENROLLED", - "channels": { - "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T07:30:00+03:00"}, - "xrdp": {"status": "verified", "checked_at": "2026-09-16T07:32:00+03:00"}, - "rustdesk": {"status": "pending"}, - "meshcentral": {"status": "verified", "checked_at": "2026-09-16T07:35:00+03:00"}, - }, - "last_seen": "2026-09-16T09:04:00+03:00", - }, - { - "dealer_id": "12010195", - "wave": "P1", - "state": "PROVISIONED_OFFLINE", - "channels": {}, - "last_seen": "2026-09-13T16:20:00+03:00", - }, - { - "dealer_id": "12010204", - "wave": "P2", - "state": "PROVISIONED_OFFLINE", - "channels": {}, - "last_seen": "2026-09-12T11:00:00+03:00", - }, - { - "dealer_id": "12010205", - "wave": "P2", - "state": "ENROLLED", - "channels": { - "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T06:10:00+03:00"}, - "xrdp": {"status": "failed", "checked_at": "2026-09-16T06:12:00+03:00"}, - "rustdesk": {"status": "verified", "checked_at": "2026-09-16T06:14:00+03:00"}, - "meshcentral": {"status": "unknown"}, - }, - "last_seen": "2026-09-16T06:20:00+03:00", - }, - { - "dealer_id": "12010206", - "wave": "P2", - "state": "PROVISIONED_OFFLINE", - "channels": {}, - "last_seen": "2026-09-11T14:45:00+03:00", - }, - { - "dealer_id": "12010207", - "wave": "W1", - "state": "PROVISIONED_OFFLINE", - "channels": {}, - "last_seen": "2026-09-10T10:05:00+03:00", - }, - { - "dealer_id": "12010208", - "wave": "W1", - "state": "ABSENT", - "channels": {}, - "last_seen": None, - "note": "Envanterde var, merkez hiç görmedi.", - }, - { - "dealer_id": "12010209", - "wave": "W2", - "state": "ABSENT", - "channels": {}, - "last_seen": None, - }, - { - "dealer_id": "12010210", - "wave": "PROD", - "state": "PROVISIONED_OFFLINE", - "channels": {}, - "last_seen": "2026-09-09T09:00:00+03:00", - }, - { - "dealer_id": "12010211", - "wave": "W2", - "state": "READY", - "channels": { - "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-15T13:00:00+03:00"}, - "xrdp": {"status": "verified", "checked_at": "2026-09-15T13:02:00+03:00"}, - "rustdesk": {"status": "verified", "checked_at": "2026-09-15T13:04:00+03:00"}, - "meshcentral": {"status": "verified", "checked_at": "2026-09-15T13:06:00+03:00"}, - }, - "last_seen": "2026-09-16T09:01:00+03:00", - }, -) - - -# -------------------------------------------------------------------------- -# Records -# -------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ChannelCheck: - """Verification state of one channel on one device.""" - - key: str - label: str - hint: str - status: str - checked_at: str | None = None - - @property - def status_word(self) -> str: - return CHANNEL_STATUS_WORDS[self.status] - - @property - def tone(self) -> str: - return CHANNEL_STATUS_TONES[self.status] - - @property - def is_verified(self) -> bool: - return self.status == "verified" - - @property - def checked_at_text(self) -> str: - """Never a bare ``None`` on screen: an operator reads Turkish words.""" - return self.checked_at or "kayıt yok" - - @property - def status_word_title(self) -> str: - return f"{self.label}: {self.status_word}" - - -@dataclass(frozen=True) -class InvalidRecord: - """A record that could not be identified. Shown, never silently dropped.""" - - record_ref: str - reason: str - - -@dataclass(frozen=True) -class Device: - """One fleet device, as the panel describes it.""" - - device_id: str - hostname: str - dealer_id: str - wave_code: str - state: str - channels: tuple[ChannelCheck, ...] = () - last_seen: str | None = None - note: str | None = None - claimed_state: str | None = None - reason: str | None = None - notes: tuple[str, ...] = field(default=()) - - @property - def spec(self) -> StateSpec: - return STATE_BY_NAME[self.state] - - @property - def state_label(self) -> str: - return self.spec.word - - @property - def state_meaning(self) -> str: - return self.spec.meaning - - @property - def state_token_suffix(self) -> str: - return self.spec.token_suffix - - @property - def wave(self) -> Wave: - return WAVE_BY_CODE[self.wave_code] - - @property - def wave_label(self) -> str: - return self.wave.full_label - - @property - def is_ready(self) -> bool: - return self.state == "READY" - - @property - def unverified_channels(self) -> tuple[str, ...]: - return tuple(check.label for check in self.channels if not check.is_verified) - - @property - def verified_channel_count(self) -> int: - return sum(1 for check in self.channels if check.is_verified) - - @property - def last_seen_text(self) -> str: - return self.last_seen or "kayıt yok" - - @property - def ready_gate_note(self) -> str: - """The Turkish sentence that explains the READY gate for this device.""" - if self.is_ready: - return "Bu cihaz HAZIR: dört kanalın dördü de doğrulandı (docs/27)." - unverified = self.unverified_channels - if not unverified: - return ( - "Dört kanal da doğrulandı, ancak merkez kaydı HAZIR demiyor. " - "Panel durumu yükseltmez (docs/27)." - ) - return ( - "HAZIR için eksik kanallar: " + ", ".join(unverified) + - ". READY yalnız dördü de doğrulandığında verilir (docs/27)." - ) - - -@dataclass(frozen=True) -class FleetSnapshot: - """Everything the panel knows: devices, refused records and the source.""" - - devices: tuple[Device, ...] - invalid: tuple[InvalidRecord, ...] - source_kind: str # "file" | "demo" | "error" - source_path: str | None - source_label: str - error: str | None - loaded_at: str - generated_at: str | None = None - schema_version: int | None = None - - @property - def is_demo(self) -> bool: - return self.source_kind == "demo" - - @property - def is_file(self) -> bool: - return self.source_kind == "file" - - @property - def is_error(self) -> bool: - return self.source_kind == "error" - - @property - def total(self) -> int: - return len(self.devices) - - @property - def counts(self) -> dict[str, int]: - """Count of devices per Turkish state word, zero filled, word order kept.""" - counted = {spec.word: 0 for spec in STATE_VOCABULARY} - for device in self.devices: - counted[device.state_label] += 1 - return counted - - @property - def ready_count(self) -> int: - return self.counts["HAZIR"] - - @property - def not_ready_count(self) -> int: - return self.total - self.ready_count - - @property - def wave_counts(self) -> dict[str, int]: - counted = {wave.code: 0 for wave in WAVES} - for device in self.devices: - counted[device.wave_code] += 1 - return counted - - @property - def source_kind_word(self) -> str: - return { - "file": "envanter dosyası", - "demo": "sentetik demo veri", - "error": "okunamadı", - }[self.source_kind] - - @property - def banner_word(self) -> str: - """One Turkish word that names what the operator is looking at.""" - return {"file": "ENVANTER", "demo": "DEMO VERİ", "error": "VERİ YOK"}[self.source_kind] - - @property - def banner_tone(self) -> str: - return {"file": "info", "demo": "warn", "error": "danger"}[self.source_kind] - - @property - def banner_text(self) -> str: - """The sentence under the word: what the data is, and what it is not.""" - if self.is_demo: - return ( - f"Gerçek filo envanteri bağlı değil: aşağıdaki {self.total} kayıt " - "sentetiktir ve hiçbir cihazı temsil etmez. Gerçek veri için paneli " - "«--data » ile başlatın." - ) - if self.is_file: - generated = self.generated_at or "bildirilmemiş" - return ( - f"Kaynak: {self.source_path} · şema sürümü {self.schema_version} · " - f"dosya üretimi: {generated} · yükleme: {self.loaded_at}" - ) - return f"{self.error} Demo veriye düşülmedi; filo durumu bilinmiyor." - - -@dataclass(frozen=True) -class FleetFilter: - """What the operator narrowed the list to.""" - - query: str = "" - state: str = "" # machine state name, "" means every state - wave: str = "" # wave code, "" means every wave - only_not_ready: bool = False - - @property - def is_active(self) -> bool: - return bool(self.query or self.state or self.wave or self.only_not_ready) - - def matches(self, device: Device) -> bool: - if self.state and device.state != self.state: - return False - if self.wave and device.wave_code != self.wave: - return False - if self.only_not_ready and device.is_ready: - return False - if self.query: - needle = fold(self.query) - haystack = " ".join((device.device_id, device.hostname, device.dealer_id)) - if needle not in fold(haystack): - return False - return True - - @property - def description(self) -> str: - """Turkish summary of the filter, for the header band and the empty note.""" - parts: list[str] = [] - if self.query: - parts.append(f"metin «{self.query}»") - if self.state: - parts.append(f"durum {STATE_BY_NAME[self.state].word}") - if self.wave: - parts.append(f"dalga {self.wave}") - if self.only_not_ready: - parts.append("yalnız hazır olmayanlar") - return " · ".join(parts) if parts else "filtre yok" - - -# -------------------------------------------------------------------------- -# Text helpers (Turkish aware: the vocabulary contains ı/İ/ş/ğ/ç/ö/ü) -# -------------------------------------------------------------------------- - -_FOLD_TABLE = str.maketrans( - { - "ı": "i", "İ": "i", "I": "i", "ş": "s", "Ş": "s", "ğ": "g", "Ğ": "g", - "ç": "c", "Ç": "c", "ö": "o", "Ö": "o", "ü": "u", "Ü": "u", - } -) - - -def fold(text: str) -> str: - """Case- and accent-insensitive key for operator input (Turkish included). - - ``HAZIR``, ``Hazır`` and ``hazir`` must all mean the same filter value, and - ``str.lower()`` alone does not get there: ``"HAZIR".lower()`` is ``hazir`` - while ``"hazır".lower()`` is ``hazır``. - """ - return text.translate(_FOLD_TABLE).lower() - - -def _text(value: Any) -> str | None: - """Non-empty string, or ``None`` -- the panel never shows a bare ``None``.""" - if isinstance(value, str): - stripped = value.strip() - return stripped or None - if isinstance(value, bool): - return None - if isinstance(value, (int, float)): - return str(value) - return None - - -def first_param(params: Mapping[str, Any], key: str) -> str: - """First value of a query parameter, in both ``str`` and ``list`` shapes.""" - value = params.get(key) - if value is None: - return "" - if isinstance(value, (list, tuple)): - value = value[0] if value else "" - return _text(str(value)) or "" - - -def timestamp_now() -> str: - return datetime.now().astimezone().isoformat(timespec="seconds") - - -def _alias_index(items: Iterable[tuple[str, str]]) -> dict[str, str]: - """Build a folded lookup table, complaining loudly about collisions.""" - table: dict[str, str] = {} - for key, value in items: - folded = fold(key) - existing = table.get(folded) - if existing is not None and existing != value: - raise ValueError(f"alias collision: {key!r} -> {value!r} / {existing!r}") - table[folded] = value - return table - - -STATE_ALIASES: dict[str, str] = _alias_index( - [(spec.state, spec.state) for spec in STATE_VOCABULARY] - + [(spec.word, spec.state) for spec in STATE_VOCABULARY] - + [(spec.token_suffix, spec.state) for spec in STATE_VOCABULARY] -) - -WAVE_ALIASES: dict[str, str] = _alias_index( - [(wave.code, wave.code) for wave in WAVES] - + [(alias, wave.code) for wave in WAVES for alias in wave.aliases] -) - -CHANNEL_ALIASES: dict[str, str] = _alias_index( - [(spec.key, spec.key) for spec in CHANNELS] - + [(spec.label, spec.key) for spec in CHANNELS] - + [ - ("ssh", "ssh_over_wg"), - ("ssh-over-wg", "ssh_over_wg"), - ("ssh_over_wireguard", "ssh_over_wg"), - ("wireguard-ssh", "ssh_over_wg"), - ("rdp", "xrdp"), - ("rust_desk", "rustdesk"), - ("mesh", "meshcentral"), - ] -) - -CHANNEL_STATUS_ALIASES: dict[str, str] = _alias_index( - [(status, status) for status in CHANNEL_STATUSES] - + [(word, status) for status, word in CHANNEL_STATUS_WORDS.items()] -) - - -# -------------------------------------------------------------------------- -# Parsing -# -------------------------------------------------------------------------- - - -def _record_ref(record: Any, index: int) -> str: - if isinstance(record, Mapping): - for key in ("device_id", "dealer_id", "bayi_no", "hostname"): - value = _text(record.get(key)) - if value: - return value - return f"kayıt {index + 1}" - - -def _parse_channel(raw: Any) -> tuple[str, str | None, str | None]: - """-> (status, checked_at, advisory). Unknown shapes never crash the panel.""" - if raw is None: - return "missing", None, None - if isinstance(raw, str): - claimed, checked_at = raw, None - elif isinstance(raw, Mapping): - claimed = _text(raw.get("status")) or "unknown" - checked_at = _text(raw.get("checked_at")) or _text(raw.get("checked")) - else: - return "unknown", None, "Kanal kaydı okunamadı: nesne ya da metin bekleniyor." - status = CHANNEL_STATUS_ALIASES.get(fold(claimed)) - if status is None: - return "unknown", checked_at, f"Bilinmeyen kanal durumu yok sayıldı: «{claimed}»." - return status, checked_at, None - - -def parse_record(record: Any, index: int = 0) -> tuple[Device | None, InvalidRecord | None]: - """Turn one raw inventory record into a ``Device`` or a refused record. - - A record is refused only when it cannot be identified at all; anything the - panel can name it shows, with the reason it could not be trusted attached. - """ - ref = _record_ref(record, index) - if not isinstance(record, Mapping): - return None, InvalidRecord(ref, "Kayıt nesne değil; JSON nesnesi bekleniyor.") - - dealer_id = _text(record.get("dealer_id")) or _text(record.get("dealer")) \ - or _text(record.get("bayi_no")) - if not dealer_id: - return None, InvalidRecord(ref, "Bayi no yok; cihaz kimliği türetilemez (docs/02 §9).") - if not DEALER_PATTERN.match(dealer_id): - return None, InvalidRecord( - ref, - f"Bayi no 8 hane değil: «{dealer_id}» (kural ^[0-9]{{8}}$).", - ) - - device_id = DEVICE_ID_PREFIX + dealer_id - hostname = HOSTNAME_PREFIX + dealer_id - - declared_id = _text(record.get("device_id")) - if declared_id and declared_id != device_id: - return None, InvalidRecord( - ref, f"Cihaz kimliği kurala uymuyor: «{declared_id}» yerine «{device_id}» olmalı (docs/02)." - ) - declared_host = _text(record.get("hostname")) - if declared_host and declared_host != hostname: - return None, InvalidRecord( - ref, f"Hostname kurala uymuyor: «{declared_host}» yerine «{hostname}» olmalı (docs/02)." - ) - - wave_raw = _text(record.get("wave")) or _text(record.get("dalga")) - if not wave_raw: - return None, InvalidRecord(ref, "Dalga yok; cihaz bir yayılım dalgasına bağlanamaz (docs/18).") - wave_code = WAVE_ALIASES.get(fold(wave_raw)) - if wave_code is None: - return None, InvalidRecord( - ref, f"Bilinmeyen dalga: «{wave_raw}» (LAB, P1, P2, W1, W2, PROD)." - ) - - notes: list[str] = [] - state_raw = _text(record.get("state")) or _text(record.get("durum")) - claimed_state: str | None = None - reason: str | None = None - if state_raw is None: - state = "FAILED" - reason = ( - "Durum alanı yok. Panel durum uydurmaz; cihaz HATA olarak işaretlendi " - "(docs/27)." - ) - else: - resolved = STATE_ALIASES.get(fold(state_raw)) - if resolved is None: - state = "FAILED" - claimed_state = state_raw - reason = ( - f"Durum alanı tanınmıyor: «{state_raw}». Panel durum uydurmaz; cihaz " - "HATA olarak işaretlendi (docs/27)." - ) - else: - state = resolved - - raw_channels = record.get("channels") - statuses: dict[str, tuple[str, str | None]] = {} - if raw_channels is None: - notes.append("Kanallar bildirilmemiş; dört kanal «yok» sayıldı (docs/27).") - elif not isinstance(raw_channels, Mapping): - notes.append("«channels» alanı nesne değil; dört kanal «yok» sayıldı (docs/27).") - else: - for raw_key, raw_value in raw_channels.items(): - key = CHANNEL_ALIASES.get(fold(str(raw_key))) - if key is None: - notes.append(f"Bilinmeyen kanal anahtarı yok sayıldı: «{raw_key}».") - continue - status, checked_at, advisory = _parse_channel(raw_value) - statuses[key] = (status, checked_at) - if advisory: - notes.append(advisory) - - checks: list[ChannelCheck] = [] - for spec in CHANNELS: - status, checked_at = statuses.get(spec.key, ("missing", None)) - checks.append(ChannelCheck(spec.key, spec.label, spec.hint, status, checked_at)) - - # READY gate (docs/27 §9.4): the claim is refused while a channel is - # unverified. The panel does not downgrade the word to a warning -- a claim - # the fleet cannot back is a failure and needs a person. - if state == "READY": - blocked = [check.label for check in checks if not check.is_verified] - if blocked: - claimed_state = "READY" - state = "FAILED" - reason = ( - "HAZIR iddiası reddedildi: " + ", ".join(blocked) + - " doğrulanmadı. READY yalnız dört kanalın hepsi doğrulandığında " - "verilir (docs/27)." - ) - - # A channel check that failed means the device needs a person. - failed = [check.label for check in checks if check.status == "failed"] - if failed and state not in ("FAILED",): - reason = ( - "Kanal doğrulaması başarısız: " + ", ".join(failed) + - ". Cihaz bir insan bekliyor (docs/27)." - ) - state = "FAILED" - - # The panel never promotes: a record may lag behind the centre, never lead it. - if state in ("PROVISIONED_OFFLINE", "ENROLLED") and all( - check.is_verified for check in checks - ): - notes.append( - "Dört kanal da doğrulandı, ancak merkez kaydı daha ileri bir durum " - "demiyor; panel durumu yükseltmez (docs/27)." - ) - - return ( - Device( - device_id=device_id, - hostname=hostname, - dealer_id=dealer_id, - wave_code=wave_code, - state=state, - channels=tuple(checks), - last_seen=_text(record.get("last_seen")) or _text(record.get("son_gorulme")), - note=_text(record.get("note")), - claimed_state=claimed_state, - reason=reason, - notes=tuple(notes), - ), - None, - ) - - -def parse_records(records: Sequence[Any]) -> tuple[tuple[Device, ...], tuple[InvalidRecord, ...]]: - """Parse a whole document body; duplicate dealer numbers are refused (docs/02).""" - devices: list[Device] = [] - invalid: list[InvalidRecord] = [] - seen: dict[str, str] = {} - for index, record in enumerate(records): - device, refused = parse_record(record, index) - if refused is not None: - invalid.append(refused) - continue - assert device is not None - if device.dealer_id in seen: - invalid.append( - InvalidRecord( - device.device_id, - "Yinelenen bayi no; ikinci kayıt reddedildi (docs/02 §8).", - ) - ) - continue - seen[device.dealer_id] = device.device_id - devices.append(device) - return tuple(devices), tuple(invalid) - - -# -------------------------------------------------------------------------- -# Loading -# -------------------------------------------------------------------------- - - -def _error_snapshot(path: Path | None, error: str, stamp: str) -> FleetSnapshot: - return FleetSnapshot( - devices=(), - invalid=(), - source_kind="error", - source_path=str(path) if path is not None else None, - source_label=f"veri okunamadı: {path}" if path is not None else "veri okunamadı", - error=error, - loaded_at=stamp, - ) - - -def demo_snapshot(stamp: str | None = None) -> FleetSnapshot: - """Labelled synthetic data, for a first look with no fleet attached.""" - devices, invalid = parse_records(DEMO_DEVICES) - return FleetSnapshot( - devices=devices, - invalid=invalid, - source_kind="demo", - source_path=None, - source_label=DEMO_SOURCE_LABEL, - error=None, - loaded_at=stamp or timestamp_now(), - generated_at=None, - schema_version=SUPPORTED_SCHEMA_VERSION, - ) - - -def load_fleet( - path: str | Path | None = None, - *, - env: Mapping[str, str] | None = None, - now: str | None = None, - default_path: str | Path | None = None, -) -> FleetSnapshot: - """Read one inventory document into a ``FleetSnapshot``. - - Fall-back rules, deliberately different for the two cases: - - * no document requested and the default location is empty -> labelled demo - data, so a first look is possible without a fleet; - * a document *was* requested (``path`` or ``BF_FLEET_DATA``) but cannot be - read, is not JSON, or breaks the schema -> an error snapshot. A broken - feed never becomes demo data and never becomes an empty fleet. - - ``env``, ``now`` and ``default_path`` exist so a test can pin the source and - the clock; the panel itself passes none of them. - """ - environment = os.environ if env is None else env - stamp = now or timestamp_now() - - requested: str | None = None - if path is not None: - requested = str(path) - else: - from_env = _text(environment.get(DATA_PATH_ENV)) - if from_env: - requested = from_env - explicit = requested is not None - target = Path(requested) if requested is not None else Path( - str(default_path) if default_path is not None else str(DEFAULT_DATA_PATH) - ) - - if not target.is_file(): - if explicit: - return _error_snapshot( - target, - f"Envanter dosyası bulunamadı: {target}. Panel demo veriye düşmedi; " - "filo durumu bilinmiyor.", - stamp, - ) - return demo_snapshot(stamp) - - try: - document = json.loads(target.read_text(encoding="utf-8")) - except OSError as exc: - return _error_snapshot( - target, - f"Envanter dosyası okunamadı: {target} ({exc.strerror or exc}).", - stamp, - ) - except json.JSONDecodeError as exc: - return _error_snapshot( - target, - f"Envanter dosyası JSON olarak çözümlenemedi: {target} " - f"(satır {exc.lineno}, sütun {exc.colno}).", - stamp, - ) - - if not isinstance(document, Mapping): - return _error_snapshot( - target, f"Envanter belgesi nesne değil: {target} (JSON nesnesi bekleniyor).", stamp - ) - - schema_version = document.get("schema_version") - if not isinstance(schema_version, int) or isinstance(schema_version, bool): - return _error_snapshot( - target, - f"Envanter belgesinde schema_version yok ya da sayı değil: {target}. " - f"Bu panel şema sürümü {SUPPORTED_SCHEMA_VERSION} okur.", - stamp, - ) - if schema_version > SUPPORTED_SCHEMA_VERSION: - return _error_snapshot( - target, - f"Desteklenmeyen şema sürümü: {schema_version}; bu panel " - f"{SUPPORTED_SCHEMA_VERSION} okur. Panel fail-closed durdu.", - stamp, - ) - - records = document.get("devices") - if not isinstance(records, list): - return _error_snapshot( - target, f"Envanter belgesinde «devices» listesi yok: {target}.", stamp - ) - - devices, invalid = parse_records(records) - return FleetSnapshot( - devices=devices, - invalid=invalid, - source_kind="file", - source_path=str(target), - source_label=f"envanter dosyası: {target}", - error=None, - loaded_at=stamp, - generated_at=_text(document.get("generated_at")), - schema_version=schema_version, - ) - - -# -------------------------------------------------------------------------- -# Ordering and filtering -# -------------------------------------------------------------------------- - - -def sort_devices(devices: Iterable[Device]) -> tuple[Device, ...]: - """Non-ready first (docs/DESIGN.md), then wave order, then identity. - - Reading order for an operator: what needs a person, then the rollout order, - with the id as the last, stable tie-breaker. - """ - return tuple( - sorted( - devices, - key=lambda device: ( - STATE_ORDER.index(device.state), - device.wave.order, - device.device_id, - ), - ) - ) - - -def filter_devices(devices: Iterable[Device], criteria: FleetFilter) -> tuple[Device, ...]: - return tuple(device for device in devices if criteria.matches(device)) - - -def parse_filter(params: Mapping[str, Any]) -> tuple[FleetFilter, tuple[str, ...]]: - """Normalise operator input; unknown values are ignored *visibly*.""" - warnings: list[str] = [] - - state_raw = first_param(params, "durum") - state = "" - if state_raw: - state = STATE_ALIASES.get(fold(state_raw), "") - if not state: - warnings.append( - f"Bilinmeyen durum filtresi yok sayıldı: «{state_raw}». " - "Sözlük: " + ", ".join(spec.word for spec in STATE_VOCABULARY) + "." - ) - - wave_raw = first_param(params, "dalga") - wave = "" - if wave_raw: - wave = WAVE_ALIASES.get(fold(wave_raw), "") - if not wave: - warnings.append( - f"Bilinmeyen dalga filtresi yok sayıldı: «{wave_raw}». " - "Dalgalar: " + ", ".join(w.code for w in WAVES) + "." - ) - - only_not_ready = fold(first_param(params, "hazir_degil")) in ("1", "var", "true", "on", "evet") - - criteria = FleetFilter( - query=first_param(params, "q"), - state=state, - wave=wave, - only_not_ready=only_not_ready, - ) - return criteria, tuple(warnings) - - -def state_counts(devices: Iterable[Device]) -> dict[str, int]: - counted = {spec.word: 0 for spec in STATE_VOCABULARY} - for device in devices: - counted[device.state_label] += 1 - return counted - - -def wave_counts(devices: Iterable[Device]) -> dict[str, int]: - counted = {wave.code: 0 for wave in WAVES} - for device in devices: - counted[device.wave_code] += 1 - return counted - - - -"""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) - - - -# 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-` / `bf-` (`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. - - - -"""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(), - ) - - - -#!/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()) - - #!/usr/bin/env bash # @@ -7483,771 +1921,6 @@ bf_deliver_secret() { } - -#!/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-, human id is BF- (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 -- "grouphost" for every host in the inventory. -# A deliberately small YAML reader: the inventory is a nested map of -# all.children..children..hosts.. 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- 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 -} - - - -#!/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" -} - - - -#!/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" "$@" # ''' -"""bfos operator console — thin entry point (sh/Python polyglot). - -The file is valid POSIX shell *and* valid Python: - -* run as ``admin/bf`` (or as the ``bf`` symlink 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 - the proven bash console (``admin/bf-menu``) — a device without Python is still - operational, -* 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; - all logic is in the ``bfos`` package next to it. - -Usage: sudo bfos [--full] [--dry-run] [--check] [--repo YOL] [--version] [--lang tr] -""" - -import os -import sys - -MIN_PYTHON = (3, 8) - -if sys.version_info < MIN_PYTHON: # pragma: no cover - depends on the interpreter - sys.stderr.write( - "HATA: Python sürümü çok eski: {0}. bfos için Python {1}.{2}+ gerekir.\n".format( - sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1] - ) - ) - sys.stderr.write("Yedek yol (bash): sudo admin/bf-menu\n") - raise SystemExit(3) - - -def _repository_root(): - """First candidate directory that actually contains the bfos package.""" - here = os.path.dirname(os.path.realpath(__file__)) - candidates = [ - os.environ.get("BF_REPO", ""), - os.path.dirname(here), # /admin/bf -> - here, - os.getcwd(), - ] - for candidate in candidates: - if candidate and os.path.isfile(os.path.join(candidate, "bfos", "__init__.py")): - return candidate - return "" - - -ROOT = _repository_root() -if not ROOT: - sys.stderr.write( - "HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n" - "ya da --repo YOL ile depo yolunu belirtin.\n" - "Yedek yol (bash): sudo admin/bf-menu\n" - ) - raise SystemExit(3) - -if ROOT not in sys.path: - sys.path.insert(0, ROOT) - -from bfos.cli import main # noqa: E402 (sys.path is prepared above) - -if __name__ == "__main__": - raise SystemExit(main()) - - #!/usr/bin/env bash # @@ -8487,1953 +2160,6 @@ if [[ "$FETCH" -eq 1 ]]; then fi - -#!/usr/bin/env bash -# -# bf-menu -- numbered operator console for the Blueforce fleet. -# -# One entry point for two very different jobs: -# * category 1 installs THIS device locally, on the machine you are sitting at -# * categories 2 and 5 drive the fleet through the existing Ansible playbooks -# with an explicit --limit and never invent a new playbook -# -# Safety rules this console is built around: -# * every command is printed before it runs and must be confirmed, -# * destructive operations ask a second, explicit question, -# * wave-gated playbooks are never handed a gate flag silently: the operator -# has to confirm the gate (and type "production" for the production wave), -# * the audit log is mode 600 and never receives a secret: only the sanitized -# command, its exit status and the target are recorded (raw playbook output -# is shown on screen but never written to the log), -# * a missing Ansible, inventory or playbook produces an explanation, never a -# silent fallback to "something similar". -# -# Usage: admin/bf-menu [options] (see --help) -# -set -euo pipefail - -BF_MENU_VERSION="1.0.0" -BF_MENU_NAME="admin/bf-menu" -DEFAULT_LOG_FILE="/var/log/blueforce-console.log" -INSTALLER_REL="scripts/install/blueforce-install.sh" -PLAYBOOK_DIR_REL="ansible/playbooks" - -# --------------------------------------------------------------------------- -# Globals (set by argument parsing / discovery) -# --------------------------------------------------------------------------- -BF_REPO="${BF_REPO:-}" -BF_INVENTORY="${BF_INVENTORY:-}" -BF_LOG_FILE="${BF_CONSOLE_LOG:-$DEFAULT_LOG_FILE}" -BF_LOG_READY=0 -BF_DRY_RUN=0 -BF_INVENTORY_CACHE_FILE="" -BF_CMD=() -BF_EXTRA=() -BF_LAST_RC=0 - -# --------------------------------------------------------------------------- -# Usage -# --------------------------------------------------------------------------- -bf_usage() { - cat <&2; exit 2; }; BF_REPO="$2"; shift 2 ;; - --inventory) [[ $# -ge 2 ]] || { printf 'ERROR: --inventory needs a path\n' >&2; exit 2; }; BF_INVENTORY="$2"; shift 2 ;; - --log-file) [[ $# -ge 2 ]] || { printf 'ERROR: --log-file needs a path\n' >&2; exit 2; }; BF_LOG_FILE="$2"; shift 2 ;; - --dry-run) BF_DRY_RUN=1; shift ;; - --no-color) TUI_COLOR_MODE=never; shift ;; - --list) BF_LIST=1; shift ;; - --list-targets) BF_LIST_TARGETS=1; shift ;; - *) printf 'ERROR: unknown argument: %s\n' "$1" >&2; bf_usage >&2; exit 2 ;; - esac - done -} -BF_LIST=0 -BF_LIST_TARGETS=0 - -# --------------------------------------------------------------------------- -# Repository discovery -# --------------------------------------------------------------------------- -bf_resolve_repo() { - local self self_real self_dir - if [[ -z "$BF_REPO" ]]; then - # bf-bootstrap.sh links this script into a bin directory, so the path we - # were invoked through is usually a symlink: resolve it before deriving - # the repository root. - self="${BASH_SOURCE[0]}" - self_real="$(readlink -f -- "$self" 2>/dev/null || printf '%s' "$self")" - self_dir="$(cd "$(dirname "$self_real")" && pwd)" - BF_REPO="$(cd "$self_dir/.." && pwd)" - fi - if [[ ! -d "$BF_REPO/ansible" || ! -d "$BF_REPO/scripts" ]]; then - printf 'ERROR: %s does not look like the Blueforce repository (no ansible/ and scripts/).\n' "$BF_REPO" >&2 - printf ' Clone it and run the console from the checkout, or pass --repo PATH.\n' >&2 - exit 2 - fi - if [[ ! -r "$BF_REPO/admin/lib/tui.sh" || ! -r "$BF_REPO/admin/lib/targets.sh" ]]; then - printf 'ERROR: %s/admin/lib/tui.sh or targets.sh is missing.\n' "$BF_REPO" >&2 - printf ' The console needs its libraries next to it (admin/lib/).\n' >&2 - exit 2 - fi - export BF_REPO -} - -# --------------------------------------------------------------------------- -# Audit log: mode 600, sanitized, never a secret -# --------------------------------------------------------------------------- -# bf_log_sanitize -- single choke point before anything reaches the log file. -bf_log_sanitize() { - sed -E \ - -e 's/((password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)[A-Za-z0-9_.-]*[[:space:]]*[=:])[^[:space:]]+/\1/Ig' \ - -e 's/(BEGIN [A-Z ]*PRIVATE KEY).*/\1 /g' -} - -bf_log() { - local level="$1"; shift - [[ "$BF_LOG_READY" -eq 1 ]] || return 0 - printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$(printf '%s' "$*" | bf_log_sanitize)" >>"$BF_LOG_FILE" -} - -# bf_log_init -- keep the log at mode 600 or refuse to log into an insecure -# path. Falls back to a per-user state directory when /var/log is not writable. -bf_log_init() { - local fallback_dir candidate - fallback_dir="${XDG_STATE_HOME:-$HOME/.local/state}/blueforce" - for candidate in "$BF_LOG_FILE" "$fallback_dir/blueforce-console.log"; do - [[ -n "$candidate" ]] || continue - if [[ -e "$candidate" ]]; then - [[ -w "$candidate" ]] || continue - else - mkdir -p "$(dirname "$candidate")" 2>/dev/null || continue - [[ -w "$(dirname "$candidate")" ]] || continue - fi - if [[ -e "$candidate" ]]; then - chmod 600 "$candidate" 2>/dev/null || continue - else - ( umask 077; : >"$candidate" ) 2>/dev/null || continue - chmod 600 "$candidate" 2>/dev/null || continue - fi - BF_LOG_FILE="$candidate" - BF_LOG_READY=1 - if [[ "$candidate" != "${BF_LOG_REQUESTED:-$DEFAULT_LOG_FILE}" ]]; then - printf '[WARN] %s is not writable; logging to %s instead (mode 600).\n' \ - "${BF_LOG_REQUESTED:-$DEFAULT_LOG_FILE}" "$candidate" >&2 - fi - return 0 - done - BF_LOG_READY=0 - printf '[WARN] no writable audit log found; this session will not be logged.\n' >&2 - return 0 -} - -bf_cleanup() { - if [[ -n "$BF_INVENTORY_CACHE_FILE" && -f "$BF_INVENTORY_CACHE_FILE" ]]; then - rm -f "$BF_INVENTORY_CACHE_FILE" - fi -} - -bf_quit() { - local code="${1:-0}" - tui_note "Leaving the Blueforce console." - exit "$code" -} - -# --------------------------------------------------------------------------- -# Runtime checks with explanations instead of guesses -# --------------------------------------------------------------------------- -bf_have_ansible() { - command -v ansible-playbook >/dev/null 2>&1 -} - -bf_require_ansible() { - if bf_have_ansible; then - return 0 - fi - tui_error "ansible-playbook was not found on this machine; fleet operations cannot run." - tui_note "The console does not emulate Ansible: it drives the repository's playbooks (docs/09)." - tui_note "Install it on the admin machine, e.g.:" - tui_note " sudo apt install ansible # or: pipx install ansible-core" - return 1 -} - -bf_require_inventory() { - local resolved - # stdout carries the resolved path, stderr carries the explanation: never - # merge them, or an error message would end up inside the path. - if resolved="$(bf_inventory_resolve)"; then - BF_INVENTORY="$resolved" - export BF_INVENTORY - return 0 - fi - return 1 -} - -bf_require_root() { - if [[ "$(id -u)" -eq 0 ]]; then - return 0 - fi - tui_error "This operation needs root on THIS device." - tui_note "Re-run the console with sudo: sudo $BF_MENU_NAME" - return 1 -} - -# bf_tool_path NAME -> preferred path of a diagnostics/maintenance tool. -# A tool installed under /usr/local/bin wins (that is the canonical Field OS -# location); otherwise the copy in this checkout is used. -bf_tool_path() { - local name="$1" - local sys="/usr/local/bin/$name" - local repo="$BF_REPO/scripts/diagnostics/$name" - local maint="$BF_REPO/scripts/maintenance/$name" - if [[ -x "$sys" ]]; then printf '%s\n' "$sys"; return 0; fi - if [[ -x "$repo" ]]; then printf '%s\n' "$repo"; return 0; fi - if [[ -x "$maint" ]]; then printf '%s\n' "$maint"; return 0; fi - return 1 -} - -bf_installer_path() { - local p="$BF_REPO/$INSTALLER_REL" - [[ -f "$p" ]] && printf '%s\n' "$p" -} - -# --------------------------------------------------------------------------- -# Execution -# --------------------------------------------------------------------------- -# bf_cmd_string -- the command as a copy/pasteable shell line. -bf_cmd_string() { - printf '%q ' "${BF_CMD[@]}" -} - -# bf_exec -- run BF_CMD (or print it under --dry-run). Sets BF_LAST_RC. -bf_exec() { - local printable rc=0 - printable="$(bf_cmd_string)" - printf '\n' - tui_section "Command" - printf ' %s\n\n' "$printable" - if [[ "$BF_DRY_RUN" -eq 1 ]]; then - tui_warn "dry-run: the command above was NOT executed." - bf_log "DRYRUN" "$printable" - BF_LAST_RC=0 - return 0 - fi - bf_log "RUN" "$printable" - set +e - "${BF_CMD[@]}" - rc=$? - set -e - BF_LAST_RC="$rc" - if [[ "$rc" -eq 0 ]]; then - tui_ok "Command finished successfully (exit 0)." - bf_log "OK" "exit=0 :: $printable" - else - tui_error "Command failed with exit $rc." - bf_log "FAIL" "exit=$rc :: $printable" - fi - return 0 -} - -# bf_confirm_and_exec LABEL DESTRUCTIVE -# Shows the command, asks for confirmation, then runs it. Returns 0 when the -# command was executed (including a confirmed --check run), 1 when cancelled. -bf_confirm_and_exec() { - local label="$1" destructive="${2:-no}" answer=0 - if [[ "$BF_DRY_RUN" -eq 1 ]]; then - bf_exec - return 0 - fi - if [[ "$destructive" == "yes" ]]; then - tui_warn "State-changing operation: $label" - tui_warn "Read the command above before confirming; it changes the selected target(s)." - tui_ask_run "Proceed? " || answer=$? - else - tui_ask_run "Run this command?" || answer=$? - fi - case "$answer" in - 0) : ;; - 2) - BF_CMD+=(--check) - tui_info "check mode: the command runs with --check and applies no change." - ;; - *) - tui_info "Cancelled; nothing was run." - bf_log "CANCEL" "aborted by operator :: $(bf_cmd_string)" - return 1 - ;; - esac - bf_exec - return 0 -} - -# --------------------------------------------------------------------------- -# Operation catalogue -# --------------------------------------------------------------------------- -# Fields: id|label|playbook|scope|destructive|prompt|description -# scope simple | wave | release (cross-checked against the playbook) -# destructive yes|no (second confirmation) -# prompt none | packages | service | script | grace | disk_pct | -# log_lines | expected_version -BF_OPS_REPORT=( - "ping|Ping / reachability|bf-ping.yml|simple|no|none|SSH reachability check (read-only)" - "uptime|Uptime|bf-uptime.yml|simple|no|none|Uptime and load (read-only)" - "disk|Disk usage|bf-disk-check.yml|simple|no|disk_pct|Disk usage, warns above a threshold" - "docker|Docker status|bf-docker-status.yml|simple|no|none|Docker daemon and containers (read-only)" - "packages|Package versions|bf-package-check.yml|simple|no|none|Installed versions of pinned packages" - "security-updates|Pending security updates|bf-security-updates-check.yml|simple|no|none|Report only, never installs" - "provisioning-status|Provisioning phase report|bf-provisioning-status.yml|wave|no|none|state.json phase per device" - "enrollment-status|Enrollment + central evidence|bf-enrollment-status.yml|wave|no|none|ENROLLED/READY claims vs central proof" - "fieldos-version|Field OS version report|bf-fieldos-version.yml|wave|no|expected_version|Version per device, optional drift check" - "remote-channels|Remote access channels|bf-remote-channels-check.yml|wave|no|none|SSH/xRDP/RustDesk/WireGuard readiness" - "offline-ready|Offline readiness report|bf-offline-ready-check.yml|wave|no|none|Local preconditions for offline provisioning" -) - -BF_OPS_MAINTENANCE=( - "rdp-restart|Restart RDP|bf-rdp-restart.yml|simple|yes|none|Restart xrdp" - "rustdesk-restart|Restart RustDesk|bf-rustdesk-restart.yml|simple|yes|none|Restart the RustDesk service" - "wireguard-restart|Restart WireGuard|bf-wireguard-restart.yml|simple|yes|none|Restart wg-quick@wg0" - "service-restart|Restart an allowlisted service|bf-service-restart.yml|simple|yes|service|Allowlisted service names only" - "gui-off|GUI off (headless)|bf-gui-off.yml|wave|yes|none|Switch the wave to multi-user.target" - "gui-on|GUI on (maintenance)|bf-gui-on.yml|wave|yes|none|Start the graphical session again" - "collect-logs|Collect logs / support bundle|bf-collect-logs.yml|wave|no|log_lines|Timestamped journal + snapshot bundle" - "run-script|Run an approved script|bf-run-script.yml|wave|yes|script|Only /opt/blueforce/bin and /usr/local/sbin" - "reboot|Controlled reboot|bf-reboot.yml|release|yes|grace|Serial reboots with a health gate" - "deploy-update|Deploy approved package update|bf-deploy-update.yml|release|yes|packages|Pinned package=version, health-gated" -) - -bf_op_field() { - local row="$1" field="$2" - printf '%s' "$row" | awk -F'|' -v n="$field" '{print $n}' -} - -# --------------------------------------------------------------------------- -# Playbook helpers: the playbook file stays the source of truth for its gates -# --------------------------------------------------------------------------- -# bf_playbook_var_present PLAYBOOK VAR -- true when VAR is used outside comments. -bf_playbook_var_present() { - local pb="$1" var="$2" file="$BF_REPO/$PLAYBOOK_DIR_REL/$1" - [[ -r "$file" ]] || return 1 - grep -vE '^[[:space:]]*#' "$file" | grep -qE "\\b${var}\\b" -} - -# bf_playbook_scope PLAYBOOK -> wave|release|simple, derived from the playbook. -bf_playbook_scope() { - local pb="$1" - if bf_playbook_var_present "$pb" release_wave; then - printf 'release\n' - return 0 - fi - if bf_playbook_var_present "$pb" operation_wave; then - printf 'wave\n' - return 0 - fi - printf 'simple\n' -} - -# bf_playbook_check PLAYBOOK DECLARED_SCOPE -- existence plus a metadata -# cross-check, so a console table that drifted from the playbook can never -# silently drop a wave gate. -bf_playbook_check() { - local pb="$1" declared="$2" file="$BF_REPO/$PLAYBOOK_DIR_REL/$1" actual - if [[ ! -r "$file" ]]; then - tui_error "Playbook not found: ansible/playbooks/$pb" - tui_note "Expected at: $file" - tui_note "Set --repo to the repository checkout that contains it." - return 1 - fi - actual="$(bf_playbook_scope "$pb")" - if [[ "$actual" != "$declared" ]]; then - tui_error "Refusing to run $pb: the console expects scope '$declared' but the playbook declares '$actual'." - tui_note "The playbook is the source of truth for its gates; update admin/bf-menu before using it." - return 1 - fi - return 0 -} - -# --------------------------------------------------------------------------- -# Extra -e variables, validated with the same rules the playbooks enforce -# --------------------------------------------------------------------------- -BF_SERVICE_ALLOWLIST=(blueforce-agent docker ssh sshd systemd-journald cron) -BF_SCRIPT_DIRS=(/opt/blueforce/bin /usr/local/sbin) - -bf_prompt_extra() { - local kind="$1" - BF_EXTRA=() - case "$kind" in - none) return 0 ;; - packages) bf_prompt_packages ;; - service) bf_prompt_service ;; - script) bf_prompt_script ;; - grace) bf_prompt_grace ;; - disk_pct) bf_prompt_disk_pct ;; - log_lines) bf_prompt_log_lines ;; - expected_version) bf_prompt_expected_version ;; - *) tui_error "Console bug: unknown prompt kind '$kind'."; return 1 ;; - esac -} - -bf_prompt_packages() { - local pins=() pin array=() - tui_note "Exact pins only: package=version (the playbook refuses ranges, 'latest' and empty lists)." - tui_prompt "Packages (comma separated)" "" || return 1 - if [[ -z "$TUI_REPLY" ]]; then - tui_error "At least one pinned package is required." - return 1 - fi - IFS=',' read -r -a pins <<<"$TUI_REPLY" - for pin in "${pins[@]}"; do - pin="${pin#"${pin%%[![:space:]]*}"}" - pin="${pin%"${pin##*[![:space:]]}"}" - [[ -n "$pin" ]] || continue - if [[ ! "$pin" =~ ^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$ ]]; then - tui_error "Invalid pin '$pin': expected package=version (e.g. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)." - return 1 - fi - array+=("\"$pin\"") - done - [[ ${#array[@]} -gt 0 ]] || { tui_error "No usable package pin was given."; return 1; } - BF_EXTRA=(-e "update_packages=[$(IFS=,; printf '%s' "${array[*]}")]") - return 0 -} - -bf_prompt_service() { - local name - tui_note "Allowlist: ${BF_SERVICE_ALLOWLIST[*]}" - tui_prompt "Service name" "" || return 1 - name="$TUI_REPLY" - for allowed in "${BF_SERVICE_ALLOWLIST[@]}"; do - if [[ "$name" == "$allowed" ]]; then - BF_EXTRA=(-e "service_name=$name") - return 0 - fi - done - tui_error "Refusing '$name': not in the service allowlist (the playbook would fail closed anyway)." - return 1 -} - -bf_prompt_script() { - local path args dir ok=0 - tui_note "Allowed directories: ${BF_SCRIPT_DIRS[*]}" - tui_prompt "Script path" "" || return 1 - path="$TUI_REPLY" - if [[ "$path" != /* ]]; then - tui_error "Script path must be absolute." - return 1 - fi - for dir in "${BF_SCRIPT_DIRS[@]}"; do - [[ "$path" == "$dir"/* ]] && ok=1 - done - if [[ "$ok" -ne 1 ]]; then - tui_error "Refusing '$path': outside the approved directories (the playbook would refuse it too)." - return 1 - fi - tui_prompt "Script arguments (optional)" "" || return 1 - args="$TUI_REPLY" - BF_EXTRA=(-e "script_path=$path") - if [[ -n "$args" ]]; then - BF_EXTRA+=(-e "script_args=$args") - fi - return 0 -} - -bf_prompt_grace() { - local seconds - tui_prompt "Reboot grace period in seconds" "300" || return 1 - seconds="$TUI_REPLY" - if [[ ! "$seconds" =~ ^[0-9]+$ || "$seconds" -lt 0 ]]; then - tui_error "Grace period must be a whole number of seconds." - return 1 - fi - BF_EXTRA=(-e "reboot_grace_seconds=$seconds") - return 0 -} - -bf_prompt_disk_pct() { - local pct - tui_prompt "Warn above percent (blank = playbook default 80)" "" || return 1 - pct="$TUI_REPLY" - if [[ -z "$pct" ]]; then - return 0 - fi - if [[ ! "$pct" =~ ^[0-9]+$ || "$pct" -lt 1 || "$pct" -gt 100 ]]; then - tui_error "Threshold must be between 1 and 100." - return 1 - fi - BF_EXTRA=(-e "disk_warn_pct=$pct") - return 0 -} - -bf_prompt_log_lines() { - local lines - tui_prompt "Journal lines per device" "500" || return 1 - lines="$TUI_REPLY" - if [[ ! "$lines" =~ ^[0-9]+$ || "$lines" -lt 1 ]]; then - tui_error "Line count must be a positive whole number." - return 1 - fi - BF_EXTRA=(-e "log_lines=$lines") - return 0 -} - -bf_prompt_expected_version() { - local version - tui_prompt "Expected Field OS version (blank = no drift check)" "" || return 1 - version="$TUI_REPLY" - if [[ -z "$version" ]]; then - return 0 - fi - BF_EXTRA=(-e "fieldos_expected_version=$version") - return 0 -} - -# --------------------------------------------------------------------------- -# Ansible command construction -# --------------------------------------------------------------------------- -bf_build_ansible_cmd() { - local pb="$1" scope="$2" wave="${3:-}" gate_confirmed="${4:-no}" - # Single choke point: no playbook command is ever built without a target. - if ! bf_target_is_set; then - return 1 - fi - BF_CMD=(ansible-playbook -i "$BF_INVENTORY" "$BF_REPO/$PLAYBOOK_DIR_REL/$pb" --limit "$BF_TARGET_LIMIT") - case "$scope" in - wave) - BF_CMD+=(-e "operation_wave=$wave" -e operation_confirmed=true) - if [[ "$wave" == "production" ]]; then - if bf_playbook_var_present "$pb" production_override; then - BF_CMD+=(-e production_override=true) - fi - fi - ;; - release) - BF_CMD+=(-e "release_wave=$wave") - if [[ "$gate_confirmed" == "yes" ]] && bf_playbook_var_present "$pb" wave_gate_confirmed; then - BF_CMD+=(-e wave_gate_confirmed=true) - fi - if bf_playbook_var_present "$pb" reboot_confirmed; then - BF_CMD+=(-e reboot_confirmed=true) - fi - if [[ "$wave" == "production" ]]; then - if bf_playbook_var_present "$pb" production_override; then - BF_CMD+=(-e production_override=true) - fi - if bf_playbook_var_present "$pb" allow_production_reboot; then - BF_CMD+=(-e allow_production_reboot=true) - fi - fi - ;; - esac - if [[ ${#BF_EXTRA[@]} -gt 0 ]]; then - BF_CMD+=("${BF_EXTRA[@]}") - fi - return 0 -} - -# bf_confirm_wave_gate WAVE PLAYBOOK -- reproduce the playbook's own gate -# contract as an explicit operator statement. Nothing is auto-confirmed. -bf_confirm_wave_gate() { - local wave="$1" pb="$2" - if [[ "$wave" != "production" ]]; then - return 0 - fi - tui_warn "Wave 'production' is the live fleet: $pb refuses to run without an explicit production override." - if ! tui_confirm_token "Type production to confirm a PRODUCTION run." "production"; then - tui_info "Production confirmation not given." - return 1 - fi - return 0 -} - -bf_confirm_release_gate() { - local pb="$1" - if ! bf_playbook_var_present "$pb" wave_gate_confirmed; then - return 0 - fi - tui_warn "$pb refuses to run unless the wave gate is confirmed." - tui_warn "Confirm only after the previous wave's health gate and the central approval have passed (docs/10)." - if ! tui_confirm_token "Wave gate confirmation." "GATE"; then - tui_info "Wave gate not confirmed." - return 1 - fi - return 0 -} - -# --------------------------------------------------------------------------- -# The device-operation runner -# --------------------------------------------------------------------------- -# bf_show_operation_header ROW -- always show what is about to happen. -bf_show_operation_header() { - local row="$1" - tui_kv "Operation" "$(bf_op_field "$row" 2)" - tui_kv "Playbook" "ansible/playbooks/$(bf_op_field "$row" 3)" - tui_kv "Scope" "$(bf_op_field "$row" 4)" - tui_kv "Inventory" "${BF_INVENTORY:-unresolved}" - tui_kv "Target" "${BF_TARGET_LABEL:-unset}" - if [[ -n "${BF_TARGET_WAVE:-}" ]]; then - tui_kv "Wave" "$BF_TARGET_WAVE" - fi - printf '\n' -} - -# bf_run_wave_once ROW WAVE -- build, confirm and run one wave. -bf_run_wave_once() { - local row="$1" wave="$2" pb="$3" destructive="$4" gate="no" scope - scope="$(bf_op_field "$row" 4)" - # The production token only applies where the playbook actually carries a - # wave/release gate; a simple playbook on a production device does not. - if [[ "$scope" != "simple" ]] && ! bf_confirm_wave_gate "$wave" "$pb"; then - return 1 - fi - if ! bf_confirm_release_gate "$pb"; then - return 1 - fi - if bf_playbook_var_present "$pb" wave_gate_confirmed; then - gate="yes" - fi - if ! bf_build_ansible_cmd "$pb" "$(bf_op_field "$row" 4)" "$wave" "$gate"; then - tui_error "No target is selected; refusing to build a command." - return 1 - fi - printf '\n' - tui_section "Command" - printf ' %s\n\n' "$(bf_cmd_string)" - # Fleet-wide scope is the one selection that can hurt many devices at once: - # it needs its own explicit confirmation, not just the run prompt. - if [[ "${BF_TARGET_KIND:-}" == "all" ]]; then - tui_warn "FLEET-WIDE: this command targets every device in the inventory (${BF_TARGET_COUNT} device(s))." - if ! tui_confirm_token "Fleet-wide scope confirmation." "all"; then - tui_info "Fleet-wide confirmation not given." - bf_log "CANCEL" "fleet-wide confirmation withheld :: $(bf_cmd_string)" - return 1 - fi - fi - bf_confirm_and_exec "$(bf_op_field "$row" 2) on wave '$wave'" "$destructive" || return 1 - return 0 -} - -# bf_run_row ROW -- full flow: target selection, extra vars, execution. -bf_run_row() { - local row="$1" id pb declared scope prompt destructive mode wave count - id="$(bf_op_field "$row" 1)" - pb="$(bf_op_field "$row" 3)" - declared="$(bf_op_field "$row" 4)" - destructive="$(bf_op_field "$row" 5)" - prompt="$(bf_op_field "$row" 6)" - - bf_require_ansible || return 1 - bf_require_inventory || return 1 - bf_playbook_check "$pb" "$declared" || return 1 - scope="$declared" - - case "$scope" in - wave) mode="wave" ;; - release) mode="release" ;; - *) mode="any" ;; - esac - - tui_screen "$(bf_op_field "$row" 2)" "$(bf_op_field "$row" 7)" - tui_note "Choose the target: dealer number, group, or all." - if [[ "$scope" == "release" ]]; then - tui_note "Release-gated playbooks accept one approved wave, never a single device." - fi - printf '\n' - if ! bf_target_select "$mode"; then - return 0 - fi - - if ! bf_prompt_extra "$prompt"; then - tui_warn "No command was built." - tui_pause - return 0 - fi - bf_log "TARGET" "$id target=${BF_TARGET_LIMIT} kind=${BF_TARGET_KIND} wave=${BF_TARGET_WAVE:-none}" - - if [[ "$scope" == "wave" && "$BF_TARGET_KIND" == "all" ]]; then - tui_screen "$(bf_op_field "$row" 2)" "wave by wave, stops at the first failure" - tui_warn "This playbook is wave-scoped: the fleet is processed one wave at a time." - tui_warn "Order: $(bf_waves_csv | tr ',' ' '). Each wave is shown and confirmed separately." - printf '\n' - if ! tui_confirm "Start with the first wave?"; then - tui_info "Cancelled; nothing was run." - return 0 - fi - # An array, not `while read < <(...)`: a redirection on a loop would also - # hijack the stdin that the confirmation prompts read from. - local waves=() - mapfile -t waves < <(bf_wave_groups_present || true) - for wave in "${waves[@]}"; do - [[ -n "$wave" ]] || continue - count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$wave" || true)")" - tui_screen "$(bf_op_field "$row" 2)" "wave $wave -- $count device(s)" - if ! tui_confirm_token "About to run on wave '$wave' ($count device(s))." "$wave"; then - tui_info "Wave '$wave' skipped by the operator." - tui_pause - continue - fi - BF_TARGET_KIND="group" - BF_TARGET_LIMIT="$wave" - BF_TARGET_WAVE="$wave" - BF_TARGET_COUNT="$count" - BF_TARGET_LABEL="wave '$wave' ($count device(s))" - if ! bf_run_wave_once "$row" "$wave" "$pb" "$destructive"; then - tui_error "Wave '$wave' did not run to completion; stopping the sequence (no later wave is touched)." - bf_log "STOP" "$id stopped after wave=$wave" - tui_pause - return 1 - fi - if [[ "$BF_LAST_RC" -ne 0 ]]; then - tui_error "Wave '$wave' failed (exit $BF_LAST_RC); stopping the sequence." - bf_log "STOP" "$id failed at wave=$wave" - tui_pause - return 1 - fi - tui_pause - done - return 0 - fi - - wave="${BF_TARGET_WAVE:-}" - if [[ "$scope" == "wave" && -z "$wave" ]]; then - tui_error "This playbook is wave-scoped but the target carries no approved wave." - tui_note "Pick a wave group (or a device that belongs to one)." - tui_pause - return 1 - fi - if [[ "$scope" == "release" && -z "$wave" ]]; then - tui_error "Release-gated playbooks need an approved wave group as the target." - tui_pause - return 1 - fi - - tui_screen "$(bf_op_field "$row" 2)" "$(bf_op_field "$row" 7)" - bf_show_operation_header "$row" - bf_run_wave_once "$row" "$wave" "$pb" "$destructive" || return 0 - tui_pause - return 0 -} - -# --------------------------------------------------------------------------- -# Local (on this device) command runner -# --------------------------------------------------------------------------- -# bf_local LABEL DESTRUCTIVE -- cmd... : show, confirm, run a local command. -bf_local() { - local label="$1" destructive="$2" - shift 2 - BF_CMD=("$@") - printf '\n' - tui_section "$label" - bf_confirm_and_exec "$label" "$destructive" || return 1 - return 0 -} - -# bf_show_local_cmd LABEL -- cmd... : run a read-only command without ceremony. -# The command is passed as arguments; when it is omitted, the caller's BF_CMD is -# used as-is (defensive: an accidental empty argument list must not erase it). -bf_show_local_cmd() { - local label="$1" - shift - if [[ $# -gt 0 ]]; then - BF_CMD=("$@") - fi - printf '\n' - tui_section "$label" - printf ' %s\n\n' "$(bf_cmd_string)" - if [[ "$BF_DRY_RUN" -eq 1 ]]; then - tui_warn "dry-run: the command above was NOT executed." - return 0 - fi - bf_log "RUN" "$(bf_cmd_string)" - set +e - "${BF_CMD[@]}" - BF_LAST_RC=$? - set -e - if [[ "$BF_LAST_RC" -eq 0 ]]; then - bf_log "OK" "exit=0 :: $(bf_cmd_string)" - else - tui_warn "Command exited with $BF_LAST_RC." - bf_log "FAIL" "exit=$BF_LAST_RC :: $(bf_cmd_string)" - fi - return 0 -} - -# bf_run_tool NAME [ARGS...] -- run a diagnostics tool from the preferred path. -bf_run_tool() { - local name="$1" - shift - local path - if ! path="$(bf_tool_path "$name")"; then - tui_warn "$name is not available (neither /usr/local/bin/$name nor the checkout copy)." - tui_note "Install it from this checkout: sudo install -m 0755 scripts/diagnostics/$name /usr/local/bin/$name" - return 1 - fi - bf_show_local_cmd "$name" "$path" "$@" - return 0 -} - -# --------------------------------------------------------------------------- -# Category 1 -- local installation of THIS device -# --------------------------------------------------------------------------- -bf_installer_or_error() { - local path - if ! path="$(bf_installer_path)"; then - tui_error "Installer not found: $INSTALLER_REL" - tui_note "Set --repo to the checkout that contains it, or clone the repository first:" - tui_note " git clone Blueforce-Linux && cd Blueforce-Linux" - return 1 - fi - printf '%s\n' "$path" -} - -bf_cat_install() { - local installer choice dealer offline=0 resume=0 - while true; do - tui_screen "1) Installation (this device)" "Local install only -- no fleet device is touched here" - tui_item 1 "Pre-flight check" "installer --check, writes nothing" - tui_item 2 "Install this device" "full run, asks dealer number" - tui_item 3 "Resume after a failed run" "installer --resume" - tui_item 4 "Run a single module" "installer --only" - tui_item 5 "Show module state" "/var/lib/blueforce/install-state" - tui_item 6 "Show the installer log" "tail of /var/log/blueforce-install.log" - tui_footer - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 0) return 0 ;; - q|Q) bf_quit ;; - 1) - installer="$(bf_installer_or_error)" || { tui_pause; continue; } - tui_note "Read-only pre-flight: the installer creates no log, state or directory in --check mode." - tui_read "Dealer number (8 digits) > " || bf_quit - dealer="$TUI_REPLY" - if ! bf_dealer_validate "$dealer"; then - tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE}." - tui_pause; continue - fi - BF_CMD=(bash "$installer" --dealer-id "$dealer" --check) - bf_show_local_cmd "Pre-flight check for $(bf_dealer_to_device_id "$dealer")" "${BF_CMD[@]}" - tui_pause - ;; - 2|3|4) - installer="$(bf_installer_or_error)" || { tui_pause; continue; } - bf_require_root || { tui_pause; continue; } - printf '\n' - tui_read "Dealer number (8 digits) > " || bf_quit - dealer="$TUI_REPLY" - if ! bf_dealer_validate "$dealer"; then - tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE}." - tui_pause; continue - fi - offline=0 resume=0 - if [[ "$choice" == "3" ]]; then - resume=1 - else - if tui_confirm "Install from the local offline repository (--offline)?"; then - offline=1 - fi - if [[ "$choice" == "2" ]] && tui_confirm "Resume: skip modules already recorded as OK?"; then - resume=1 - fi - fi - BF_CMD=(bash "$installer" --dealer-id "$dealer") - [[ "$offline" -eq 1 ]] && BF_CMD+=(--offline) - [[ "$resume" -eq 1 ]] && BF_CMD+=(--resume) - if [[ "$choice" == "4" ]]; then - bf_pick_module "$installer" || { tui_pause; continue; } - fi - printf '\n' - tui_info "Target device: $(bf_dealer_to_device_id "$dealer") ($(bf_dealer_to_host "$dealer"))" - bf_confirm_and_exec "install on $(bf_dealer_to_device_id "$dealer")" "yes" || true - tui_pause - ;; - 5) - BF_CMD=(cat /var/lib/blueforce/install-state) - bf_show_local_cmd "Installer module state" "${BF_CMD[@]}" - tui_pause - ;; - 6) - BF_CMD=(tail -n 40 /var/log/blueforce-install.log) - bf_show_local_cmd "Installer log (last 40 lines)" "${BF_CMD[@]}" - tui_pause - ;; - *) tui_error "Unknown choice '$choice'."; tui_pause ;; - esac - done -} - -# bf_pick_module INSTALLER -- choose one installer module and append --only. -bf_pick_module() { - local installer="$1" dir index=0 name choice i=0 pick="" modules=() - dir="$BF_REPO/scripts/install/installer/modules" - if [[ ! -d "$dir" ]]; then - tui_error "Module directory not found: $dir" - return 1 - fi - while IFS= read -r name; do - [[ -n "$name" ]] || continue - modules+=("$name") - done < <(find "$dir" -maxdepth 1 -type f -name '[0-9][0-9]-*.sh' -printf '%f\n' 2>/dev/null | sort) - if [[ ${#modules[@]} -eq 0 ]]; then - tui_error "No installer modules found under $dir" - return 1 - fi - printf '\n' - for name in "${modules[@]}"; do - index=$((index + 1)) - tui_item "$index" "${name%.sh}" "" - done - tui_footer "Cancel the module selection" - printf '\n' - tui_read "Module number > " || return 1 - choice="$TUI_REPLY" - if ! tui_menu_index "$choice" "${#modules[@]}"; then - tui_error "Enter a number between 1 and ${#modules[@]}." - return 1 - fi - for name in "${modules[@]}"; do - i=$((i + 1)) - if [[ "$i" -eq "$choice" ]]; then - pick="${name%.sh}" - break - fi - done - [[ -n "$pick" ]] || return 1 - BF_CMD+=(--only "$pick") - tui_info "Selected module: $pick" - return 0 -} - -# --------------------------------------------------------------------------- -# Category 2 -- device operations through Ansible -# --------------------------------------------------------------------------- -bf_ops_menu() { - local title="$1" subtitle="$2" - shift 2 - local rows=("$@") row index=0 choice - while true; do - tui_screen "$title" "$subtitle" - for row in "${rows[@]}"; do - index=$((index + 1)) - tui_item "$index" "$(bf_op_field "$row" 2)" "$(bf_op_field "$row" 7)" - done - tui_footer - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 0) return 0 ;; - q|Q) bf_quit ;; - *) - index=0 - if tui_menu_index "$choice" "${#rows[@]}"; then - bf_run_row "${rows[$((choice - 1))]}" - else - tui_error "Unknown choice '$choice'." - tui_pause - fi - ;; - esac - index=0 - done -} - -bf_cat_devices() { - local choice - while true; do - tui_screen "2) Device operations" "Existing playbooks only, always with an explicit target" - if ! bf_have_ansible; then - tui_warn "ansible-playbook is not installed on this machine: fleet operations will refuse to run." - fi - tui_item 1 "Read-only reports" "${#BF_OPS_REPORT[@]} operations, no change on any device" - tui_item 2 "Maintenance actions" "${#BF_OPS_MAINTENANCE[@]} operations, some destructive" - tui_item 3 "Show inventory targets" "groups, hosts and counts" - tui_footer - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 0) return 0 ;; - q|Q) bf_quit ;; - 1) bf_ops_menu "2.1) Read-only fleet reports" "No playbook in this list changes a device" "${BF_OPS_REPORT[@]}" ;; - 2) bf_ops_menu "2.2) Fleet maintenance" "Destructive operations ask twice" "${BF_OPS_MAINTENANCE[@]}" ;; - 3) bf_show_targets; tui_pause ;; - *) tui_error "Unknown choice '$choice'."; tui_pause ;; - esac - done -} - -bf_show_targets() { - if ! bf_require_inventory; then - return 1 - fi - local group count host waves=() groups=() hosts=() - mapfile -t waves < <(bf_wave_groups_present || true) - mapfile -t groups < <(bf_inventory_groups || true) - mapfile -t hosts < <(bf_inventory_hosts || true) - printf '\n' - tui_section "Waves (rollout order)" - for group in "${waves[@]}"; do - [[ -n "$group" ]] || continue - count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")" - tui_kv "$group" "$count device(s)" - done - printf '\n' - tui_section "Other groups in the inventory" - for group in "${groups[@]}"; do - [[ -n "$group" ]] || continue - bf_is_wave "$group" && continue - count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")" - tui_kv "$group" "$count device(s)" - done - printf '\n' - tui_section "Devices" - for host in "${hosts[@]}"; do - [[ -n "$host" ]] || continue - printf ' %s\n' "$host" - done - printf '\n' - tui_kv "Inventory" "$BF_INVENTORY" - tui_kv "Devices total" "$(bf_inventory_count_all)" - return 0 -} - -# --------------------------------------------------------------------------- -# Category 3 -- central side preparation (no central server is installed here) -# --------------------------------------------------------------------------- -bf_cat_central() { - local choice - while true; do - tui_screen "3) Central (preparation)" "This console prepares and verifies central integration; it installs no central server" - tui_item 1 "Central readiness report" "inventory, placeholders, admin key" - tui_item 2 "Show configured central endpoints" "WireGuard hub, monitoring, mirror" - tui_item 3 "Probe central endpoints" "DNS + TCP, read-only" - tui_item 4 "Wave rollout gate state" "per-wave device counts and gate rules" - tui_item 5 "Central preparation checklist" "what must exist before enrollment" - tui_footer - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 0) return 0 ;; - q|Q) bf_quit ;; - 1) bf_central_readiness; tui_pause ;; - 2) bf_central_endpoints; tui_pause ;; - 3) bf_central_probe; tui_pause ;; - 4) bf_wave_gate_state; tui_pause ;; - 5) bf_central_checklist; tui_pause ;; - *) tui_error "Unknown choice '$choice'."; tui_pause ;; - esac - done -} - -bf_group_vars_path() { - local p="$BF_REPO/ansible/inventory/group_vars/all.yml" - [[ -r "$p" ]] && printf '%s\n' "$p" -} - -bf_group_vars_value() { - local key="$1" file - file="$(bf_group_vars_path)" || return 1 - awk -v k="$key" ' - $0 ~ "^[[:space:]]*" k "[[:space:]]*:" { - sub(/^[^:]*:/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, "") - gsub(/^["\x27]|["\x27]$/, ""); gsub(/[[:space:]]*#.*$/, ""); print; exit - }' "$file" -} - -bf_central_readiness() { - local file placeholders keyfile - printf '\n' - tui_section "Central readiness" - if ! bf_require_inventory; then - return 1 - fi - tui_ok "Inventory found: $BF_INVENTORY ($(bf_inventory_count_all) device(s))" - - if file="$(bf_group_vars_path)"; then - placeholders="$(grep -c 'PLACEHOLDER' "$file" || true)" - if [[ "${placeholders:-0}" -gt 0 ]]; then - tui_warn "group_vars/all.yml still contains ${placeholders} PLACEHOLDER value(s):" - grep -n 'PLACEHOLDER' "$file" | sed 's/^/ /' - tui_note "Replace them before a production run; the console refuses nothing here on purpose, it reports." - else - tui_ok "No PLACEHOLDER left in group_vars/all.yml" - fi - else - tui_warn "ansible/inventory/group_vars/all.yml was not found." - fi - - keyfile="${HOME}/.ssh/blueforce_id_ed25519" - if [[ -r "$keyfile" ]]; then - tui_ok "Admin SSH key present: $keyfile (contents are never read or printed)" - else - tui_warn "Admin SSH key not found at $keyfile (docs/06)." - tui_note "The inventory may point somewhere else: ansible_ssh_private_key_file." - fi - - if grep -q 'example' "$BF_INVENTORY" 2>/dev/null || [[ "$BF_INVENTORY" == *example* ]]; then - tui_warn "This looks like an example inventory; do not aim it at real devices." - fi - return 0 -} - -bf_central_endpoints() { - local key value - printf '\n' - tui_section "Configured central endpoints" - for key in wg_subnet wg_interface wg_hub_endpoint monitoring_endpoint monitoring_job update_repo_url meg_image_tag support_bundle_dir; do - value="$(bf_group_vars_value "$key" || true)" - if [[ -n "$value" ]]; then - tui_kv "$key" "$value" - fi - done - tui_note "Values come from ansible/inventory/group_vars/all.yml; no secret is stored there or printed here." - return 0 -} - -bf_central_probe() { - local endpoint host port resolved - printf '\n' - tui_section "Central endpoint probe (read-only)" - for endpoint in "$(bf_group_vars_value wg_hub_endpoint || true)" "$(bf_group_vars_value monitoring_endpoint || true)" "$(bf_group_vars_value update_repo_url || true)"; do - [[ -n "$endpoint" ]] || continue - host="${endpoint#*//}" - host="${host%%/*}" - host="${host%%:*}" - port="" - if [[ "$endpoint" =~ :([0-9]+)$ ]]; then - port="${BASH_REMATCH[1]}" - elif [[ "$endpoint" == http://* ]]; then - port=80 - elif [[ "$endpoint" == https://* ]]; then - port=443 - fi - if [[ -z "$host" ]]; then - continue - fi - if resolved="$(getent hosts "$host" 2>/dev/null | awk 'NR==1{print $1}')" && [[ -n "$resolved" ]]; then - if [[ -n "$port" ]]; then - if timeout 3 bash -c "/dev/null; then - tui_ok "$host:$port reachable (resolves to $resolved)" - else - tui_warn "$host:$port not reachable (resolves to $resolved)" - fi - else - tui_ok "$host resolves to $resolved (no port derived from the value)" - fi - else - tui_warn "$host does not resolve -- expected while the placeholder is unfilled" - fi - done - return 0 -} - -bf_wave_gate_state() { - local wave count - printf '\n' - tui_section "Wave rollout gate state" - if ! bf_require_inventory; then - return 1 - fi - if ! bf_inventory_cache; then - tui_error "Inventory could not be parsed: $BF_INVENTORY" - return 1 - fi - for wave in "${BF_WAVES[@]}"; do - if bf_inventory_group_exists "$wave"; then - count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$wave" || true)")" - tui_kv "$wave" "$count device(s)" - else - tui_kv "$wave" "absent from the inventory" - fi - done - tui_note "Gate rule: a wave may only start after the previous wave passed its health gate and central approval (docs/10)." - tui_note "Production additionally needs the typed 'production' confirmation in this console and its override flag." - return 0 -} - -bf_central_checklist() { - printf '\n' - tui_section "Central preparation checklist (informational)" - tui_msg " 1. WireGuard hub: endpoint, keys and peer allocation for each bf- (docs/08)." - tui_msg " 2. Enrollment token service: one-time, TTL-limited tokens (docs/29)." - tui_msg " 3. Monitoring: Prometheus/VictoriaMetrics + Grafana, device_id label = BF- (docs/14)." - tui_msg " 4. Remote access: RustDesk hbbs/hbbr or MeshCentral reachable over WireGuard (docs/07)." - tui_msg " 5. Package mirror + offline snapshot for field installs (docs/10, docs/27)." - tui_msg " 6. Ansible inventory with real hostnames and a per-admin SSH key (docs/02, docs/06, docs/09)." - tui_note "This console verifies 6 and prepares 1-5; it installs no central component." - return 0 -} - -# --------------------------------------------------------------------------- -# Category 4 -- tools -# --------------------------------------------------------------------------- -bf_cat_tools() { - local choice out_dir - while true; do - tui_screen "4) Tools" "Local helpers; the ISO build is intentionally not part of this console" - tui_section "Repository" - tui_item 1 "Update this checkout" "git pull --ff-only" - tui_item 2 "Environment and reference info" "paths, versions, resolved settings" - tui_item 3 "Show the operation catalogue" "--list output" - tui_section "Local device tools" - tui_item 4 "Local offline gate" "bf-check-local" - tui_item 5 "Enrollment gate" "bf-check-enrollment" - tui_item 6 "Ready gate" "bf-check-ready" - tui_item 7 "Deep diagnostics" "bf-diagnostics (or bf-status)" - tui_item 8 "Live hardware / OS check" "bf-live-hw-check, read-only" - tui_item 9 "Remote access channels" "bf-remote-status --check" - tui_item 10 "Field OS release identity" "bf-release" - tui_item 11 "Hardware inventory" "bf-hardware-inventory.sh" - tui_item 12 "Support bundle" "bf-support-bundle --out-dir" - tui_section "This console" - tui_item 13 "Show the console log" "last 40 lines of the audit log" - tui_footer - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 0) return 0 ;; - q|Q) bf_quit ;; - 1) - BF_CMD=(git -C "$BF_REPO" pull --ff-only) - if bf_confirm_and_exec "update the checkout at $BF_REPO" "yes"; then - tui_note "The console itself may have changed; restart it after a pull." - fi - tui_pause - ;; - 2) bf_show_environment; tui_pause ;; - 3) bf_print_catalogue; tui_pause ;; - 4) bf_run_tool bf-check-local --check; tui_pause ;; - 5) bf_run_tool bf-check-enrollment --check; tui_pause ;; - 6) bf_run_tool bf-check-ready --check; tui_pause ;; - 7) - if ! bf_run_tool bf-diagnostics; then - bf_run_tool bf-status || true - fi - tui_pause - ;; - 8) bf_run_tool bf-live-hw-check; tui_pause ;; - 9) bf_run_tool bf-remote-status --check; tui_pause ;; - 10) bf_run_tool bf-release; tui_pause ;; - 11) - if [[ "$(id -u)" -eq 0 ]]; then - out_dir="/var/lib/blueforce/inventory" - else - out_dir="${XDG_STATE_HOME:-$HOME/.local/state}/blueforce/inventory" - fi - if bf_tool_path bf-hardware-inventory.sh >/dev/null 2>&1; then - tui_prompt "Dealer number (8 digits)" "" || continue - if ! bf_dealer_validate "$TUI_REPLY"; then - tui_error "Invalid dealer number; it must match ${BF_DEALER_RE}." - tui_pause; continue - fi - bf_show_local_cmd "Hardware inventory for $(bf_dealer_to_device_id "$TUI_REPLY")" \ - "$(bf_tool_path bf-hardware-inventory.sh)" --dealer-id "$TUI_REPLY" --out-dir "$out_dir" - else - tui_warn "bf-hardware-inventory.sh is not available." - fi - tui_pause - ;; - 12) - if bf_tool_path bf-support-bundle >/dev/null 2>&1; then - bf_local "Support bundle" "no" "$(bf_tool_path bf-support-bundle)" --out-dir /var/tmp - else - tui_warn "bf-support-bundle is not available." - fi - tui_pause - ;; - 13) - BF_CMD=(tail -n 40 "$BF_LOG_FILE") - bf_show_local_cmd "Console log ($BF_LOG_FILE)" "${BF_CMD[@]}" - tui_pause - ;; - *) tui_error "Unknown choice '$choice'."; tui_pause ;; - esac - done -} - -bf_show_environment() { - printf '\n' - tui_section "Environment" - tui_kv "Console" "$BF_MENU_NAME $BF_MENU_VERSION" - tui_kv "Repository" "$BF_REPO" - tui_kv "Inventory" "${BF_INVENTORY:-not resolved yet}" - tui_kv "Audit log" "$BF_LOG_FILE (mode $(stat -c '%a' "$BF_LOG_FILE" 2>/dev/null || echo '?'))" - tui_kv "dry-run" "$([[ "$BF_DRY_RUN" -eq 1 ]] && echo yes || echo no)" - tui_kv "Running as" "uid $(id -u) ($(id -un 2>/dev/null || echo unknown))" - tui_kv "Ansible" "$(command -v ansible-playbook 2>/dev/null || echo 'not installed')" - if bf_have_ansible; then - tui_kv "ansible-core" "$(ansible --version 2>/dev/null | awk 'NR==1{print $2}' || echo unknown)" - fi - tui_kv "Installer" "$(bf_installer_path || echo 'not found')" - tui_kv "Playbook dir" "$BF_REPO/$PLAYBOOK_DIR_REL" - printf '\n' - tui_section "Resolved tool paths" - for tool in bf-status bf-diagnostics bf-check-local bf-check-enrollment bf-check-ready bf-release bf-remote-status bf-support-bundle bf-hardware-inventory.sh; do - tui_kv "$tool" "$(bf_tool_path "$tool" || echo 'not available')" - done - return 0 -} - -bf_print_catalogue() { - local row index - printf '\n' - tui_section "Category 2.1 -- read-only fleet reports" - index=0 - for row in "${BF_OPS_REPORT[@]}"; do - index=$((index + 1)) - printf ' %2s) %-34s %-36s scope=%-8s destructive=%s\n' "$index" "$(bf_op_field "$row" 1)" \ - "$(bf_op_field "$row" 3)" "$(bf_op_field "$row" 4)" "$(bf_op_field "$row" 5)" - done - printf '\n' - tui_section "Category 2.2 -- fleet maintenance" - index=0 - for row in "${BF_OPS_MAINTENANCE[@]}"; do - index=$((index + 1)) - printf ' %2s) %-34s %-36s scope=%-8s destructive=%s\n' "$index" "$(bf_op_field "$row" 1)" \ - "$(bf_op_field "$row" 3)" "$(bf_op_field "$row" 4)" "$(bf_op_field "$row" 5)" - done - printf '\n' - tui_note "scope=wave needs -e operation_wave= -e operation_confirmed=true (set by this console after confirmation)." - tui_note "scope=release needs -e release_wave= plus the wave gate typed confirmation." - return 0 -} - -# --------------------------------------------------------------------------- -# Category 5 -- device information -# --------------------------------------------------------------------------- -bf_cat_info() { - local choice - while true; do - tui_screen "5) Device information" "Read-only: nothing here changes a device" - tui_section "This device" - tui_item 1 "Identity" "hostname, device id, dealer number" - tui_item 2 "Health summary" "bf-status" - tui_item 3 "Provisioning and enrollment state" "state.json whitelist + bf-enrollment-status" - tui_item 4 "Network" "addresses, route, DNS, WireGuard peers" - tui_item 5 "Disk, memory, CPU" "df, free, load" - tui_item 6 "Installed pinned packages" "dpkg-query" - tui_item 7 "Service status" "ssh, xrdp, rustdesk, docker, wg, meshagent" - tui_item 8 "Recent journal errors" "journalctl -p err" - tui_section "The fleet" - tui_item 9 "Inventory summary" "waves, groups, counts" - tui_item 10 "Resolve a target" "check a dealer number or group" - tui_footer - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 0) return 0 ;; - q|Q) bf_quit ;; - 1) bf_info_identity; tui_pause ;; - 2) bf_run_tool bf-status; tui_pause ;; - 3) bf_info_state; tui_pause ;; - 4) bf_info_network; tui_pause ;; - 5) bf_info_resources; tui_pause ;; - 6) bf_info_packages; tui_pause ;; - 7) bf_info_services; tui_pause ;; - 8) bf_info_journal; tui_pause ;; - 9) bf_show_targets; tui_pause ;; - 10) bf_info_resolve_target; tui_pause ;; - *) tui_error "Unknown choice '$choice'."; tui_pause ;; - esac - done -} - -bf_info_identity() { - local host dealer device_id - host="$(hostname 2>/dev/null || echo unknown)" - printf '\n' - tui_section "Identity" - tui_kv "Hostname (machine)" "$host" - if dealer="$(bf_host_to_dealer "$host")"; then - tui_kv "Dealer number" "$dealer" - tui_kv "Device ID (human)" "$(bf_dealer_to_device_id "$dealer")" - else - tui_warn "Hostname is not bf-<8 digits>; it does not match the naming standard (docs/02)." - tui_note "The installer sets it from --dealer-id: sudo $BF_MENU_NAME -> category 1" - fi - if [[ -r /var/lib/blueforce/state.json ]]; then - device_id="$(bf_state_value device_id || true)" - [[ -n "$device_id" ]] && tui_kv "state.json device_id" "$device_id" - fi - tui_kv "Kernel" "$(uname -sr 2>/dev/null || echo unknown)" - tui_kv "Console" "$BF_MENU_NAME $BF_MENU_VERSION" - return 0 -} - -# bf_state_value KEY -- read one whitelisted key from /var/lib/blueforce/state.json. -# Only non-secret identity/lifecycle fields are ever extracted: the file is read -# through a whitelist so a future token field can never leak into the UI or log. -BF_STATE_ALLOWED_KEYS="phase enrollment_status fleet_status device_id provisioning_id" -bf_state_value() { - local key="$1" state="/var/lib/blueforce/state.json" - [[ -r "$state" ]] || return 1 - [[ " $BF_STATE_ALLOWED_KEYS " == *" $key "* ]] || return 1 - if command -v python3 >/dev/null 2>&1; then - python3 -c ' -import json, sys -try: - with open(sys.argv[1], encoding="utf-8") as handle: - data = json.load(handle) -except Exception: - sys.exit(1) -if not isinstance(data, dict): - sys.exit(1) -value = data.get(sys.argv[2]) -if value is None or isinstance(value, bool): - sys.exit(1) -sys.stdout.write(str(value) + "\n") -' "$state" "$key" 2>/dev/null || return 1 - return 0 - fi - awk -v pattern="\"$key\"" ' - index($0, pattern) { - line = $0 - sub(/^[^:]*:[[:space:]]*/, "", line) - gsub(/^"|"[,]?$/, "", line) - print line; exit - }' "$state" || return 1 - return 0 -} - -bf_info_state() { - local key value - printf '\n' - tui_section "Provisioning and enrollment state" - if [[ -r /var/lib/blueforce/state.json ]]; then - for key in phase enrollment_status fleet_status device_id provisioning_id; do - value="$(bf_state_value "$key" || true)" - [[ -n "$value" ]] && tui_kv "$key" "$value" - done - tui_note "Only the identity/lifecycle whitelist above is read; tokens and keys are never printed." - else - tui_warn "/var/lib/blueforce/state.json is missing: the device has not been provisioned by the installer." - fi - printf '\n' - printf ' %sProvisioning : PROVISIONED_OFFLINE -> Enrollment : ENROLLED -> Fleet State : READY%s\n' "$C_DIM" "$C_RESET" - tui_note "Forward-only state machine; READY requires verified central evidence (docs/29)." - return 0 -} - -bf_info_network() { - printf '\n' - tui_section "Network" - if command -v ip >/dev/null 2>&1; then - bf_show_local_cmd "Interfaces" ip -brief address - fi - if command -v ip >/dev/null 2>&1; then - bf_show_local_cmd "Default routes" ip route show default - fi - if [[ -r /etc/resolv.conf ]]; then - bf_show_local_cmd "DNS servers (nameserver lines only)" awk '/^nameserver/{print}' /etc/resolv.conf - fi - if command -v wg >/dev/null 2>&1; then - bf_show_local_cmd "WireGuard listen port" wg show wg0 listen-port - bf_show_local_cmd "WireGuard peer count" sh -c 'wg show wg0 peers | wc -l' - bf_show_local_cmd "WireGuard handshakes (epoch, no keys)" wg show wg0 latest-handshakes - else - tui_note "wg is not installed; WireGuard channels are not configured on this device." - fi - tui_note "WireGuard private and preshared keys are never requested, displayed or logged." - return 0 -} - -bf_info_resources() { - printf '\n' - tui_section "Resources" - bf_show_local_cmd "Filesystems" df -h - bf_show_local_cmd "Memory" free -h - bf_show_local_cmd "Load average and uptime" uptime - return 0 -} - -bf_info_packages() { - printf '\n' - tui_section "Installed versions" - if command -v dpkg-query >/dev/null 2>&1; then - # shellcheck disable=SC2016 # dpkg field syntax, not a shell expansion - bf_show_local_cmd "Pinned packages" dpkg-query -W -f='${Package} ${Version} ${Status}\n' \ - docker-ce wireguard rustdesk xrdp openssh-server - else - tui_warn "dpkg-query is not available on this machine." - fi - return 0 -} - -bf_info_services() { - printf '\n' - tui_section "Service status (read-only)" - if ! command -v systemctl >/dev/null 2>&1; then - tui_warn "systemctl is not available on this machine." - return 0 - fi - bf_show_local_cmd "Units" systemctl is-active ssh sshd xrdp rustdesk docker wg-quick@wg0 meshagent cron - return 0 -} - -bf_info_journal() { - printf '\n' - tui_section "Recent journal errors" - if command -v journalctl >/dev/null 2>&1; then - bf_show_local_cmd "Errors (last 20)" journalctl -p err -n 20 --no-pager - else - tui_warn "journalctl is not available on this machine." - fi - return 0 -} - -bf_info_resolve_target() { - local word mode="any" answer - printf '\n' - tui_section "Resolve a target" - tui_note "Accepts a dealer number, a group name, 'all' or a bf- hostname." - tui_read "Target > " || return 1 - word="$TUI_REPLY" - if [[ -z "$word" ]]; then - return 1 - fi - if ! bf_require_inventory; then - return 1 - fi - if tui_confirm "Is this target for a wave-scoped operation?"; then - mode="wave" - fi - answer="$word" - if bf_target_parse "$answer" "$mode"; then - printf '\n' - tui_kv "Kind" "$BF_TARGET_KIND" - tui_kv "Ansible --limit" "$BF_TARGET_LIMIT" - tui_kv "Wave" "${BF_TARGET_WAVE:-none}" - tui_kv "Devices" "$BF_TARGET_COUNT" - printf '\n' - tui_note "Example: ansible-playbook -i $BF_INVENTORY $BF_REPO/$PLAYBOOK_DIR_REL/bf-ping.yml --limit $BF_TARGET_LIMIT" - else - tui_warn "That target cannot be used." - fi - return 0 -} - -# --------------------------------------------------------------------------- -# Main menu -# --------------------------------------------------------------------------- -bf_main_menu() { - local choice - while true; do - tui_screen "Blueforce operator console $BF_MENU_VERSION" "repo: $BF_REPO" - if [[ "$BF_DRY_RUN" -eq 1 ]]; then - tui_warn "DRY-RUN MODE: commands are printed, never executed." - fi - if ! bf_have_ansible; then - tui_warn "ansible-playbook not found: categories 2, 3 and 5-fleet need Ansible." - fi - tui_section "Categories" - tui_item 1 "Installation" "this device, local one-click installer" - tui_item 2 "Device operations" "fleet playbooks, explicit target" - tui_item 3 "Central (preparation)" "verify and prepare central integration" - tui_item 4 "Tools" "repository and local helpers" - tui_item 5 "Device information" "read-only information screens" - printf '\n' - tui_hr '-' - printf ' %s%2s%s) %s\n' "$C_BOLD" "q" "$C_RESET" "Quit the console" - printf ' %sLog:%s %s\n' "$C_DIM" "$C_RESET" "$BF_LOG_FILE" - tui_hr '-' - if ! tui_read "Choice > "; then bf_quit; fi - choice="$TUI_REPLY" - case "$choice" in - 1) bf_cat_install ;; - 2) bf_cat_devices ;; - 3) bf_cat_central ;; - 4) bf_cat_tools ;; - 5) bf_cat_info ;; - q|Q) bf_quit ;; - 0) tui_note "Already at the top level."; tui_pause ;; - *) tui_error "Unknown choice '$choice'."; tui_pause ;; - esac - done -} - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- -main() { - bf_parse_args "$@" - BF_LOG_REQUESTED="$BF_LOG_FILE" - bf_resolve_repo - trap bf_cleanup EXIT - # shellcheck source=lib/tui.sh disable=SC1091 - source "$BF_REPO/admin/lib/tui.sh" - tui_init_colors "${TUI_COLOR_MODE:-auto}" - # shellcheck source=lib/targets.sh disable=SC1091 - source "$BF_REPO/admin/lib/targets.sh" - bf_log_init - - if [[ "$BF_LIST_TARGETS" -eq 1 ]]; then - bf_show_targets - exit $? - fi - if [[ "$BF_LIST" -eq 1 ]]; then - bf_print_catalogue - exit 0 - fi - - bf_log "SESSION" "$BF_MENU_NAME $BF_MENU_VERSION start uid=$(id -u) repo=$BF_REPO dry_run=$BF_DRY_RUN" - if ! bf_require_inventory; then - tui_warn "Starting without a usable inventory: fleet categories will explain what is missing." - BF_INVENTORY="" - fi - bf_main_menu -} - -main "$@" - - - -# Blueforce operator console (`bf-menu`) - -Numbered terminal console for the Blueforce field fleet. One entry point for the -operator: no command has to be memorised, every command is printed before it -runs, and the dangerous ones ask twice. - -Three layers, one screen: - -| Layer | What it is | Where | -|---|---|---| -| Local install | This device, brought to the Field OS standard | category **1** | -| Fleet operations | The repository's existing Ansible playbooks, always with an explicit `--limit` | category **2** | -| Read-only information | Device and fleet state, no change anywhere | categories **3, 4, 5** | - -The console is a thin front end. It does not reimplement installer logic, does -not invent playbooks, and does not talk to devices itself: it selects the right -`bf-*` tool, playbook and target, then executes exactly that. - ---- - -## 1. Requirements - -- Ubuntu LTS field device or admin workstation (the console is plain bash 4+). -- `git` for the checkout; `ansible-core`/`ansible` **only** on the machine that - drives fleet operations (categories 2 and 3). A field device that only installs - itself needs neither Ansible nor an inventory. -- `sudo` for category 1 (local install) and for writing to - `/var/log/blueforce-console.log`. -- Optional, per category: `python3` (reads `state.json`), `wireguard-tools` - (`wg`), `systemd`, `dpkg`. - -## 2. Quick start - -```bash -git clone Blueforce-Linux -cd Blueforce-Linux - -sudo admin/bf-bootstrap.sh # optional: links the tools into /usr/local/bin -sudo admin/bf-menu # or: sudo bf-menu, once bootstrap ran -``` - -An install is `git`-based on purpose: the running revision is traceable with -`git log`, and the device identity (dealer number) is never baked into an image -— it is typed in the first console session. ISO/firstboot remain a secondary, -optional path (`docs/26`, `docs/27`). - -Options: - -``` --h, --help usage --V, --version console version - --repo PATH repository root (default: parent of this script, symlinks resolved) - --inventory PATH Ansible inventory (default: /ansible/inventory/hosts.yml) - --log-file PATH audit log (default: /var/log/blueforce-console.log) - --dry-run print every command instead of running it - --no-color disable ANSI colors (NO_COLOR is honored too) - --list print the operation catalogue and exit (non-interactive) - --list-targets print inventory groups and host counts, then exit -``` - -Environment: `BF_REPO`, `BF_INVENTORY`, `BF_CONSOLE_LOG` mirror the options. - -Exit status: `0` success or clean quit, `1` failure, `2` usage error. - -## 3. Menu map - -`0` is **Back** on every screen, `q` is **Quit** on every screen. The console -never changes anything from a menu that only displays information. - -**1) Installation — this device, locally** - -| # | Action | Under the hood | -|---|---|---| -| 1 | Pre-flight check | `blueforce-install.sh --dealer-id --check` (writes nothing) | -| 2 | Install this device | `--dealer-id [--offline] [--resume]` | -| 3 | Resume a failed run | `--dealer-id --resume` | -| 4 | Run a single module | `--dealer-id --only ` | -| 5 | Show module state | `/var/lib/blueforce/install-state` | -| 6 | Show the installer log | `tail -n 40 /var/log/blueforce-install.log` | - -Dealer numbers are validated against `^[0-9]{8}$` before anything runs; a wrong -format stops the flow immediately. Actions 2–4 require root and will say so. - -**2) Device operations — the fleet, via Ansible** - -*2.1 Read-only reports* (11 operations, nothing is changed): - -| # | Operation | Playbook | -|---|---|---| -| 1 | Ping / reachability | `bf-ping.yml` | -| 2 | Uptime | `bf-uptime.yml` | -| 3 | Disk usage | `bf-disk-check.yml` | -| 4 | Docker status | `bf-docker-status.yml` | -| 5 | Package versions | `bf-package-check.yml` | -| 6 | Pending security updates | `bf-security-updates-check.yml` | -| 7 | Provisioning phase report | `bf-provisioning-status.yml` | -| 8 | Enrollment + central evidence | `bf-enrollment-status.yml` | -| 9 | Field OS version report | `bf-fieldos-version.yml` | -| 10 | Remote access channels | `bf-remote-channels-check.yml` | -| 11 | Offline readiness report | `bf-offline-ready-check.yml` | - -*2.2 Maintenance* (10 operations, destructive ones ask twice): - -| # | Operation | Playbook | Scope | -|---|---|---|---| -| 1 | Restart RDP | `bf-rdp-restart.yml` | simple | -| 2 | Restart RustDesk | `bf-rustdesk-restart.yml` | simple | -| 3 | Restart WireGuard | `bf-wireguard-restart.yml` | simple | -| 4 | Restart an allowlisted service | `bf-service-restart.yml` | simple | -| 5 | GUI off (headless) | `bf-gui-off.yml` | wave | -| 6 | GUI on (maintenance) | `bf-gui-on.yml` | wave | -| 7 | Collect logs / support bundle | `bf-collect-logs.yml` | wave | -| 8 | Run an approved script | `bf-run-script.yml` | wave | -| 9 | Controlled reboot | `bf-reboot.yml` | release | -| 10 | Deploy approved update | `bf-deploy-update.yml` | release | - -*2.3 Show inventory targets* lists the waves with their device counts. - -The console cross-checks its own table against the playbook file before running -(see §5); a playbook whose gates changed refuses to run rather than run ungated. - -**3) Central (preparation)** — verifies and prepares central integration; it -installs no central component. Central readiness report (inventory, placeholders -in `group_vars/all.yml`, admin key path), configured endpoints, a read-only -DNS/TCP probe of those endpoints, the per-wave gate state, and the preparation -checklist. - -**4) Tools** — `git pull --ff-only` of the checkout, environment/reference info, -the operation catalogue, the local gates (`bf-check-local`, `bf-check-enrollment`, -`bf-check-ready`), `bf-diagnostics`, `bf-live-hw-check`, `bf-remote-status --check`, -`bf-release`, `bf-hardware-inventory.sh`, `bf-support-bundle`, and the console's -own log. - -**5) Device information** — identity (hostname, `BF-`, dealer number), -`bf-status`, provisioning/enrollment state, network (addresses, routes, DNS, -WireGuard **peer counts and handshake ages**), disk/memory/CPU, installed pinned -packages, service status, recent journal errors, plus the fleet inventory summary -and a "resolve a target" helper that shows the exact `--limit` for copy/paste. - -## 4. Target selection - -Every device operation asks for a target first — never a default: - -``` -Target selection (mode: any|wave|release) - 1) Single device by dealer number -> bf-<8 digits> - 2) Group / wave -> lab, pilot_1, pilot_2, wave_1, wave_2, production - 3) All devices in the fleet -> --limit all - 9) Type the target directly -> dealer number, group, hostname or 'all' - 0) Back q) Quit -``` - -Rules the console enforces: - -- **Single device** — the dealer number must match `^[0-9]{8}$` and the device - must exist in the inventory. The wave is derived from the inventory, so a - wave-scoped playbook gets `-e operation_wave=`. A device outside - every wave cannot be used for a wave-scoped operation. -- **Group / wave** — the group must exist in the inventory. For wave-scoped and - release-gated operations only the six approved waves are offered. -- **All devices** — allowed for simple and wave-scoped playbooks. It shows how - many devices are affected and requires typing `all` before anything runs. For - wave-scoped playbooks the fleet is processed **one wave at a time** in - `lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production`, each wave shown and - confirmed on its own, stopping at the first failure so no later wave is touched. -- **Release-gated playbooks** (`bf-reboot.yml`, `bf-deploy-update.yml`) accept an - approved wave group only: no single device, no fleet-wide run. That is the - playbooks' own gate (docs/10) and the console does not work around it. - -## 5. What gets added to a command - -The console derives the gate variables from the playbook file itself (its -non-comment lines), so the table in the menu can never silently drop a gate: - -| Scope | Detected by | Variables added | -|---|---|---| -| `simple` | neither wave variable | only `--limit` | -| `wave` | uses `operation_wave` | `-e operation_wave= -e operation_confirmed=true`, plus `-e production_override=true` for the production wave | -| `release` | uses `release_wave` | `-e release_wave=`, `-e reboot_confirmed=true` / `-e wave_gate_confirmed=true` when the playbook declares them, plus the production override for the production wave | - -Confirmations, in order: - -1. the target and the full command are printed; -2. for `release` scope, the **wave gate** must be typed as `GATE` (the console - never sets `wave_gate_confirmed=true` on its own — only after the previous - wave's health gate and central approval, docs/10); -3. for the production wave, the word `production` must be typed; -4. for fleet-wide scope, the word `all` must be typed; -5. `y` runs, `c` runs with `--check` (dry run of the playbook), anything else cancels. - -Extra variables are validated with the same rules the playbooks enforce, so the -operator gets a clear message instead of an Ansible assertion failure: package -pins must be `package=version`; service names must be in the playbook allowlist; -script paths must be inside `/opt/blueforce/bin` or `/usr/local/sbin`; the reboot -grace period must be a whole number of seconds. - -## 6. Dry runs - -- `--dry-run` prints every command and executes nothing. -- Answering `c` at the run prompt executes the command with `--check` appended, - which is a real Ansible dry run. -- Category 1's pre-flight already is the installer's own `--check`. - -## 7. Audit log - -Default `/var/log/blueforce-console.log`, created with mode **600** (it is -re-`chmod`ed to 600 if it already exists). If it is not writable, the console -warns and falls back to `$XDG_STATE_HOME/blueforce/blueforce-console.log`; it -never logs into a world-readable file. - -Each run records one line per event: session start, target, the exact command, -and the exit status. It does **not** store raw playbook output: tool output may -contain whatever a device script prints, and copying it into a log is how a -secret ends up on disk. Output is shown on screen and stays there. - -Before anything reaches the log it passes a single sanitizer that redacts -`password=`, `token=`, `secret=`, `api_key=`, `private_key=`, `passphrase=`, -`psk=` and `BEGIN … PRIVATE KEY` payloads. The console itself never asks for a -secret: no password, token or private key is typed into or stored by it. - -## 8. Security notes - -- **No secret material is read, printed or logged.** The console never runs bare - `wg show` (private and preshared keys stay off the screen) — only the listen - port, peer count and handshake ages. -- **`state.json` is read through a whitelist** of non-secret keys (`phase`, - `enrollment_status`, `fleet_status`, `device_id`, `provisioning_id`). A future - token field cannot leak into the UI or the log. -- **No ungated update.** There is no `apt upgrade`, no `latest` pull and no - free-form command anywhere in the menu; updates go through the release-gated - playbook or not at all (docs/10). -- **Destructive actions ask twice**: a warning plus an explicit `y`, and the - highest-risk scopes need a typed word (`GATE`, `production`, `all`, or the wave - name in a fleet-wide loop). -- **Fail-closed on drift**: if the playbook's declared gates do not match the - console's expectations, the console refuses to run that playbook at all. -- **Root is used where the underlying tool needs it** (local installer, support - bundle). Read-only information screens are careful about what they read. -- The console **never edits the inventory**. Adding a device stays a reviewed - edit of `ansible/inventory/hosts.yml`: - -```yaml - pilot_1: - hosts: - bf-12010193: - ansible_host: 10.42.1.20 -``` - -## 9. Deliberately out of scope - -- **No distro/ISO build.** ISO and firstboot remain a secondary path (`docs/26`, - `docs/27`); the primary install path is `git clone` + install. Category 4 does - not offer `provisioning/iso/build-blueforce-iso.sh`. -- **No password generation.** Credential files are the separate, optional - `admin/bf-creds` tool and default to **off**; the console does not call it. -- **No central server installation.** Category 3 verifies and prepares; - installing the hub, monitoring or the token service is infrastructure work. -- **No new playbooks, no free-form commands.** If an operation is not one of the - 21 existing playbooks, the console does not fake it. - -## 10. Troubleshooting - -| Symptom | Why | What to do | -|---|---|---| -| `ansible-playbook was not found on this machine` | categories 2/3 need Ansible on the driving machine | `sudo apt install ansible` (or `pipx install ansible-core`), or run the command from another admin host | -| `No Ansible inventory found under …` | no `ansible/inventory/hosts.yml` yet | `cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml`, then fill in real `bf-` hosts | -| Only `hosts.example.yml` exists | it holds example IDs and is never used automatically | copy it and replace the example devices | -| `Invalid dealer number ''` | must be exactly 8 digits | type the number as printed on the device record, leading zeros included | -| `bf- is not in the inventory` | the device is not registered | add it to the right wave group (§8), then retry | -| `… is not an approved wave` | a wave-scoped or release-gated operation got a non-wave group | pick one of `lab, pilot_1, pilot_2, wave_1, wave_2, production` | -| `Refusing to run : the console expects scope …` | the playbook's gates changed since the console table was written | update `admin/bf-menu` before using that playbook | -| `This operation needs root on THIS device` | local install runs as root | `sudo bf-menu` | -| Log warning about `/var/log/blueforce-console.log` | not writable, another admin owns it, or the mode is wrong | run with `sudo`, or pass `--log-file ` | - -## 11. Files - -| File | Role | -|---|---| -| `admin/bf-menu` | entry point: menu screens, operation catalogue, command building, confirmations, audit log | -| `admin/lib/tui.sh` | presentation and input helpers (screens, items, prompts, confirmations, messages) | -| `admin/lib/targets.sh` | device identity, inventory parsing, target selection, `--limit` construction | - -Library contract: `bf-menu` sets `BF_REPO`/`BF_INVENTORY` and sources both -libraries; `targets.sh` fills `BF_TARGET_KIND`, `BF_TARGET_LIMIT`, -`BF_TARGET_WAVE`, `BF_TARGET_COUNT`, `BF_TARGET_LABEL` and never renders UI or -executes anything; `tui.sh` never exits the process except in `tui_die`. - -Both libraries are safe to source on their own, which is how the target logic and -the inventory parser can be tested without a terminal. - -## 12. Verifying a change - -```bash -bash -n admin/bf-menu admin/lib/tui.sh admin/lib/targets.sh -shellcheck -x admin/bf-menu admin/lib/tui.sh admin/lib/targets.sh -admin/bf-menu --list # catalogue, no inventory needed -admin/bf-menu --list-targets # groups, hosts and counts -``` - -`--list` and `--list-targets` are the non-interactive entry points: they exercise -the catalogue and the inventory parser without touching a device. - -The console was checked on an admin workstation with a stub `ansible-playbook` -that echoes its arguments, covering: every scope/target combination and the exact -`-e` variables produced, the refusal paths (single device for release scope, -fleet-wide release run, wrong wave for a wave-scoped run, bad dealer number, -unknown device, bad package pin, service outside the allowlist, script outside -the approved directories, production/gate/fleet-wide words withheld), `--dry-run` -executing nothing, `--check` being appended, the missing-Ansible and -missing-inventory explanations, the log's 600 mode and its redaction of a test -secret, and clean EOF behaviour on a closed stdin. Playbook coverage was -cross-checked programmatically: all 21 playbooks in `ansible/playbooks/` appear -exactly once and every declared scope matches the playbook's own gate variables. -Real devices have not been touched yet — the LAB run is what turns these -expectations into evidence. - - # Shared variables for all Blueforce fleet hosts. # Fill placeholders before first production run. @@ -11826,276 +3552,6 @@ all: failed_when: reboot_health.rc != 0 - ---- -# Blueforce uzak erişim kanalı raporu -- READ-ONLY, dalga kapsamlı. -# Ne yapar: SSH, xRDP, RustDesk, MeshCentral ve WireGuard kanallarının birim (unit) durumunu -# raporlar; MeshCentral ve AnyDesk OPSİYONELDİR (AnyDesk kurulu değilse açıkça -# "NOT INSTALLED (OPTIONAL)" yazılır). Kanonik /usr/local/bin/bf-remote-status --check -# çıktısı bağımsız doğrulama olarak eklenir (docs/07, docs/11 K-04). -# Hiçbir görev durum değiştirmez: yalnız stat ve read-only CLI çıktısı okunur; tüm command -# görevleri changed_when: false taşır. -# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-remote-channels-check.yml -e operation_wave=lab -e operation_confirmed=true --check -# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-remote-channels-check.yml -e operation_wave=lab -e operation_confirmed=true -# Semaphore Pro bağımlılığı yok. -# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, -# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). -- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) - hosts: "{{ ansible_limit | default('localhost') }}" - gather_facts: false - become: false - vars: - approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] - production_override: false - operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" - tasks: - - name: Require an approved confirmed operation wave before any host is touched - ansible.builtin.assert: - that: - - operation_wave_gate in approved_operation_waves - - operation_confirmed | default(false) | bool - - operation_wave_gate in groups - - groups[operation_wave_gate] | length > 0 - - (operation_wave_gate != 'production') or (production_override | default(false) | bool) - fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." - run_once: true - - - name: Require the --limit selection to intersect the declared wave - ansible.builtin.assert: - that: - - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) - or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) - fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." - when: - - (ansible_limit | default('') | length) > 0 - - not (ansible_limit | default('') is search('[][*?]')) - run_once: true - -- name: Uzak erişim kanallarının durumunu raporla (read-only) - hosts: "{{ operation_wave | default('invalid-wave', true) }}" - gather_facts: false - become: true # Yalnız wg show ve root okuma gerektiren kanıtı (birim/state) OKUMAK için; mutasyon yok. - vars: - approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] - production_override: false - remote_channel_units: [ssh, sshd, xrdp, xrdp-sesman, rustdesk, meshagent, wg-quick@wg0] - remote_channel_props: [is-enabled, is-active] - remote_wg_interface: "{{ wg_interface | default('wg0') }}" - remote_wg_handshake_max_age: "{{ wg_handshake_max_age_seconds | default(180) }}" - remote_rustdesk_system_config: /etc/rustdesk/RustDesk2.toml - remote_rustdesk_user_config: /home/blueforce/.config/RustDesk/RustDesk2.toml - remote_mesh_config: /etc/meshagent/meshagent.json - remote_status_bin: /usr/local/bin/bf-remote-status - pre_tasks: - - name: Require confirmed approved operation wave scope - ansible.builtin.assert: - that: - - operation_wave in approved_operation_waves - - operation_confirmed | bool - - operation_wave in groups - - groups[operation_wave] | length > 0 - - ansible_play_hosts_all | length > 0 - - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 - fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." - run_once: true - - - name: Require separate production override - ansible.builtin.assert: - that: - - operation_wave != 'production' or production_override | bool - fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." - run_once: true - tasks: - - name: Kanal yapılandırma dosyalarını denetle (read-only) - ansible.builtin.stat: - path: "{{ item }}" - loop: - - "{{ remote_rustdesk_system_config }}" - - "{{ remote_rustdesk_user_config }}" - - "{{ remote_mesh_config }}" - - "{{ remote_status_bin }}" - register: remote_channel_files - - - name: Yapılandırma durumlarını yol ile indeksle - ansible.builtin.set_fact: - remote_channel_files_by_path: "{{ remote_channel_files.results | items2dict(key_name='item', value_name='stat') }}" - - - name: Birim etkin/aktif durumlarını oku (systemctl, read-only) - ansible.builtin.command: - argv: - - systemctl - - "{{ item[1] }}" - - --quiet - - "{{ item[0] }}" - loop: "{{ remote_channel_units | product(remote_channel_props) | list }}" - register: remote_channel_probe - changed_when: false - failed_when: false - - - name: Birim durumlarını tek sözlükte topla - ansible.builtin.set_fact: - bf_unit_states: "{{ bf_unit_states | default({}) | combine({ item.item[0] ~ ':' ~ item.item[1]: (item.rc == 0) }) }}" - loop: "{{ remote_channel_probe.results }}" - - - name: RustDesk paket durumunu oku (read-only) - ansible.builtin.command: - argv: - - dpkg-query - - -W - - -f=${Status} - - rustdesk - register: remote_rustdesk_pkg - changed_when: false - failed_when: false - - - name: AnyDesk (opsiyonel) paket durumunu oku (read-only) - ansible.builtin.command: - argv: - - dpkg-query - - -W - - -f=${Status} - - anydesk - register: remote_anydesk_pkg - changed_when: false - failed_when: false - - - name: WireGuard son handshake kaydını oku (read-only) - ansible.builtin.command: - argv: - - wg - - show - - "{{ remote_wg_interface }}" - - latest-handshakes - register: remote_wg_handshakes - changed_when: false - failed_when: false - - - name: Kontrol saatinin epoch değerini oku (read-only) - ansible.builtin.command: date +%s - register: remote_now_epoch - changed_when: false - - - name: Kanonik uzak erişim doğrulamasını çalıştır (bf-remote-status --check, read-only) - ansible.builtin.command: "{{ remote_status_bin }} --check" - register: remote_status_cli - changed_when: false - failed_when: false - when: remote_channel_files_by_path[remote_status_bin].exists | default(false) - - - name: Kanal hükümlerini hesapla - ansible.builtin.set_fact: - bf_ch_ssh: >- - {{ 'enabled-active' - if ((bf_unit_states.get('ssh:is-enabled', false) | bool and bf_unit_states.get('ssh:is-active', false) | bool) - or (bf_unit_states.get('sshd:is-enabled', false) | bool and bf_unit_states.get('sshd:is-active', false) | bool)) - else 'not-ready' }} - bf_ch_xrdp: >- - {{ 'enabled-active' - if (bf_unit_states.get('xrdp:is-enabled', false) | bool and bf_unit_states.get('xrdp:is-active', false) | bool - and bf_unit_states.get('xrdp-sesman:is-enabled', false) | bool and bf_unit_states.get('xrdp-sesman:is-active', false) | bool) - else 'not-ready' }} - bf_ch_rustdesk: >- - {{ 'installed-active' - if (remote_rustdesk_pkg.rc | default(1) == 0 and bf_unit_states.get('rustdesk:is-active', false) | bool) - else ('not-installed' if remote_rustdesk_pkg.rc | default(1) != 0 else 'not-ready') }} - bf_ch_meshcentral: >- - {{ 'not-configured' if not (remote_channel_files_by_path[remote_mesh_config].exists | default(false)) - else ('configured-active' - if (bf_unit_states.get('meshagent:is-enabled', false) | bool and bf_unit_states.get('meshagent:is-active', false) | bool) - else 'not-ready') }} - bf_ch_anydesk: "{{ 'installed (optional)' if remote_anydesk_pkg.rc | default(1) == 0 else 'NOT INSTALLED (OPTIONAL)' }}" - bf_ch_rustdesk_config: >- - {{ 'configured' if (remote_channel_files_by_path[remote_rustdesk_system_config].exists | default(false) - and remote_channel_files_by_path[remote_rustdesk_user_config].exists | default(false)) - else 'not-configured' }} - bf_ch_canonical_ready: "{{ 'ALL-READY' if remote_status_cli.rc | default(1) == 0 else 'NOT-READY' }}" - - - name: WireGuard handshake epoch listesini çıkar (read-only kanıt) - ansible.builtin.set_fact: - bf_wg_epochs: "{{ remote_wg_handshakes.stdout | default('') | regex_findall('(?m)([0-9]+)$') | map('int') | list }}" - - - name: WireGuard en yeni handshake kaydını belirle - ansible.builtin.set_fact: - bf_wg_latest_epoch: "{{ (bf_wg_epochs | max | int) if bf_wg_epochs | length > 0 else 0 }}" - - - name: WireGuard handshake yaşını hesapla - ansible.builtin.set_fact: - bf_wg_handshake_age: "{{ (remote_now_epoch.stdout | default('0') | int) - (bf_wg_latest_epoch | int) }}" - - - name: WireGuard kanal hükmünü yaz - ansible.builtin.set_fact: - bf_ch_wireguard: >- - {{ 'no-handshake' if bf_wg_latest_epoch | int <= 0 - else ('fresh-handshake' if bf_wg_handshake_age | int <= remote_wg_handshake_max_age | int else 'stale-handshake') }} - - - name: Zorunlu kanal hazır/ready satırını yaz - ansible.builtin.set_fact: - bf_required_channel_line: >- - ssh:{{ 'READY' if bf_ch_ssh == 'enabled-active' else 'NOT-READY' }} - xrdp:{{ 'READY' if bf_ch_xrdp == 'enabled-active' else 'NOT-READY' }} - rustdesk:{{ 'READY' if bf_ch_rustdesk == 'installed-active' else 'NOT-READY' }} - wireguard:{{ 'READY' if bf_ch_wireguard == 'fresh-handshake' else 'NOT-READY' }} - - - name: Zorunlu kanalların hazır durumunu ve sayısını belirle - ansible.builtin.set_fact: - bf_required_channel_ready: "{{ [bf_ch_ssh == 'enabled-active', bf_ch_xrdp == 'enabled-active', bf_ch_rustdesk == 'installed-active', bf_ch_wireguard == 'fresh-handshake'] | select | list | length }}" - - - name: Kanal satırlarını raporla - ansible.builtin.debug: - msg: - - "{{ bf_device_label }} ssh={{ bf_ch_ssh }} xrdp={{ bf_ch_xrdp }} rustdesk={{ bf_ch_rustdesk }} wireguard={{ bf_ch_wireguard }}" - - "{{ bf_device_label }} meshcentral={{ bf_ch_meshcentral }} (opsiyonel) anydesk={{ bf_ch_anydesk }} rustdesk_config={{ bf_ch_rustdesk_config }}" - - "{{ bf_device_label }} hazır zorunlu kanal={{ bf_required_channel_ready }}/4 kanonik_bf_remote_status={{ bf_ch_canonical_ready }}" - vars: - bf_device_label: "{{ inventory_hostname | upper }}" - - - name: Kanonik doğrulama çıktısını yaz (bağımsız kanıt) - ansible.builtin.debug: - msg: "bf-remote-status --check rc={{ remote_status_cli.rc | default(1) }} cikti={{ remote_status_cli.stdout_lines | default([]) }}" - when: remote_channel_files_by_path[remote_status_bin].exists | default(false) - - - name: Hazır olmayan kanalı işaretle (opsiyoneller hariç) - ansible.builtin.debug: - msg: "UYARI {{ inventory_hostname | upper }}: kanal hazır değil -> {{ bf_not_ready_channels }}" - vars: - bf_not_ready_channels: >- - {{ (['ssh'] if bf_ch_ssh != 'enabled-active' else []) - + (['xrdp'] if bf_ch_xrdp != 'enabled-active' else []) - + (['rustdesk'] if bf_ch_rustdesk != 'installed-active' else []) - + (['wireguard'] if bf_ch_wireguard != 'fresh-handshake' else []) }} - when: bf_not_ready_channels | length > 0 - - - name: MeshCentral (opsiyonel) kanalının kapalı olduğunu bilgi olarak yaz - ansible.builtin.debug: - msg: "BİLGİ {{ inventory_hostname | upper }}: MeshCentral durumu={{ bf_ch_meshcentral }}; MeshCentral/AnyDesk opsiyoneldir, READY kapısının parçası değildir." - when: bf_ch_meshcentral != 'configured-active' - - - name: Kanal başına hazır cihaz sayısını hesapla - ansible.builtin.set_fact: - bf_channel_ready_counts: "{{ bf_channel_ready_counts | default({}) | combine({ item: bf_channel_lines | select('search', item ~ ':READY') | list | length }) }}" - loop: [ssh, xrdp, rustdesk, wireguard] - vars: - bf_channel_lines: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_required_channel_line') | map('default', '', true) | list }}" - run_once: true - - - name: Dalga kanal özetini yaz - ansible.builtin.debug: - msg: - - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" - - "Hazır kanal başına cihaz sayısı: {{ bf_channel_ready_counts | default({}) }}" - - "İki kanaldan az erişimi olan cihazlar: {{ bf_single_channel_hosts }}" - vars: - bf_single_channel_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_required_channel_ready') | map('default', 0) | map('int') | list)) | dict2items | selectattr('value', 'lt', 2) | map(attribute='key') | list }}" - run_once: true - - - name: Kanonik doğrulamada hazır olmayan cihazların dalga listesini yaz - ansible.builtin.debug: - msg: "bf-remote-status --check başarısız (merkez kanıtı dahil): {{ bf_canonical_not_ready_hosts }}" - vars: - bf_canonical_not_ready_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_ch_canonical_ready') | map('default', 'NOT-READY', true) | list)) | dict2items | selectattr('value', 'equalto', 'NOT-READY') | map(attribute='key') | list }}" - run_once: true - - --- # Run an approved script already present on a confirmed maintenance wave. @@ -12690,1200 +4146,51 @@ if __name__ == "__main__": raise SystemExit(main()) - -"""Operation model for the bfos operator console. - -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 -points can never disagree: - - * the 21 Ansible playbooks of ``ansible/playbooks/`` with their declared - scope (simple / wave / release) and destructive flag, - * the local install flow (``scripts/install/blueforce-install.sh``), - * the local diagnostics tools (``bf-status``, ``bf-diagnostics``, - ``bf-support-bundle``, ``bf-release``, ``bf-remote-status`` ...), - * target selection: dealer number ``^[0-9]{8}$``, group/wave, or ``all``, - * the safety gates: scope cross-check against the playbook file, production - override, release/wave tokens, destructive second confirmation, the - fleet-wide wave-by-wave loop that stops at the first failure. - -Design rules: - * no UI and no execution: every function here is pure or reads a file, and - every command is *produced* as an argv list for :mod:`bfos.runner`, - * refusals are raised as :class:`Refused` carrying a Turkish operator - message; nothing is auto-confirmed or silently downgraded, - * standard library only, Python 3.8+. -""" + +"""Canonical Blueforce device identity.""" from __future__ import annotations -import json import os -import platform import re -import socket -import stat -from dataclasses import dataclass, field, replace -from typing import Dict, Iterable, List, Optional, Sequence, Tuple +from pathlib import Path -from . import config +DEALER_ID_PATTERN = re.compile(r"^[0-9]{8}$") +DEALER_ID_FILE = Path("/etc/blueforce/dealer-id") -# --------------------------------------------------------------------------- -# Refusals -# --------------------------------------------------------------------------- +def validate_dealer_id(value: str) -> bool: + return bool(DEALER_ID_PATTERN.fullmatch(str(value).strip())) -class Refused(Exception): - """A refusal with an operator-facing Turkish explanation. - ``detail`` holds optional extra lines (hints, remediation) so the UI can - print the message and the hint separately. - """ - - def __init__(self, message: str, detail: str = "") -> None: - super().__init__(message) - self.message = message - self.detail = detail - - -# --------------------------------------------------------------------------- -# Identity helpers (mirrors admin/lib/targets.sh) -# --------------------------------------------------------------------------- -DEALER_RE = re.compile(config.DEALER_PATTERN) -HOST_RE = re.compile(config.HOST_PATTERN) - - -def dealer_is_valid(value: str) -> bool: - return bool(DEALER_RE.match(value or "")) - - -def dealer_to_host(dealer: str) -> str: - return "bf-{0}".format(dealer) - - -def dealer_to_device_id(dealer: str) -> str: - return "BF-{0}".format(dealer) - - -def host_to_dealer(host: str) -> Optional[str]: - match = HOST_RE.match(host or "") - return match.group(0)[3:] if match else None - - -def is_wave(name: str) -> bool: - return name in config.WAVES - - -def waves_csv() -> str: - return ",".join(config.WAVES) - - -def waves_readable() -> str: - return ", ".join(config.WAVES) - - -# --------------------------------------------------------------------------- -# Operation catalogue -- 21 playbooks, one row each -# --------------------------------------------------------------------------- -PROMPT_NONE = "none" -PROMPT_PACKAGES = "packages" -PROMPT_SERVICE = "service" -PROMPT_SCRIPT = "script" -PROMPT_GRACE = "grace" -PROMPT_DISK_PCT = "disk_pct" -PROMPT_LOG_LINES = "log_lines" -PROMPT_VERSION = "expected_version" - -CATEGORY_REPORT = "report" -CATEGORY_MAINTENANCE = "maintenance" - - -@dataclass(frozen=True) -class Operation: - """One fleet operation, bound to exactly one existing playbook.""" - - ident: str - label: str - playbook: str - declared_scope: str - destructive: bool - prompt: str - description: str - category: str - - @property - def target_mode(self) -> str: - """Target mode the operator is offered for this operation.""" - if self.declared_scope == config.SCOPE_RELEASE: - return config.MODE_RELEASE - if self.declared_scope == config.SCOPE_WAVE: - return config.MODE_WAVE - return config.MODE_ANY - - -# 2.1 Read-only fleet reports -- no playbook in this list changes a device. -REPORT_OPERATIONS: Tuple[Operation, ...] = ( - Operation("ping", "Erişilebilirlik testi (ping)", "bf-ping.yml", "simple", False, PROMPT_NONE, - "SSH erişimi kontrol edilir (salt okunur)", CATEGORY_REPORT), - Operation("uptime", "Çalışma süresi", "bf-uptime.yml", "simple", False, PROMPT_NONE, - "Açık kalma süresi ve sistem yükü", CATEGORY_REPORT), - Operation("disk", "Disk kullanımı", "bf-disk-check.yml", "simple", False, PROMPT_DISK_PCT, - "Disk doluluğu; eşik üstünde uyarı verir", CATEGORY_REPORT), - Operation("docker", "Docker durumu", "bf-docker-status.yml", "simple", False, PROMPT_NONE, - "Docker servisi ve konteynerler (salt okunur)", CATEGORY_REPORT), - Operation("packages", "Paket sürümleri", "bf-package-check.yml", "simple", False, PROMPT_NONE, - "Sabitlenmiş paketlerin kurulu sürümleri", CATEGORY_REPORT), - Operation("security-updates", "Bekleyen güvenlik güncellemeleri", - "bf-security-updates-check.yml", "simple", False, PROMPT_NONE, - "Yalnız rapor; hiçbir paket kurmaz", CATEGORY_REPORT), - Operation("provisioning-status", "Kurulum fazı raporu", "bf-provisioning-status.yml", "wave", - False, PROMPT_NONE, "Cihaz başına state.json fazı", CATEGORY_REPORT), - Operation("enrollment-status", "Kayıt ve merkez kanıtı", "bf-enrollment-status.yml", "wave", - False, PROMPT_NONE, "ENROLLED/READY iddiası ↔ merkez kanıtı", CATEGORY_REPORT), - Operation("fieldos-version", "Field OS sürüm raporu", "bf-fieldos-version.yml", "wave", - False, PROMPT_VERSION, "Cihaz başına sürüm; isteğe bağlı sapma denetimi", - CATEGORY_REPORT), - Operation("remote-channels", "Uzak erişim kanalları", "bf-remote-channels-check.yml", "wave", - False, PROMPT_NONE, "SSH/xRDP/RustDesk/WireGuard hazırlığı", CATEGORY_REPORT), - Operation("offline-ready", "Çevrimdışı hazırlık raporu", "bf-offline-ready-check.yml", "wave", - False, PROMPT_NONE, "İnternetsiz kurulum için yerel ön koşullar", CATEGORY_REPORT), -) - -# 2.2 Fleet maintenance -- destructive rows ask a second, explicit question. -MAINTENANCE_OPERATIONS: Tuple[Operation, ...] = ( - Operation("rdp-restart", "RDP servisini yeniden başlat", "bf-rdp-restart.yml", "simple", True, - PROMPT_NONE, "xrdp servisini yeniden başlatır", CATEGORY_MAINTENANCE), - Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "bf-rustdesk-restart.yml", - "simple", True, PROMPT_NONE, "RustDesk servisini yeniden başlatır", - CATEGORY_MAINTENANCE), - Operation("wireguard-restart", "WireGuard servisini yeniden başlat", - "bf-wireguard-restart.yml", "simple", True, PROMPT_NONE, - "wg-quick@wg0 servisini yeniden başlatır", CATEGORY_MAINTENANCE), - Operation("service-restart", "İzinli bir servisi yeniden başlat", "bf-service-restart.yml", - "simple", True, PROMPT_SERVICE, "Yalnız izinli servis adları kabul edilir", - CATEGORY_MAINTENANCE), - Operation("gui-off", "Grafik arayüzü kapat (headless)", "bf-gui-off.yml", "wave", True, - PROMPT_NONE, "Dalgayı multi-user.target'e alır", CATEGORY_MAINTENANCE), - Operation("gui-on", "Grafik arayüzü aç (bakım)", "bf-gui-on.yml", "wave", True, PROMPT_NONE, - "Grafik oturumu yeniden başlatır", CATEGORY_MAINTENANCE), - Operation("collect-logs", "Log topla / destek paketi", "bf-collect-logs.yml", "wave", False, - PROMPT_LOG_LINES, "Zaman damgalı journal kaydı ve destek paketi", - CATEGORY_MAINTENANCE), - Operation("run-script", "Onaylı betik çalıştır", "bf-run-script.yml", "wave", True, - PROMPT_SCRIPT, "Yalnız /opt/blueforce/bin ve /usr/local/sbin", - CATEGORY_MAINTENANCE), - Operation("reboot", "Kontrollü yeniden başlatma", "bf-reboot.yml", "release", True, - PROMPT_GRACE, "Sağlık kapılı, sıralı yeniden başlatma", CATEGORY_MAINTENANCE), - Operation("deploy-update", "Onaylı paket güncellemesi kur", "bf-deploy-update.yml", "release", - True, PROMPT_PACKAGES, "Sabit sürümlü paketler, sağlık kapılı", CATEGORY_MAINTENANCE), -) - -ALL_OPERATIONS: Tuple[Operation, ...] = REPORT_OPERATIONS + MAINTENANCE_OPERATIONS - -#: 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 -#: actually declares it (a permanent parity rule with admin/bf-menu). -VAR_OPERATION_WAVE = "operation_wave" -VAR_RELEASE_WAVE = "release_wave" -VAR_OPERATION_CONFIRMED = "operation_confirmed" -VAR_WAVE_GATE_CONFIRMED = "wave_gate_confirmed" -VAR_REBOOT_CONFIRMED = "reboot_confirmed" -VAR_PRODUCTION_OVERRIDE = "production_override" -VAR_ALLOW_PRODUCTION_REBOOT = "allow_production_reboot" - -GATE_VARIABLES = ( - VAR_OPERATION_WAVE, - VAR_RELEASE_WAVE, - VAR_WAVE_GATE_CONFIRMED, - VAR_REBOOT_CONFIRMED, - VAR_PRODUCTION_OVERRIDE, - VAR_ALLOW_PRODUCTION_REBOOT, -) - - -def find_operation(ident: str) -> Optional[Operation]: - for operation in ALL_OPERATIONS: - if operation.ident == ident: - return operation - return None - - -def operations_of(category: str) -> Tuple[Operation, ...]: - return tuple(op for op in ALL_OPERATIONS if op.category == category) - - -def catalogue_rows() -> Tuple[Operation, ...]: - """All operations ordered exactly like the bash catalogue.""" - return tuple(sorted(ALL_OPERATIONS, key=lambda op: (op.category != CATEGORY_REPORT, op.ident))) - - -# --------------------------------------------------------------------------- -# Playbook file: the source of truth for a playbook's own gates -# --------------------------------------------------------------------------- -def non_comment_text(text: str) -> str: - """The playbook text without full-line comments. - - Deliberately identical to ``grep -vE '^[[:space:]]*#'`` in bf-menu: a - trailing comment is kept, so the console and the bash console can never - disagree about what a playbook declares. - """ - keep = [line for line in text.splitlines() if not line.lstrip().startswith("#")] - return "\n".join(keep) - - -def playbook_uses_var(text: str, var: str) -> bool: - """True when VAR appears as a whole word outside the playbook's comments.""" - pattern = r"\b{0}\b".format(re.escape(var)) - return re.search(pattern, non_comment_text(text)) is not None - - -def playbook_scope(text: str) -> str: - """Derive wave / release / simple from the playbook's own variables.""" - if playbook_uses_var(text, VAR_RELEASE_WAVE): - return config.SCOPE_RELEASE - if playbook_uses_var(text, VAR_OPERATION_WAVE): - return config.SCOPE_WAVE - return config.SCOPE_SIMPLE - - -class Repository: - """Read-only view of the Blueforce checkout the console runs from.""" - - def __init__(self, root: str) -> None: - self.root = os.path.abspath(root) - - # -- discovery --------------------------------------------------------- - def path(self, rel: str) -> str: - return os.path.join(self.root, rel) - - def looks_valid(self) -> bool: - return os.path.isdir(self.path("ansible")) and os.path.isdir(self.path("scripts")) - - def playbook_path(self, playbook: str) -> str: - return self.path(os.path.join(config.PLAYBOOK_DIR_REL, playbook)) - - def playbook_text(self, playbook: str) -> str: - path = self.playbook_path(playbook) - try: - with open(path, "r", encoding="utf-8") as handle: - return handle.read() - except OSError as exc: - raise Refused( - config.MSG_PLAYBOOK_MISSING.format(path=path), str(exc) - ) from exc - - def verify_scope(self, operation: Operation) -> str: - """Cross-check the catalogue scope against the playbook file. - - The playbook is the source of truth for its own gates: a table that - drifted must refuse to run instead of silently dropping a gate. - """ - text = self.playbook_text(operation.playbook) - actual = playbook_scope(text) - if actual != operation.declared_scope: - raise Refused( - config.MSG_GATE_SCOPE_MISMATCH.format( - playbook=operation.playbook, - declared=operation.declared_scope, - actual=actual, - ) - ) - return actual - - def playbook_uses(self, playbook: str, var: str) -> bool: - return playbook_uses_var(self.playbook_text(playbook), var) - - # -- local assets ------------------------------------------------------ - def installer_path(self) -> str: - path = self.path(config.INSTALLER_REL) - if not os.path.isfile(path): - raise Refused(config.MSG_INSTALLER_MISSING.format(path=path)) - return path - - def tool_path(self, tool: str) -> str: - """Preferred path of a diagnostics/maintenance tool (bf_tool_path).""" - candidates = ( - os.path.join(config.TOOL_BIN_DIR, tool), - self.path(os.path.join(config.DIAGNOSTICS_DIR_REL, tool)), - self.path(os.path.join("scripts/maintenance", tool)), - ) - for candidate in candidates: - if os.access(candidate, os.X_OK) and os.path.isfile(candidate): - return candidate - raise Refused(config.MSG_TOOL_MISSING.format(tool=tool)) - - def group_vars_path(self) -> Optional[str]: - path = self.path(config.GROUP_VARS_REL) - return path if os.path.isfile(path) else None - - # -- inventory --------------------------------------------------------- - def inventory_candidates(self) -> Tuple[str, ...]: - base = self.path(config.INVENTORY_DIR_REL) - return tuple(os.path.join(base, name) for name in config.INVENTORY_CANDIDATES) - - def find_inventory(self, explicit: str = "") -> str: - """Resolve the Ansible inventory, or refuse with remediation. - - ``hosts.example.yml`` is never selected automatically: it carries - example device IDs and must only be used after a deliberate copy. - """ - if explicit: - if os.path.isfile(explicit) and os.access(explicit, os.R_OK): - return os.path.abspath(explicit) - raise Refused(config.MSG_INVENTORY_UNREADABLE.format(path=explicit)) - for candidate in self.inventory_candidates(): - if os.path.isfile(candidate) and os.access(candidate, os.R_OK): - return candidate - example = self.path(config.INVENTORY_EXAMPLE_REL) - detail = "" - if os.path.isfile(example): - detail = config.MSG_INVENTORY_EXAMPLE_ONLY.format(path=example) - raise Refused( - config.MSG_INVENTORY_MISSING.format(dir=self.path(config.INVENTORY_DIR_REL)), - detail, - ) - - -# --------------------------------------------------------------------------- -# Inventory parsing (a deliberately small YAML reader, mirrors targets.sh) -# --------------------------------------------------------------------------- -@dataclass -class Inventory: - """group -> ordered hosts, parsed from the Ansible inventory.""" - - path: Optional[str] = None - groups: Dict[str, List[str]] = field(default_factory=dict) - - # -- construction ------------------------------------------------------ - @classmethod - def parse(cls, text: str, path: Optional[str] = None) -> "Inventory": - groups: Dict[str, List[str]] = {} - # Stack of (key, indent) for keys whose value is an empty mapping. - stack: List[Tuple[str, int]] = [] - host_block = False - host_indent = 0 - host_step: Optional[int] = None - host_group = "" - - for raw in text.splitlines(): - line = raw.rstrip() - stripped = line.strip() - if not stripped or stripped.startswith("#") or stripped.startswith("-"): - continue - if ":" not in line: - continue - indent = len(line) - len(line.lstrip(" ")) - key_part, _, value_part = line.partition(":") - key = key_part.strip().strip("'\"").strip() - value = value_part.strip() - if not key: - continue - - # Close blocks that are at or above this key's indentation. - while stack and stack[-1][1] >= indent: - stack.pop() - if host_block and indent <= host_indent: - host_block = False - - if key == "hosts" and not value: - host_block = True - host_indent = indent - host_step = None - host_group = stack[-1][0] if stack else "" - stack.append((key, indent)) - continue - - if host_block and indent > host_indent: - if host_step is None: - host_step = indent - if indent == host_step: - groups.setdefault(host_group, []) - if key not in groups[host_group]: - groups[host_group].append(key) - stack.append((key, indent)) - continue - - if not value: - stack.append((key, indent)) - - return cls(path=path, groups=groups) - - @classmethod - def load(cls, path: str) -> "Inventory": - with open(path, "r", encoding="utf-8") as handle: - return cls.parse(handle.read(), path=path) - - @classmethod - def load_repository(cls, repo: Repository, explicit: str = "") -> "Inventory": - """Load the inventory, or return an empty one marked as unresolved. - - Callers that must refuse on a missing inventory ask the repository - directly (:meth:`Repository.find_inventory`); the UI keeps working with - an unresolved inventory so read-only screens can still explain what is - missing instead of crashing. - """ - try: - resolved = repo.find_inventory(explicit) - except Refused: - return cls(path=None, groups={}) - try: - return cls.load(resolved) - except (OSError, ValueError): - return cls(path=None, groups={}) - - # -- queries ----------------------------------------------------------- - @property - def resolved(self) -> bool: - """True when an inventory was actually found and parsed. - - A path is not required: an inventory parsed from text (tests, callers - that hold the YAML themselves) is just as usable as a file on disk. - """ - return self.path is not None or bool(self.groups) - - def labels(self) -> str: - return self.path or "çözümlenmedi" - - def group_names(self) -> Tuple[str, ...]: - return tuple(self.groups) - - def hosts_of(self, group: str) -> Tuple[str, ...]: - return tuple(self.groups.get(group, ())) - - def group_exists(self, group: str) -> bool: - return group in self.groups - - def host_exists(self, host: str) -> bool: - return any(host in hosts for hosts in self.groups.values()) - - def all_hosts(self) -> Tuple[str, ...]: - hosts: List[str] = [] - for group_hosts in self.groups.values(): - for host in group_hosts: - if host not in hosts: - hosts.append(host) - return tuple(hosts) - - def count_all(self) -> int: - return len(self.all_hosts()) - - def waves_present(self) -> Tuple[str, ...]: - """Canonical waves that exist in the inventory, in rollout order.""" - return tuple(wave for wave in config.WAVES if self.group_exists(wave)) - - def host_wave(self, host: str) -> Optional[str]: - """The approved wave containing HOST, or None.""" - for wave in config.WAVES: - if host in self.groups.get(wave, ()): - return wave - return None - - -# --------------------------------------------------------------------------- -# Target selection (mirrors bf_target_*) -# --------------------------------------------------------------------------- -KIND_HOST = "host" -KIND_GROUP = "group" -KIND_ALL = "all" - - -@dataclass(frozen=True) -class Target: - kind: str - limit: str - wave: str - count: int - label: str - - -def _target_host(dealer: str, mode: str, inventory: Inventory) -> Target: - if not dealer_is_valid(dealer): - raise Refused(config.MSG_TARGET_DEALER_INVALID.format(value=dealer)) - if mode == config.MODE_RELEASE: - raise Refused(config.MSG_TARGET_RELEASE_DEVICE) - if not inventory.resolved: - raise Refused(config.MSG_TARGET_DEVICE_UNKNOWN.format( - host=dealer_to_host(dealer), inventory=inventory.labels())) - host = dealer_to_host(dealer) - if not inventory.host_exists(host): - raise Refused(config.MSG_TARGET_DEVICE_UNKNOWN.format(host=host, inventory=inventory.labels())) - wave = inventory.host_wave(host) or "" - if not wave and mode != config.MODE_ANY: - raise Refused(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=host)) - label = "cihaz {0} ({1})".format(dealer_to_device_id(dealer), host) - if wave: - label += " — dalga '{0}'".format(wave) - return Target(kind=KIND_HOST, limit=host, wave=wave, count=1, label=label) - - -def _target_group(group: str, mode: str, inventory: Inventory) -> Target: - if not inventory.resolved or not inventory.group_exists(group): - raise Refused( - config.MSG_TARGET_GROUP_UNKNOWN.format( - group=group, groups=" ".join(inventory.group_names()) or "-" - ) - ) - if mode in (config.MODE_WAVE, config.MODE_RELEASE) and not is_wave(group): - raise Refused(config.MSG_TARGET_GROUP_NOT_WAVE.format(group=group, waves=waves_readable())) - count = len(inventory.hosts_of(group)) - if count == 0: - raise Refused(config.MSG_TARGET_GROUP_EMPTY.format(group=group)) - label = "dalga '{0}' ({1} cihaz)".format(group, count) if is_wave(group) else \ - "grup '{0}' ({1} cihaz)".format(group, count) - wave = group if is_wave(group) else "" - return Target(kind=KIND_GROUP, limit=group, wave=wave, count=count, label=label) - - -def _target_all(mode: str, inventory: Inventory) -> Target: - if mode == config.MODE_RELEASE: - raise Refused(config.MSG_TARGET_RELEASE_ALL) - if not inventory.resolved or inventory.count_all() == 0: - raise Refused(config.MSG_TARGET_ALL_EMPTY) - count = inventory.count_all() - return Target(kind=KIND_ALL, limit="all", wave="", count=count, - label="filo geneli ({0} cihaz)".format(count)) - - -def resolve_target(word: str, mode: str, inventory: Inventory) -> Target: - """Turn one operator word into a validated target. - - Accepts an 8-digit dealer number, a ``bf-`` hostname (convenience), - a group/wave name, or ``all``. Anything else is refused with a Turkish - explanation; nothing is guessed. - """ - word = (word or "").strip() - if not word: - raise Refused(config.MSG_TARGET_EMPTY) - if word == "all": - return _target_all(mode, inventory) - if dealer_is_valid(word): - return _target_host(word, mode, inventory) - if inventory.resolved and inventory.group_exists(word): - return _target_group(word, mode, inventory) - if HOST_RE.match(word) and dealer_is_valid(word[3:]): - return _target_host(word[3:], mode, inventory) - raise Refused( - config.MSG_TARGET_UNKNOWN.format(value=word), - config.MSG_TARGET_KNOWN_GROUPS.format(groups=" ".join(inventory.group_names()) or "-"), +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 wave_sequence(inventory: Inventory) -> Tuple[Tuple[str, int], ...]: - """Waves present in the inventory with their device counts, in order.""" - return tuple((wave, len(inventory.hosts_of(wave))) for wave in inventory.waves_present()) - - -# --------------------------------------------------------------------------- -# Gates: the confirmation plan (what the operator must state explicitly) -# --------------------------------------------------------------------------- -STEP_TOKEN = "token" -STEP_APPROVAL = "approval" - - -@dataclass(frozen=True) -class GateStep: - """One explicit confirmation the operator has to give before a run.""" - - kind: str - key: str - prompt: str - message: str = "" - token: str = "" - unlocks: Tuple[str, ...] = () - - -@dataclass -class GatesGranted: - """Flags granted by the operator's confirmations. - - ``build_ansible_command`` adds a gate variable only when the matching flag - is granted AND the playbook declares that variable, so a command can never - become less gated than the playbook expects. - """ - - wave_gate_confirmed: bool = False - production_override: bool = False - allow_production_reboot: bool = False - fleet_wide: bool = False - - def grant(self, step: GateStep) -> "GatesGranted": - changes = {name: True for name in step.unlocks if hasattr(self, name)} - return replace(self, **changes) if changes else self - - -def confirmation_plan( - operation: Operation, - scope: str, - target: Target, - wave: str = "", - wave_gate_supported: bool = False, -) -> List[GateStep]: - """The ordered list of confirmations required for one operation run. - - Rules (all preserved from the bash console): - * the production wave requires the word ``production`` **and** a separate - production approval that unlocks ``production_override=true``, - * a release-gated operation (reboot, deploy-update) requires its wave name - to be written out before anything is built; a wave-scoped read-only - report does not (the bash console asks no token for it either), - * a destructive operation requires a second, explicit approval, - * a fleet-wide simple run requires the word ``all``. - """ - steps: List[GateStep] = [] - effective_wave = wave or target.wave - - if scope in (config.SCOPE_WAVE, config.SCOPE_RELEASE): - if effective_wave == config.PRODUCTION_WAVE: - message = config.MSG_GATE_PRODUCTION - if wave_gate_supported: - message = message + "\n" + config.MSG_GATE_GATE_FLAG - steps.append( - GateStep( - kind=STEP_TOKEN, - key="production_token", - prompt=config.MSG_GATE_PRODUCTION_ASK, - message=message, - token=config.PRODUCTION_WAVE, - unlocks=(VAR_WAVE_GATE_CONFIRMED,) if wave_gate_supported else (), - ) - ) - steps.append( - GateStep( - kind=STEP_APPROVAL, - key="production_approval", - prompt=config.MSG_GATE_PRODUCTION_APPROVAL, - message=config.MSG_GATE_PRODUCTION, - unlocks=(VAR_PRODUCTION_OVERRIDE, VAR_ALLOW_PRODUCTION_REBOOT), - ) - ) - elif effective_wave and scope == config.SCOPE_RELEASE: - # Release-gated operations never run from a typed word alone: the - # operator has to name the exact wave that is being released. - message = config.MSG_GATE_WAVE_TOKEN.format(wave=effective_wave) - if wave_gate_supported: - message = message + "\n" + config.MSG_GATE_GATE_FLAG - steps.append( - GateStep( - kind=STEP_TOKEN, - key="wave_token", - prompt=config.MSG_GATE_WAVE_TOKEN_ASK.format(wave=effective_wave), - message=message, - token=effective_wave, - unlocks=(VAR_WAVE_GATE_CONFIRMED,) if wave_gate_supported else (), - ) - ) - - if target.kind == KIND_ALL and scope == config.SCOPE_SIMPLE: - steps.append( - GateStep( - kind=STEP_TOKEN, - key="fleet_token", - prompt=config.MSG_GATE_FLEET_TOKEN_ASK, - message=config.MSG_GATE_FLEET_TOKEN.format(count=target.count), - token="all", - unlocks=("fleet_wide",), - ) - ) - - if operation.destructive and not any(step.key == "production_approval" for step in steps): - # The second, explicit approval for a state-changing operation. It is a - # gate (it happens before the command is even built); the run prompt - # after the command has been shown is a separate, final confirmation. - steps.append( - GateStep( - kind=STEP_APPROVAL, - key="destructive_approval", - prompt=config.MSG_DESTRUCTIVE_SUB, - message=config.MSG_DESTRUCTIVE_GATE.format(label=operation.label), - ) - ) - return steps - - -# --------------------------------------------------------------------------- -# Extra -e variables, validated with the rules the playbooks enforce -# --------------------------------------------------------------------------- -PIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$") - - -def parse_packages(text: str) -> List[str]: - pins: List[str] = [] - for chunk in (text or "").split(","): - pin = chunk.strip() - if not pin: - continue - if not PIN_RE.match(pin): - raise Refused(config.MSG_PKG_INVALID.format(value=pin)) - version = pin.partition("=")[2].strip("=").strip() - if not version or version.lower().startswith("latest"): - raise Refused(config.MSG_PKG_LATEST.format(value=pin)) - pins.append(pin) - if not pins: - raise Refused(config.MSG_PKG_REQUIRED) - return pins - - -def packages_expression(pins: Sequence[str]) -> str: - """The ``update_packages`` value: ``["pkg=ver",...]`` like bf_prompt_packages. - - Only the value is returned; the caller pairs it with the variable name, so - exactly one ``update_packages=`` prefix ever reaches the command line. - """ - return "[{0}]".format(",".join('"{0}"'.format(pin) for pin in pins)) - - -def parse_service(text: str) -> str: - name = (text or "").strip() - if name not in config.SERVICE_ALLOWLIST: - raise Refused( - config.MSG_SERVICE_INVALID.format(value=name), - config.MSG_SERVICE_HINT.format(allowlist=" ".join(config.SERVICE_ALLOWLIST)), - ) - return name - - -def parse_script(path: str, args: str = "") -> List[Tuple[str, str]]: - script = (path or "").strip() - if not script.startswith("/"): - raise Refused(config.MSG_SCRIPT_ABSOLUTE) - if not any(script.startswith(directory + "/") for directory in config.SCRIPT_DIRS): - raise Refused( - config.MSG_SCRIPT_OUTSIDE.format(value=script, dirs=" ".join(config.SCRIPT_DIRS)) - ) - pairs = [("script_path", script)] - if (args or "").strip(): - pairs.append(("script_args", args.strip())) - return pairs - - -def parse_grace(text: str) -> str: - value = (text or "").strip() or str(config.DEFAULT_REBOOT_GRACE) - if not value.isdigit(): - raise Refused(config.MSG_GRACE_INVALID) +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 parse_disk_pct(text: str) -> List[Tuple[str, str]]: - value = (text or "").strip() - if not value: - return [] - if not value.isdigit() or not (config.DISK_WARN_MIN <= int(value) <= config.DISK_WARN_MAX): - raise Refused(config.MSG_PCT_INVALID.format(min=config.DISK_WARN_MIN, max=config.DISK_WARN_MAX)) - return [("disk_warn_pct", 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 parse_log_lines(text: str) -> str: - value = (text or "").strip() or str(config.DEFAULT_LOG_LINES) - if not value.isdigit() or int(value) < 1: - raise Refused(config.MSG_LINES_INVALID) - return value - - -def parse_expected_version(text: str) -> List[Tuple[str, str]]: - value = (text or "").strip() - return [("fieldos_expected_version", value)] if value else [] - - -def build_extra_vars(prompt: str, answers: Dict[str, str]) -> List[Tuple[str, str]]: - """Validate the operator's answers for one operation's prompt kind.""" - if prompt == PROMPT_NONE: - return [] - if prompt == PROMPT_PACKAGES: - return [("update_packages", packages_expression(parse_packages(answers.get("packages", ""))))] - if prompt == PROMPT_SERVICE: - return [("service_name", parse_service(answers.get("service", "")))] - if prompt == PROMPT_SCRIPT: - return parse_script(answers.get("script", ""), answers.get("script_args", "")) - if prompt == PROMPT_GRACE: - return [("reboot_grace_seconds", parse_grace(answers.get("grace", "")))] - if prompt == PROMPT_DISK_PCT: - return parse_disk_pct(answers.get("disk_pct", "")) - if prompt == PROMPT_LOG_LINES: - return [("log_lines", parse_log_lines(answers.get("log_lines", "")))] - if prompt == PROMPT_VERSION: - return parse_expected_version(answers.get("expected_version", "")) - raise Refused("Konsol hatası: bilinmeyen soru türü '{0}'.".format(prompt)) - - -# --------------------------------------------------------------------------- -# Command construction -# --------------------------------------------------------------------------- -def build_ansible_command( - repo: Repository, - inventory_path: str, - operation: Operation, - scope: str, - target: Optional[Target], - wave: str = "", - extra_vars: Sequence[Tuple[str, str]] = (), - gates: Optional[GatesGranted] = None, - check_mode: bool = False, -) -> List[str]: - """Build the ansible-playbook argv for one operation and one target. - - Single choke point: no command is ever built without a target, and a gated - scope is never built without a wave. - """ - gates = gates or GatesGranted() - if target is None: - raise Refused(config.MSG_GATE_NO_TARGET) - playbook = operation.playbook - - def uses(var: str) -> bool: - return repo.playbook_uses(playbook, var) - - argv = [ - "ansible-playbook", - "-i", inventory_path, - repo.playbook_path(playbook), - "--limit", target.limit, - ] - effective_wave = wave or target.wave - - if scope == config.SCOPE_WAVE: - if not effective_wave: - raise Refused(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=target.limit)) - argv += ["-e", "{0}={1}".format(VAR_OPERATION_WAVE, effective_wave), - "-e", "{0}=true".format(VAR_OPERATION_CONFIRMED)] - if effective_wave == config.PRODUCTION_WAVE and gates.production_override \ - and uses(VAR_PRODUCTION_OVERRIDE): - argv += ["-e", "{0}=true".format(VAR_PRODUCTION_OVERRIDE)] - elif scope == config.SCOPE_RELEASE: - if not effective_wave or not is_wave(effective_wave): - raise Refused(config.MSG_TARGET_RELEASE_DEVICE) - argv += ["-e", "{0}={1}".format(VAR_RELEASE_WAVE, effective_wave)] - if gates.wave_gate_confirmed and uses(VAR_WAVE_GATE_CONFIRMED): - argv += ["-e", "{0}=true".format(VAR_WAVE_GATE_CONFIRMED)] - if uses(VAR_REBOOT_CONFIRMED): - argv += ["-e", "{0}=true".format(VAR_REBOOT_CONFIRMED)] - if effective_wave == config.PRODUCTION_WAVE: - if gates.production_override and uses(VAR_PRODUCTION_OVERRIDE): - argv += ["-e", "{0}=true".format(VAR_PRODUCTION_OVERRIDE)] - if gates.allow_production_reboot and uses(VAR_ALLOW_PRODUCTION_REBOOT): - argv += ["-e", "{0}=true".format(VAR_ALLOW_PRODUCTION_REBOOT)] - - for name, value in extra_vars: - argv += ["-e", "{0}={1}".format(name, value)] - if check_mode: - argv.append("--check") - return argv - - -def build_install_command( - installer: str, - dealer: str, - offline: bool = False, - resume: bool = False, - only: str = "", - check_mode: bool = False, -) -> List[str]: - """The local installer argv (bash --dealer-id ...).""" - if not dealer_is_valid(dealer): - raise Refused(config.MSG_TARGET_DEALER_INVALID.format(value=dealer)) - argv = ["bash", installer, "--dealer-id", dealer] - if offline: - argv.append("--offline") - if resume: - argv.append("--resume") - if only: - argv += ["--only", only] - if check_mode: - argv.append("--check") - return argv - - -def build_tool_command(tool: str, args: Sequence[str] = (), check_mode: bool = False) -> List[str]: - argv = [tool] + list(args) - if check_mode: - argv.append("--check") - return argv - - -def module_names(repo: Repository) -> Tuple[str, ...]: - """Installer modules (``NN-name.sh``) offered by the single-module action.""" - modules_dir = repo.path(os.path.join("scripts/install/installer/modules")) - try: - names = sorted( - entry for entry in os.listdir(modules_dir) - if re.match(r"^[0-9]{2}-.*\.sh$", entry) - ) - except OSError: - return () - return tuple(name[:-3] for name in names) - - -# --------------------------------------------------------------------------- -# Read-only information reports (returned as text lines for the UI) -# --------------------------------------------------------------------------- -def targets_lines(inventory: Inventory) -> List[str]: - lines: List[str] = [] - lines.append("DALGALAR (yayılma sırası)") - for wave in config.WAVES: - if inventory.group_exists(wave): - lines.append(" {0:<14} {1} cihaz".format(wave, len(inventory.hosts_of(wave)))) - else: - lines.append(" {0:<14} envanterde yok".format(wave)) - others = [group for group in inventory.group_names() if not is_wave(group)] - if others: - lines.append("") - lines.append("DİĞER GRUPLAR") - for group in others: - lines.append(" {0:<14} {1} cihaz".format(group, len(inventory.hosts_of(group)))) - lines.append("") - lines.append("CİHAZLAR") - for host in inventory.all_hosts(): - lines.append(" {0}".format(host)) - lines.append("") - lines.append("Envanter: {0}".format(inventory.labels())) - lines.append("Toplam cihaz: {0}".format(inventory.count_all())) - return lines - - -def wave_gate_state_lines(inventory: Inventory) -> List[str]: - lines = ["DALGA GEÇİŞ KAPISI DURUMU"] - for wave in config.WAVES: - if inventory.group_exists(wave): - lines.append(" {0:<14} {1} cihaz".format(wave, len(inventory.hosts_of(wave)))) - else: - lines.append(" {0:<14} envanterde yok".format(wave)) - lines.append("") - lines.append("Kural: bir dalga, ancak önceki dalga sağlık kapısını ve merkez onayını geçtikten sonra başlar.") - lines.append("Üretim (production) ayrıca yazılı 'production' onayı ve production_override=true gerektirir.") - lines.append("Kaynak: docs/10 — güncelleme ve geri alma politikası.") - return lines - - -def central_checklist_lines() -> List[str]: - return [ - "MERKEZ HAZIRLIK LİSTESİ (bilgilendirme)", - " 1. WireGuard hub: uç nokta, anahtarlar ve her bf- için eş tahsisi (docs/08).", - " 2. Kayıt (enrollment) jeton servisi: tek kullanımlık, TTL sınırlı (docs/29).", - " 3. İzleme: Prometheus/VictoriaMetrics + Grafana, device_id etiketi BF- (docs/14).", - " 4. Uzak erişim: RustDesk hbbs/hbbr veya MeshCentral, WireGuard üzerinden (docs/07).", - " 5. Paket deposu + çevrimdışı anlık görüntü (docs/10, docs/27).", - " 6. Gerçek ana makine adlarıyla Ansible envanteri ve yönetici SSH anahtarı (docs/02, 06, 09).", - "Bu konsol 6'yı doğrular, 1-5'i hazırlar; merkez bileşeni kurmaz.", - ] - - -def _group_vars_value(path: Optional[str], key: str) -> str: - if not path or not os.path.isfile(path): - return "" - pattern = re.compile(r"^[ \t]*{0}[ \t]*:(.*)$".format(re.escape(key))) - try: - with open(path, "r", encoding="utf-8") as handle: - for line in handle: - match = pattern.match(line.rstrip("\n")) - if match: - value = match.group(1) - value = value.split("#", 1)[0].strip() - return value.strip("'\"") - except OSError: - return "" - return "" - - -def central_endpoint_lines(repo: Repository) -> List[str]: - path = repo.group_vars_path() - lines = ["MERKEZ UÇ NOKTALARI (group_vars/all.yml)"] - if path is None: - lines.append(" group_vars/all.yml bulunamadı: {0}".format(repo.path(config.GROUP_VARS_REL))) - return lines - for key in config.CENTRAL_ENDPOINT_KEYS: - value = _group_vars_value(path, key) - if value: - lines.append(" {0:<22} {1}".format(key, value)) - lines.append("") - lines.append("Değerler group_vars/all.yml dosyasından okunur; burada hiçbir sır saklanmaz veya yazılmaz.") - return lines - - -def central_readiness_lines(repo: Repository, inventory: Inventory, inventory_error: str = "") -> List[str]: - lines = ["MERKEZ HAZIRLIK DURUMU"] - if inventory.resolved: - lines.append(" [OK] Envanter: {0} ({1} cihaz)".format(inventory.path, inventory.count_all())) - else: - lines.append(" [UYARI] Envanter çözümlenemedi.") - if inventory_error: - lines.extend(" " + part for part in inventory_error.splitlines()) - path = repo.group_vars_path() - if path: - try: - with open(path, "r", encoding="utf-8") as handle: - placeholders = [line.strip() for line in handle if "PLACEHOLDER" in line] - except OSError: - placeholders = [] - if placeholders: - lines.append(" [UYARI] group_vars/all.yml içinde {0} PLACEHOLDER değeri var:".format(len(placeholders))) - lines.extend(" " + line for line in placeholders) - else: - lines.append(" [OK] group_vars/all.yml içinde PLACEHOLDER kalmamış.") - else: - lines.append(" [UYARI] ansible/inventory/group_vars/all.yml bulunamadı.") - key_path = os.path.expanduser(config.ADMIN_KEY_FILE) - if os.path.isfile(key_path): - lines.append(" [OK] Yönetici SSH anahtarı var: {0} (içeriği okunmaz/yazılmaz)".format(config.ADMIN_KEY_FILE)) - else: - lines.append(" [UYARI] Yönetici SSH anahtarı yok: {0} (docs/06)".format(config.ADMIN_KEY_FILE)) - if inventory.path and "example" in os.path.basename(inventory.path): - lines.append(" [UYARI] Bu örnek bir envanter; gerçek cihazlara yöneltmeyin.") - return lines - - -def split_endpoint(endpoint: str) -> Tuple[str, Optional[int]]: - """Split a configured endpoint into (host, port) — no network access.""" - value = (endpoint or "").strip() - if not value: - return "", None - if "//" in value: - value = value.split("//", 1)[1] - value = value.split("/", 1)[0] - if value.startswith("["): # IPv6 literal - host, _, rest = value.partition("]") - host = host.lstrip("[") - port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else None - return host, port - if ":" in value: - host, _, port_text = value.rpartition(":") - if port_text.isdigit(): - return host, int(port_text) - return value, None - if endpoint.startswith("http://"): - return value, 80 - if endpoint.startswith("https://"): - return value, 443 - return value, None - - -def probe_endpoint(host: str, port: Optional[int], timeout: float = 3.0) -> Tuple[str, bool]: - """Resolve HOST and, when PORT is known, try one TCP connection.""" - if not host: - return "", False - try: - infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) - except OSError: - return "", False - if not infos: - return "", False - resolved = str(infos[0][4][0]) - if port is None: - return resolved, True - try: - with socket.create_connection((resolved, port), timeout=timeout): - return resolved, True - except OSError: - return resolved, False - - -def central_probe_lines(repo: Repository, timeout: float = 3.0) -> List[str]: - path = repo.group_vars_path() - lines = ["MERKEZ UÇ NOKTASI DENEMESİ (salt okunur)"] - if path is None: - lines.append(" group_vars/all.yml bulunamadı.") - return lines - for key in ("wg_hub_endpoint", "monitoring_endpoint", "update_repo_url"): - endpoint = _group_vars_value(path, key) - if not endpoint: - continue - host, port = split_endpoint(endpoint) - if not host: - continue - resolved, reachable = probe_endpoint(host, port, timeout=timeout) - if not resolved: - lines.append(" [UYARI] {0}: çözümlenemedi (yer tutucu olabilir)".format(host)) - elif port is None: - lines.append(" [OK] {0} -> {1} (port türetilemedi)".format(host, resolved)) - elif reachable: - lines.append(" [OK] {0}:{1} erişilebilir ({2})".format(host, port, resolved)) - else: - lines.append(" [UYARI] {0}:{1} erişilemiyor ({2})".format(host, port, resolved)) - return lines - - -def state_values(path: str = config.STATE_FILE) -> Dict[str, str]: - """Read state.json through a whitelist of non-secret keys. - - A future token field can never leak into the UI or the audit log. - """ - values: Dict[str, str] = {} - try: - with open(path, "r", encoding="utf-8") as handle: - data = json.load(handle) - except (OSError, ValueError): - return values - if not isinstance(data, dict): - return values - for key in config.STATE_ALLOWED_KEYS: - value = data.get(key) - if value is None or isinstance(value, bool): - continue - values[key] = str(value) - return values - - -def identity_lines(hostname: str = "") -> List[str]: - host = hostname or platform.node() or "bilinmiyor" - dealer = host_to_dealer(host) - lines = ["KİMLİK", " Ana makine adı : {0}".format(host)] - if dealer: - lines.append(" Bayi numarası : {0}".format(dealer)) - lines.append(" Cihaz kimliği (insan) : {0}".format(dealer_to_device_id(dealer))) - else: - lines.append(" [UYARI] Ana makine adı bf-<8 hane> biçiminde değil (docs/02).") - lines.append(" Çekirdek : {0}".format(platform.platform())) - values = state_values() - for key in ("device_id", "phase"): - if key in values: - lines.append(" state.json {0:<10}: {1}".format(key, values[key])) - return lines - - -def state_lines(path: str = config.STATE_FILE) -> List[str]: - lines = ["KURULUM / KAYIT DURUMU"] - values = state_values(path) - if not values: - lines.append(" [UYARI] {0} yok veya okunamadı: cihaz kurulmamış olabilir.".format(path)) - return lines - for key in config.STATE_ALLOWED_KEYS: - if key in values: - lines.append(" {0:<18} {1}".format(key, values[key])) - lines.append("") - lines.append("Yalnız yukarıdaki kimlik/yaşam döngüsü alanları okunur; jeton ve anahtarlar asla yazılmaz.") - lines.append("İleri yönlü durum makinesi: PROVISIONED_OFFLINE -> ENROLLED -> READY (docs/29).") - return lines - - -def environment_lines( - repo: Repository, - log_file: str, - inventory: Inventory, - dry_run: bool, - check_mode: bool, -) -> List[str]: - lines = [ - "ORTAM", - " Konsol : {0} {1}".format(config.PROGRAM_NAME, config.VERSION), - " Depo : {0}".format(repo.root), - " Envanter : {0}".format(inventory.labels()), - " Denetim kaydı : {0}".format(log_file), - " dry-run : {0}".format("evet" if dry_run else "hayır"), - " check modu : {0}".format("evet" if check_mode else "hayır"), - " Python : {0}".format(platform.python_version()), - ] - for tool in (config.TOOL_ENROLL_STATUS, config.TOOL_LIVE_HW_CHECK): - try: - lines.append(" {0:<21}: {1}".format(tool, repo.tool_path(tool))) - except Refused: - lines.append(" {0:<21}: yok".format(tool)) - return lines - - -def file_mode(path: str) -> str: - """Octal permission bits of PATH, or "?" when it cannot be read.""" - try: - return oct(stat.S_IMODE(os.stat(path).st_mode))[2:].zfill(3) - except OSError: - return "?" +def current_identity(path: str | os.PathLike[str] | None = None) -> dict[str, str]: + return identity_for(read_dealer_id(path)) @@ -14388,6 +4695,498 @@ class Console: return outcome, payload + +"""bfos/ui_widgets.py -- reusable presentational components for the console. + +What this module is +------------------- +The small widget vocabulary the redesigned operator console is built from: +line icons (Tk canvas, shipped locally, no assets), a segmented scope +control, a collapsible panel, a two-column meta grid, a scrollable row +container, an elapsed timer, and two safety dialogs (plan confirmation and +typed-token gate). + +What this module is NOT +----------------------- +It knows nothing about operations, targets, gates, commands, auditing or +execution. It receives plain strings and token colours and returns plain +widget handles and booleans. Every behavioural decision (which gates exist, +what a token must equal, when a command may run) stays in ``bfos/ui_gui.py`` +and the backend behind it. + +Design rules (docs/DESIGN.md, docs/34-CONSOLE-VISUAL-CONCEPT.md): + +* no colour literals -- every fill/foreground comes from ``ui_theme``, +* no user-visible English -- caller-facing labels are Turkish, +* status is word + colour, never colour alone (callers pass the words), +* tkinter is optional: the module imports without it so static tests never + need a display (mirrors the ``ui_gui.py`` guard). +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +try: # pragma: no cover - environment dependent + import tkinter as tk + from tkinter import ttk + + HAVE_TKINTER = True +except Exception: # pragma: no cover - environment dependent + tk = ttk = None # type: ignore[assignment] + HAVE_TKINTER = False + +# The view layer only calls these with a live display; Any keeps the optional +# import from tripping the type checker (same pattern as bfos/ui_gui.py). +tk: Any +ttk: Any + +try: + from bfos import ui_theme as theme +except ImportError: # pragma: no cover - loaded outside the package + import sys + + _WIDGETS_DIR = str(Path(__file__).resolve().parent) + if _WIDGETS_DIR not in sys.path: + sys.path.insert(0, _WIDGETS_DIR) + import ui_theme as theme # type: ignore[no-redef] + + +# --------------------------------------------------------------------------- +# Line icons -- one stroke vocabulary, drawn on a Tk canvas +# --------------------------------------------------------------------------- +def draw_icon(canvas, name: str, size: int, colour: str) -> None: + """Draw a ``name`` line icon centred on ``canvas`` (already sized). + + Coordinates are derived from ``size`` so every icon scales together; the + stroke is a single colour passed by the caller (a theme token), never a + literal. Unknown names fall back to a neutral square so a typo degrades + to a box instead of an exception. + """ + pad = max(2, size // 6) + x0, y0, x1, y1 = pad, pad, size - pad, size - pad + w = 2 + line = lambda *pts: canvas.create_line(*pts, fill=colour, width=w) + rect = lambda a, b, c, d: canvas.create_rectangle( + a, b, c, d, outline=colour, width=w + ) + + if name == "device": # monitor: screen + stand + rect(x0, y0, x1, y1 - 5) + mid = (x0 + x1) / 2 + line(mid, y1 - 5, mid, y1 - 1) + line(mid - 4, y1 - 1, mid + 4, y1 - 1) + elif name == "install": # arrow down into a tray + mid = (x0 + x1) / 2 + line(mid, y0, mid, y1 - 5) + line(mid, y0, mid - 4, y0 + 4) + line(mid, y0, mid + 4, y0 + 4) + line(x0, y1 - 2, x1, y1 - 2) + line(x0, y1 - 2, x0, y1 - 5) + line(x1, y1 - 2, x1, y1 - 5) + elif name == "health": # check mark in a circle + canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w) + line(x0 + 3, (y0 + y1) / 2, (x0 + x1) / 2 - 1, y1 - 4) + line((x0 + x1) / 2 - 1, y1 - 4, x1 - 3, y0 + 4) + elif name == "info": # small i in a circle + canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w) + mid = (x0 + x1) / 2 + line(mid, y0 + 4, mid, y0 + 5) + line(mid, y0 + 7, mid, y1 - 4) + elif name == "tool": # sliders: two horizontal tracks with knobs + third = (y1 - y0) / 3 + line(x0, y0 + third, x1, y0 + third) + line(x0, y0 + 2 * third, x1, y0 + 2 * third) + canvas.create_oval(x0 + 2, y0 + third - 3, x0 + 8, y0 + third + 3, + outline=colour, width=w) + canvas.create_oval(x1 - 8, y0 + 2 * third - 3, x1 - 2, + y0 + 2 * third + 3, outline=colour, width=w) + elif name == "wave": # signal arcs from a dot + canvas.create_oval(x0 + 1, y1 - 5, x0 + 5, y1 - 1, + fill=colour, outline=colour) + canvas.create_arc(x0 - 1, y0 + 3, x1 - 3, y1 + 1, start=270, extent=90, + outline=colour, width=w, style="arc") + canvas.create_arc(x0 + 3, y0 - 1, x1 + 1, y1 - 3, start=270, extent=90, + outline=colour, width=w, style="arc") + elif name == "fleet": # four small squares: many machines + mx, my = (x0 + x1) / 2, (y0 + y1) / 2 + rect(x0, y0, mx - 1, my - 1) + rect(mx + 1, y0, x1, my - 1) + rect(x0, my + 1, mx - 1, y1) + rect(mx + 1, my + 1, x1, y1) + elif name == "activity": # clock: recent runs + canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w) + mid = ((x0 + x1) / 2, (y0 + y1) / 2) + line(*mid, *mid, mid[0], y0 + 5) + line(*mid, *mid, mid[0] + 4, mid[1] + 1) + elif name == "warn": # triangle with a mark + mid = (x0 + x1) / 2 + line(mid, y0 + 1, x1 - 1, y1 - 1) + line(x1 - 1, y1 - 1, x0 + 1, y1 - 1) + line(x0 + 1, y1 - 1, mid, y0 + 1) + line(mid, y0 + 6, mid, y0 + 10) + line(mid, y1 - 5, mid, y1 - 4) + elif name == "shield": # privilege outline + mid = (x0 + x1) / 2 + line(x0 + 1, y0 + 1, mid, y0 + 1) + line(mid, y0 + 1, x1 - 1, y0 + 1) + line(x1 - 1, y0 + 1, x1 - 1, y0 + 7) + line(x1 - 1, y0 + 7, mid, y1 - 1) + line(mid, y1 - 1, x0 + 1, y0 + 7) + line(x0 + 1, y0 + 7, x0 + 1, y0 + 1) + elif name == "chevron": # row affordance: there is a next step + line(x0 + 2, y0 + 2, x1 - 2, (y0 + y1) / 2) + line(x1 - 2, (y0 + y1) / 2, x0 + 2, y1 - 2) + else: # neutral box: a missing icon is visible, never fatal + rect(x0 + 2, y0 + 2, x1 - 2, y1 - 2) + + +def line_icon(parent, name: str, size: int = 22, colour: str | None = None, + bg: str | None = None): + """A canvas carrying one line icon (offline, no image assets).""" + canvas = tk.Canvas( + parent, + width=size, + height=size, + background=bg or theme.BG_PANEL, + highlightthickness=0, + borderwidth=0, + ) + draw_icon(canvas, name, size, colour or theme.TEXT_MUTED) + return canvas + + +# --------------------------------------------------------------------------- +# Segmented control -- exactly one scope is on +# --------------------------------------------------------------------------- +class SegmentedControl: + """Three-way scope switch: [Cihaz | Dalga | Tüm filo]. + + Exactly one segment is selected and the selection is always visible, so + the operator can never believe a device is targeted while the fleet is. + Implemented on ttk buttons (keyboard-focusable, native tab order); the + selected segment carries the accent style, the rest the quiet style. + """ + + def __init__(self, parent, styles: Mapping[str, str], + options: Sequence[tuple[str, str]], + initial: str, + command: Callable[[str], None] | None = None) -> None: + self.styles = styles + self.command = command + self.frame = ttk.Frame(parent, style=styles["panel"]) + self._buttons: dict[str, Any] = {} + self._value = initial + for key, label in options: + button = ttk.Button(self.frame, text=label, + command=lambda k=key: self.set(k)) + button.pack(side="left", padx=(0, theme.SP_1)) + self._buttons[key] = button + self._paint() + + def _paint(self) -> None: + for key, button in self._buttons.items(): + if key == self._value: + button.configure(style=self.styles["accent_button"], + state="disabled") + else: + button.configure(style=self.styles["button"], state="normal") + + def set(self, key: str, notify: bool = True) -> None: + if key not in self._buttons or key == self._value: + return + self._value = key + self._paint() + if notify and self.command is not None: + self.command(key) + + def get(self) -> str: + return self._value + + def grid(self, **kwargs) -> None: # honour the caller's geometry + self.frame.grid(**kwargs) + + def pack(self, **kwargs) -> None: + self.frame.pack(**kwargs) + + +# --------------------------------------------------------------------------- +# Collapsible panel -- long detail hidden until asked for +# --------------------------------------------------------------------------- +class CollapsiblePanel: + """A titled section whose body folds away (command preview, details).""" + + def __init__(self, parent, styles: Mapping[str, str], title: str, + *, expanded: bool = False, + show_label: str = "Göster", + hide_label: str = "Gizle") -> None: + self.styles = styles + self.show_label = show_label + self.hide_label = hide_label + self.expanded = expanded + self.frame = ttk.Frame(parent, style=styles["panel"]) + self.toggle = ttk.Button(self.frame, style=styles["quiet_button"], + command=self.toggle_body) + self.toggle.pack(fill="x") + self.body = ttk.Frame(self.frame, style=styles["panel"]) + self._title = title + self._paint() + + def _paint(self) -> None: + marker = "▾" if self.expanded else "▸" + action = self.hide_label if self.expanded else self.show_label + self.toggle.configure(text=f"{marker} {self._title} — {action}") + if self.expanded: + self.body.pack(fill="both", expand=True, + pady=(theme.SP_2, 0)) + else: + self.body.pack_forget() + + def toggle_body(self) -> None: + self.expanded = not self.expanded + self._paint() + + def set_expanded(self, expanded: bool) -> None: + if expanded != self.expanded: + self.toggle_body() + + def pack(self, **kwargs) -> None: + self.frame.pack(**kwargs) + + def grid(self, **kwargs) -> None: + self.frame.grid(**kwargs) + + +# --------------------------------------------------------------------------- +# Meta grid -- label/value pairs with one shared label column +# --------------------------------------------------------------------------- +def meta_grid(parent, styles: Mapping[str, str], + pairs: Sequence[tuple[str, str]], + label_width: int = 14) -> Any: + """Two-column fact block (İŞLEM/HEDEF/KAPSAM/ETKİ); labels right-aligned.""" + frame = ttk.Frame(parent, style=styles["panel"]) + frame.columnconfigure(1, weight=1) + for row, (label, value) in enumerate(pairs): + ttk.Label(frame, text=label, style=styles["field_label"], + width=label_width, anchor="e").grid( + row=row, column=0, sticky="ne", padx=(0, theme.SP_2), + pady=theme.SP_1) + text = ttk.Label(frame, text=value, style=styles["panel_label"], + justify="left") + text.grid(row=row, column=1, sticky="nw", pady=theme.SP_1) + return frame + + +# --------------------------------------------------------------------------- +# Scrollable row container -- the operation browser lives here +# --------------------------------------------------------------------------- +def scrollable_rows(parent, styles: Mapping[str, str]): + """A canvas + scrollbar pair with an inner frame rows pack into.""" + frame = ttk.Frame(parent, style=styles["panel"]) + canvas = tk.Canvas(frame, background=theme.BG_PANEL, + highlightthickness=0, borderwidth=0) + scroll = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scroll.set) + scroll.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + inner = ttk.Frame(canvas, style=styles["panel"]) + window = canvas.create_window((0, 0), window=inner, anchor="nw") + + def on_inner_configure(_event=None) -> None: + canvas.configure(scrollregion=canvas.bbox("all")) + + def on_canvas_configure(event) -> None: + canvas.itemconfigure(window, width=event.width) + + inner.bind("", on_inner_configure) + canvas.bind("", on_canvas_configure) + return frame, inner + + +# --------------------------------------------------------------------------- +# Elapsed timer -- mm:ss for the running state, no widget of its own +# --------------------------------------------------------------------------- +class Elapsed: + """Monotonic stopwatch; the drawer polls :meth:`text` via ``after``.""" + + def __init__(self) -> None: + self._start = 0.0 + self._end = 0.0 + + def start(self) -> None: + self._start = time.monotonic() + self._end = 0.0 + + def stop(self) -> None: + self._end = time.monotonic() + + def seconds(self) -> float: + if not self._start: + return 0.0 + return (self._end or time.monotonic()) - self._start + + def text(self) -> str: + total = int(self.seconds()) + return f"{total // 60:02d}:{total % 60:02d}" + + +# --------------------------------------------------------------------------- +# Safety dialogs -- the confirm gate and the typed-token gate, as windows +# --------------------------------------------------------------------------- +def _base_dialog(root, title: str, min_width: int = 560): + dialog = tk.Toplevel(root) + dialog.title(title) + dialog.configure(bg=theme.BG_APP) + dialog.transient(root) + dialog.grab_set() + dialog.minsize(min_width, 200) + dialog.bind("", lambda _e: dialog.destroy()) + return dialog + + +def _dialog_buttons(dialog, styles: Mapping[str, str], + confirm_label: str, danger: bool, + on_confirm, cancel_label: str = "Vazgeç") -> Any: + row = ttk.Frame(dialog, style=styles["frame"]) + row.pack(fill="x", padx=theme.SP_4, pady=theme.SP_4) + cancel = ttk.Button(row, text=cancel_label, + style=styles["quiet_button"], + command=dialog.destroy) + cancel.pack(side="right") + confirm = ttk.Button( + row, text=confirm_label, + style=styles["danger_button"] if danger else styles["accent_button"], + command=on_confirm, + ) + confirm.pack(side="right", padx=(0, theme.SP_2)) + return confirm, cancel + + +def ask_confirmation(root, styles: Mapping[str, str], *, + title: str, + operation: str, + target_label: str, + scope_label: str, + effect: str, + preview_lines: Sequence[str], + confirm_label: str, + scope_warning: str | None = None, + danger: bool = False) -> bool: + """Plan confirmation: OPERATION / TARGET / SCOPE / EFFECT + preview. + + Returns True only on an explicit confirm click. For destructive plans + the Cancel control takes the initial focus and the Return key, so Enter + can never confirm destruction; the dialog closes (False) on Escape. + """ + result: dict[str, bool] = {"value": False} + dialog = _base_dialog(root, title) + + ttk.Label(dialog, text=title, style=styles["dialog_title"], + wraplength=520, justify="left").pack( + padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2), anchor="w") + + if scope_warning: + banner = ttk.Frame(dialog, style=styles["warn_band"]) + banner.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2)) + inner = ttk.Frame(banner, style=styles["warn_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text=scope_warning, + style=styles["warn_label"], + wraplength=480, justify="left").pack(anchor="w") + + grid = meta_grid(dialog, styles, ( + ("İŞLEM", operation), + ("HEDEF", target_label), + ("KAPSAM", scope_label), + ("ETKİ", effect), + )) + grid.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2)) + + preview = CollapsiblePanel(dialog, styles, "Komut önizlemesi", + expanded=danger) + preview.pack(fill="both", expand=True, padx=theme.SP_4) + text = tk.Text( + preview.body, + height=min(12, max(4, len(preview_lines))), + wrap="word", + font=theme.data_font(theme.SIZE_SM), + background=theme.BG_SUNKEN, + foreground=theme.TEXT, + relief="flat", + borderwidth=0, + highlightthickness=0, + padx=theme.SP_3, + pady=theme.SP_2, + cursor="arrow", + takefocus=0, + ) + text.pack(fill="both", expand=True) + for line in preview_lines: + text.insert("end", line + "\n") + text.configure(state="disabled") + + def confirm() -> None: + result["value"] = True + dialog.destroy() + + confirm_btn, cancel_btn = _dialog_buttons( + dialog, styles, confirm_label, danger, confirm) + if danger: + cancel_btn.focus_set() # Enter must never confirm destruction + else: + confirm_btn.focus_set() + dialog.bind("", lambda _e: confirm() if not danger else None) + dialog.wait_window() + return result["value"] + + +def ask_token(root, styles: Mapping[str, str], *, + title: str, + message: str, + scope_warning: str | None = None, + confirm_label: str = "Onayla") -> str | None: + """Typed-token gate: the operator retypes the exact token to proceed. + + Empty input or window close aborts (None). The comparison itself stays + with the caller in ``ui_gui.py``; this window only collects the text. + """ + result: dict[str, str | None] = {"value": None} + dialog = _base_dialog(root, title, min_width=480) + + ttk.Label(dialog, text=title, style=styles["dialog_title"]).pack( + padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2), anchor="w") + if scope_warning: + banner = ttk.Frame(dialog, style=styles["warn_band"]) + banner.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2)) + inner = ttk.Frame(banner, style=styles["warn_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text=scope_warning, + style=styles["warn_label"], + wraplength=420, justify="left").pack(anchor="w") + ttk.Label(dialog, text=message, style=styles["dialog"], + wraplength=440, justify="left").pack( + padx=theme.SP_4, pady=(0, theme.SP_3), anchor="w") + + var = tk.StringVar() + entry = ttk.Entry(dialog, textvariable=var, width=32, + style=styles["entry"]) + entry.pack(padx=theme.SP_4, anchor="w") + entry.focus_set() + + def confirm() -> None: + result["value"] = var.get().strip() or None + dialog.destroy() + + confirm_btn, _cancel_btn = _dialog_buttons( + dialog, styles, confirm_label, False, confirm) + confirm_btn.focus_set() + dialog.bind("", lambda _e: confirm()) + dialog.wait_window() + return result["value"] + + @@ -14516,32 +5315,6 @@ cmd_status() { ufw status verbose; "$DOCKER_POLICY_BIN" --check; iptables -S DOC case "${1:-apply}" in apply) cmd_apply ;; reset) cmd_reset ;; status) cmd_status ;; *) echo "Usage: $0 {apply|reset|status}" >&2; exit 1 ;; esac - -# Purpose: Harden field-device SSH to key-only maintenance access. -# Scope: Applies to the blueforce user; root and password logins stay disabled. -# Safety: Drop-in for sshd_config.d; validate with sshd -t before reload. -# Ref: docs/06-USERS-SSH-AND-PERMISSIONS.md, K-13. - -# No direct root login; privilege is gained only via audited sudo. -PermitRootLogin no - -# Key-only auth: passwords and interactive challenges are disabled. -PasswordAuthentication no -ChallengeResponseAuthentication no -KbdInteractiveAuthentication no -PubkeyAuthentication yes - -# Only the maintenance user may open SSH sessions. -AllowUsers blueforce - -# Reduce idle-session and brute-force exposure. -X11Forwarding no -MaxAuthTries 3 -LoginGraceTime 60 -ClientAliveInterval 300 -ClientAliveCountMax 2 - - @@ -17984,6 +8757,230 @@ flowchart TB ``` + +# 34 — Konsol Görsel Konsepti (Uygulanan Tasarım) + +## 1. Amaç + +Operatör konsolunun (`bfos/ui_gui.py` + `bfos/ui_widgets.py`) görsel +dünyasını tek belgede sabitlemek: uygulama kabuğu, Genel Bakış, hedef +seçici, işlem tarayıcı, ayrıntı paneli, yürütme çekmecesi ve güvenlik +pencereleri. Kavram `docs/33-OPERATOR-CONSOLE.md` §9.2'nin uygulanan +hâlini anlatır; davranış sözleşmesini değiştirmez. + +## 2. Kapsam + +Yalnızca sunum katmanı: koyu başlık bandı, hedef paneli, kenar çubuğu, +sayfalar, rozetler, diyaloglar. Renk/boşluk/yazı değerleri +`bfos/ui_theme.py` üzerinden `web/static/tokens.css` aynasından gelir; +bu belgede renk kodu yoktur. Backend kuralları (`bfos/operations.py`, +`bfos/runner.py`), kapılar ve denetim kaydı kapsam dışıdır — aynen korunur. + +## 3. Kararlar + +1. **Uygulama kabuğu:** başlık bandı + hedef paneli her modda sabittir; + detaylı modda solda 220px kenar çubuğu (5 kategori + Etkinlik), sağda + sayfa alanı, altta yürütme çekmecesi ve eylem çubuğu. +2. **Genel Bakış ilk ekrandır:** 4 durum kartı (Hedef / Cihaz durumu / + Yetki / Envanter) + 3 hızlı işlem (Kur / Durum / Çıkış). Form yok. +3. **Hedef seçici parçalıdır:** `[Cihaz | Dalga | Tüm filo]`; görünür + kapsam tektir, girdiler bağlama duyarlıdır, tüm filo yüksek etkili + uyarı şeridi açar. +4. **İşlem tarayıcı satırlıdır:** ikon + ad + tek satır açıklama + + tür rozeti + durum rozeti + ok. Engelli işlem listede kalır, solar, + nedenini yazar. +5. **Ayrıntı paneli:** başlık, açıklama, HEDEF/KAPSAM/YETKİ/TÜR ızgarası, + koşullu uyarılar, seçenek formu, istek üzerine gerçek backend ile + üretilen komut önizlemesi, birincil eylem. +6. **Onay penceresi özeldir:** İŞLEM/HEDEF/KAPSAM/ETKİ bloğu + monospace + komut + `[Vazgeç]` / işlem adlı düğme. Yıkıcıda odak Vazgeç'tedir. +7. **Yürütme çekmecesi:** durum rozeti + `işlem · hedef · süre`; log + kuyusu salt-okunur kanıttır. Bitiş TAMAM/HATA + süre + çıkış kodudur. +8. **Etkinlik sayfası:** yalnız bu oturumun biten çalışmaları (zaman, + işlem, hedef, sonuç). Sır gösterilmez. + +## 4. Neden Bu Karar? + +Operatör 3 saniyede altı soruya yanıt almalıdır: hedef ne, durumu ne, +yetkim ne, ne yapabilirim, sorun var mı, bir şey çalışıyor mu? Eski +sekme+tablo düzeni bu soruları form kalabalığına gömüyordu. Kabuk her +soruya sabit bir yer verir; güvenlik bilgisi (kapsam, yetki, yıkıcılık) +her ekranda aynı görsel dilde yinelenir. + +## 5. Alternatifler + +- **Sekmeler yerinde kalsın:** reddedildi — 5 sekme + tablo, 48 işlemi + ayırt edilemez kılar ve Genel Bakış'a yer bırakmaz. +- **Web/Electron kabuğu:** reddedildi — çevrimdışı kurulum, `sudo bf` + ile başlatma ve `python3-tk` bağımlılığı korunmalıdır. +- **Koyu tema:** ertelendi — atölye ışığında açık zemin okunur; token + yapısı koyu temaya hazırdır ama öncelik açık temadır. + +## 6. Avantajlar + +- Kapsam her ekranda görünür (başlık + hedef paneli + onay penceresi). +- Yazma işlemleri gizlenmez, kapatılır ve nedeni yazılır (yetki öğretir). +- Tehlike stili yalnız tehlikeli eylemdedir; panel kırmızıya boyanmaz. +- Tüm simgeler Tk canvas çizimidir: çevrimdışı, lisanssız, varlıksız. +- Klavye: sekme sırası görsel sırayı izler, satırlar odaklanabilir, + `Enter` çalıştırır, `Esc` diyalogu kapatır (yıkıcıda asla onaylamaz). + +## 7. Dezavantajlar + +- Özel satır bileşeni `ttk.Treeview` yerleşik sıralamasından vazgeçer: + sütun sıralaması yoktur (arama + kategori yeterlidir). +- Ayrıntıdaki hedef satırı, seçenekli işlemlerde hedef değişince otomatik + tazelenmez (girdi korunur); güncel hedef çalışma anı penceresinde + gösterilir. + +## 8. Riskler + +- **Kapsam yanılgısı:** parçalı denetim + başlık aynası + onaydaki kapsam + şeridi üç katmanlı savunmadır; `current_target()` görünür kapsama göre + hedef üretir (bayi metni dalgayı ezemez). +- **Eski alışkanlık:** sekmeleri arayan operatör için §9.2 haritası ve + Genel Bakış'taki yönlendirme metni geçişi anlatır. + +## 9. Uygulama Planı + +### 9.1 Kabuk iskeleti + +```text ++---------------------------------------------------------------+ +| koyu bant: ürün | hedef | cihaz durumu | yetki | ++---------------------------------------------------------------+ +| şerit (salt-okunur / deneme kipi; etkinse) | ++---------------------------------------------------------------+ +| HEDEF: [Cihaz|Dalga|Tüm filo] + bağlamsal girdi | ++----------------+----------------------------------------------+ +| kenar çubuğu | sayfa (tarayıcı + ayrıntı / etkinlik) | +| +----------------------------------------------+ +| | yürütme çekmecesi (başlık + log kuyusu) | ++---------------------------------------------------------------+ +| eylem çubuğu: [birincil] neden ... durum / araçlar | ++---------------------------------------------------------------+ +``` + +### 9.2 Yedi ekran + +1. **Genel Bakış** (basit mod): 4 kart + Kur/Durum/Çıkış. Yetkisizde Kur + kapalı, nedeni düğmede. +2. **Hedef seçimi:** Cihaz → 8 hane doğrulamalı giriş; Dalga → salt-okunur + liste (`production` seçilince jeton notu); Tüm filo → tehlike şeridi. +3. **İşlem tarayıcı:** arama + satırlar (ikon, ad, açıklama, `YIKICI` / + `salt-okunur`, `açık` / `kapalı — sudo bf`, ok). Seçili satır aksan + dolgulu. +4. **Ayrıntı:** meta ızgara + uyarılar + seçenekler + katlanır komut + önizlemesi + birincil düğme (yıkıcıda kırmızı, engellide kapalı). +5. **Çalışma:** `ÇALIŞIYOR` rozeti + ad + hedef + `mm:ss` + iptal; bitiş + `TAMAM`/`HATA` + süre + çıkış kodu; kayıt Etkinlik'e düşer. +6. **Yıkıcı onay:** İŞLEM/HEDEF/KAPSAM/ETKİ + filo/üretim şeridi + + açık önizleme + `[Vazgeç]` (odak) / işlem adlı kırmızı düğme. +7. **Salt-okunur:** başlık rozeti + turuncu şerit; tüm yazma düğmeleri + kapalı + gerekçeli; `_start_run` kapıyı yeniden denetler. + +### 9.3 Token ve yazı + +Zemin `--bg-app`, paneller beyaz, tek koyu bant; vurgu mavisi yalnız +birincil eylem + seçili satır. Başlık 22, bölüm 18, gövde 15, ikincil 13, +üstveri 12, veri monospace 13. Ritim 4/8/12/16/24/32/48. Kenar çubuğu +220px, satır 44px, denetim 36px. Durum söz + renk + rozet (renk yalnız +asla). + +## 10. Test Planı + +- `tests/test_bfos.py` (155) ve `tests/test_bfos_gui_static.py` (95): + pencere açmaz; katalog, kapılar, önizleme, renk aynası sözleşmeleri. +- Canlı QA (`DISPLAY=:0`): `/tmp/bf-qa.py` geometri taraması (0 hatalı + öğe), 6 sayfa × tarayıcı/ayrıntı, hedef akışları, `import -window` + yakalamaları + Tesseract OCR metin denetimi. +- Mutasyon kanıtı: `ui_widgets.py` içine renk sayesi → renk testi + kırmızıya döner; geri alınır. +- `bash tests/run-all.sh` 12/12; `python3 -m bfos --tui` duman testi. + +## 11. Rollback + +Sunum katmanıdır: `bfos/ui_gui.py`, `bfos/ui_widgets.py`, +`docs/33`, `docs/34` ve bir test iddiası değişir. Geri alma = bu dosyaları +önceki sürüme döndürmek; backend, envanter ve denetim kaydı etkilenmez. +Commitlenmeden çalışılır; değişiklikler çalışma ağacında durur. + +## 12. Kontrol Listesi + +- [x] Kabuk + Genel Bakış + hedef seçici + tarayıcı + ayrıntı + çekmece +- [x] Onay/jeton pencereleri (odak ve varsayılan güvenli) +- [x] Salt-okunur sunumu (gizleme yok, gerekçe her yerde) +- [x] Etkinlik sayfası (oturum içi, sırsız) +- [x] Token/sözdizimi aynası (`tokens.css` ↔ `ui_theme.py`) korunur +- [x] Türkçe metin taraması (teknik adlar dışında İngilizce yok) +- [x] Ekran görüntüleri (`/tmp/bf-shots/r2-*`, `r3-*`, `r4-*`) +- [ ] Vision servisiyle görsel inceleme (servis 500 veriyor; OCR ile + doğrulandı, insan gözüyle bakılmadı) + +## 13. Açık Sorular + +- Koyu tema gerekli mi? (Token yapısı hazır; talep yok.) +- Etkinlik sayfası denetim günlüğünden güvenli özet okumalı mı? + (Bugün yalnız oturum içi; güvenlik garantisi değişmeden olmaz.) + +## Ek: Kabuk durum akışı + +```mermaid +flowchart TD + A[Genel Bakış] -->|Detaylı mod| B[Kenar çubuğu: 5 kategori + Etkinlik] + B --> C[İşlem tarayıcı] + C --> D[İşlem ayrıntısı] + D --> E[Komut önizlemesi] + E --> F{Onay penceresi} + F -->|Vazgeç| D + F -->|Onayla| G[Kapı jetonları] + G -->|iptal| D + G -->|tamam| H[Kesin komut onayı] + H -->|Vazgeç| D + H -->|Onayla| I[Yürütme çekmecesi: ÇALIŞIYOR] + I --> J[TAMAM / HATA + Etkinlik kaydı] +``` + + + +# GUI yüzeyleri: kurulum ve uzaktan yönetim + +## Karar + +Python GUI tek bir "yönetim ekranı" olmaktan çıkarılır. Açılışta iki bağımsız çalışma alanı sunulur: + +1. **Kurulum konsolu** — tek fiziksel/uzak cihazın tanımlanması, ön kontrolü, kurulumu ve doğrulaması. +2. **Uzaktan yönetim portalı** — envanterdeki bilgisayarların izlenmesi, seçilmesi ve kontrollü operasyonların çalıştırılması. + +İki yüzey yalnızca `CatalogueBackend`/gerçek backend sözleşmesini paylaşır. Görünüm, navigation, seçili cihaz, form state'i ve çıktı state'i paylaşılmaz. + +## Aktif kullanıcı ve bayi kimliği + +Geçerli bayi numarası uygulamanın aktif kullanıcı kimliğidir: `bf-`. İlk geçerli kurulum onayında yerel GUI tercihlerine kaydedilir ve sonraki açılışlarda otomatik yüklenir. Kurulum ekranındaki alan kayıtlı durumda kilitlidir; değişiklik yalnızca **Bayi no değiştir** onayıyla açılır. Portalın ayarlar bölümü aynı değişikliği açık onayla yapar. Bu tercih uygulama kimliğidir; işletim sistemindeki `blueforce` bakım hesabını sessizce yeniden adlandırmaz. + +## Kurulum konsolu + +- Tek hedef: 8 haneli bayi numarası. +- Dört adımlı zihinsel model: tanımla → ön kontrol → kur/kaydet → doğrula. +- Filo, dalga ve toplu operasyon kontrolleri gösterilmez. +- Komut backend tarafından üretilir, onaydan önce görünür; canlı çıktı ayrı paneldedir. + +## Uzaktan yönetim portalı + +- Ana yüzey envanter tablosudur; arama ve cihaz seçimi ilk sınıf kontrollerdir. +- Portal navigasyonu kurulum akışından ayrıdır. +- İlk sürüm salt-okunur operasyonları açık tutar; yıkıcı filo operasyonları mevcut güvenlik kapıları ve ayrıntılı operasyon ekranına taşınır. +- Portalın sonraki genişlemesi cihaz detay drawer'ı, operasyon kuyruğu ve denetim kaydıdır. + +## Komut satırı + +```text +python3 -m bfos.ui_gui # çalışma alanı seçimi +python3 -m bfos.ui_gui --install # kurulum konsolu +python3 -m bfos.ui_gui --portal # uzaktan yönetim portalı +``` + + # Blueforce Field OS — Design System @@ -18067,194 +9064,68 @@ write operations when unprivileged, with the reason on the control. structure first; raw output stays available, collapsed. - -/* Blueforce docs theme overrides. Dark mode is the default (see docusaurus.config.ts). */ -:root { - --ifm-color-primary: #4aa8ff; -} - + +# UI work — remaining, measured - -import React from 'react'; -import Link from '@docusaurus/Link'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import Layout from '@theme/Layout'; +What is left after the first pass on the two operator surfaces. Each item was +observed on a real render, not inferred. Kept as a list so a gap is recorded +rather than remembered. -export default function Home() { - const {siteConfig} = useDocusaurusContext(); - return ( - -
    -

    {siteConfig.title}

    -

    {siteConfig.tagline}

    - - Open docs - -
    -
    - ); -} -
    +## Device console (`bfos/ui_gui.py`) - -node_modules/ -build/ -.docusaurus/ -# Generated content (recreated by sync-docs.sh on prestart/prebuild). -docs/*.md -!docs/.gitkeep - +Reviewed against a screenshot of the running window (`spectacle`, 1440x900), +with the token values read back out of `bfos/ui_theme.py`. - +**Landed** — grouping into headed regions; the privilege state moved out of a +monospace paragraph into the header and a banner; the duplicate `Çık` removed; +the output well styled as read-only; status colours from the shared tokens. The +theme file carries the same values as `web/static/tokens.css` (`#1d4ed8`, +`#15803d`, `#b45309`) — verified by comparison, not by assertion. - +**Still true at the time of writing:** - -module.exports = { - presets: [require.resolve('@docusaurus/core/lib/babel/preset')], -}; - +1. **The widgets still read as default ttk.** Inputs, comboboxes, checkbuttons + and the scrollbar keep the renderer's native chrome. `ui_theme.py` selects + `clam` because it honours `background`/`bordercolor` on the elements it + restyles, but a themed *element* is not a redesigned control. Closing this + needs `Style.element_create` work per control, or hand-drawn widgets, and it + is the single largest remaining gap between this console and the web panel. +2. **The product name appears twice.** `root.title("Blueforce Field OS — Yönetim")` + and the in-window header band carry the same words. One of them should go; + the window title is what the taskbar and window switcher show, so the header + band is the better candidate. +3. **`Cihaz durumu: YOK` is ambiguous** when no target is selected: it reads as + a state a device is in rather than "nothing selected yet". +4. **`HAZIR` in the action bar looks like a disabled button** — it is a status + label, but it is styled in the same register as the controls beside it. +5. **The operation list has no unambiguous selected/disabled distinction.** The + first row carries a focus ring while being unavailable; the ring reads as + "actionable" and the reason is only in the row's trailing text. - -import {themes as prismThemes} from 'prism-react-renderer'; -import type {Config} from '@docusaurus/types'; -import type * as Preset from '@docusaurus/preset-classic'; +## Central panel (`web/`) -// 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; - +Reviewed against served HTML and captured pages at 1440x900. - -{ - "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" } -} - +**Fixed in this pass** — the accent and the `KURULDU` state were the same blue +(`--state-provisioned: var(--info)`, and `--info` equals `--accent`), so a +selected row and a state badge were indistinguishable; both now resolve to +different colours, asserted by a test that resolves aliases before comparing. +The wave cell printed the bare code and then a label that already contains it +("P2 P2 — PILOT-2"); it now names each wave once. - -# Blueforce docs site (Docusaurus) +**Still true:** -Static documentation is synced one-way from `../docs/*.md` into `./docs/`; never edit generated `./docs/` directly. - -## Local development - -```bash -cd docs-site -npm install -npm start # http://127.0.0.1:3000 (loopback only) -npm run build -npm run serve # local static preview on 127.0.0.1:3000 -``` - -For an explicitly approved intranet preview, use `npm run start:intranet` or `npm run serve:intranet`; those commands bind `0.0.0.0:3000`. - -## Deploy - -Publish `build/` through the intranet web server. The site has no favicon reference until a maintained favicon asset is added. - - - -// Auto-generated sidebar: every synced Markdown file appears without manual entries. -// @ts-check - -/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ -const sidebars = { - docs: [{type: 'autogenerated', dirName: '.'}], -}; - -export default sidebars; - - - -#!/usr/bin/env bash -# One-way sync: repo docs (../docs/*.md) -> site content (./docs/). -# The repo Markdown files are the single source of truth; never edit ./docs by hand. -set -euo pipefail - -SITE_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC_DIR="$SITE_DIR/../docs" -DST_DIR="$SITE_DIR/docs" - -mkdir -p "$DST_DIR" - -# Copy 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." - - - -{ - "extends": "@docusaurus/tsconfig", - "compilerOptions": { - "baseUrl": "." - } -} +1. **No scroll affordance on the device table.** The page reports 13/13 while + only part of the list is in view, and nothing signals that more rows exist. +2. **The read-only privilege is repeated** in the header note and a second line + at the far right; one is enough. +3. **The `HAZIR` count appears in the loud figure and again in the breakdown.** + Defensible (summary then detail), but worth a decision rather than an + accident. +4. **Timestamps are ISO-8601** (`2026-09-16T06:20:00+03:00`). Precise and + sortable, but slow to scan for a human at a glance. +5. **A `HATA` device offers no next action** — the panel says a person is needed + and stops. For a fleet panel this is the workflow gap to close next. @@ -19295,45 +10166,6 @@ main() { if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi - -[Unit] -Description=Blueforce Field OS first-boot offline provisioning (terminal wizard) -Documentation=file:/usr/share/doc/blueforce/firstboot/README.md -After=multi-user.target -# Prerequisites that must exist before the wizard may run: the seed-provided administrator -# public key is mandatory for the offline installer. The dealer number is NOT a condition, -# because a technician at the field console enters it through the wizard. -ConditionPathExists=/etc/blueforce/admin-authorized-key.pub -# Run once per machine: once the completion marker exists the unit is skipped (not failed). -ConditionPathExists=!/var/lib/blueforce/firstboot-complete - -[Service] -Type=oneshot -Environment=BF_ADMIN_PUBLIC_KEY_FILE=/etc/blueforce/admin-authorized-key.pub -ExecStart=/usr/local/sbin/bf-firstboot -# The wizard reads and writes the field console. If no TTY can be set up (headless unit, -# no console attached) systemd fails this unit and boot continues; the script itself also -# falls back to the --dealer-id/--dealer-id-file path when no terminal is available. -StandardInput=tty-force -StandardOutput=tty -StandardError=tty -TTYPath=/dev/tty1 -TTYReset=yes -TTYVHangup=yes -# Upper bound so a console that nobody answers can never hold the boot job open. The -# wizard's own input read is bounded too (BF_FIRSTBOOT_INPUT_TIMEOUT). -TimeoutStartSec=45min -RemainAfterExit=yes -# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes -# and similar sandboxing are deliberately absent: the installer legitimately writes /etc, -# /usr and /home/blueforce, and those options would break apt/dpkg and the user module. -NoNewPrivileges=yes -PrivateTmp=yes - -[Install] -WantedBy=multi-user.target - - 1.0.0 @@ -60365,45 +51197,6 @@ print(f'Release commit: {commit}') PY
    - -#!/usr/bin/env bash -# Independently verify remote, network, and central evidence; never trusts READY state. -set -euo pipefail -STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}" -RUSTDESK_SYSTEM_CONFIG="${BF_RUSTDESK_SYSTEM_CONFIG:-/etc/rustdesk/RustDesk2.toml}" -RUSTDESK_USER_CONFIG="${BF_RUSTDESK_USER_CONFIG:-/home/blueforce/.config/RustDesk/RustDesk2.toml}" -MESH_CONFIG="${BF_MESH_CONFIG:-/etc/meshagent/meshagent.json}" -MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" -CHECK_MODE=0 -[[ "${1:-}" == --check && $# -eq 1 ]] && CHECK_MODE=1 -[[ $# -eq 0 || "$CHECK_MODE" == 1 ]] || { echo 'Usage: bf-remote-status [--check]' >&2; exit 2; } -[[ "$MAX_AGE" =~ ^[1-9][0-9]*$ ]] || { echo 'BF_WG_HANDSHAKE_MAX_AGE must be positive seconds' >&2; exit 2; } -valid_endpoint() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] && [[ "$1" != *example* && "$1" != *invalid* && "$1" != *'<'* && "$1" != *'>'* && "$1" != localhost* ]]; } -rustdesk_configured() { - local endpoint - endpoint="$(sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$RUSTDESK_SYSTEM_CONFIG" 2>/dev/null | head -1)" - valid_endpoint "$endpoint" && grep -Fqx "relay-server = '$endpoint'" "$RUSTDESK_SYSTEM_CONFIG" && grep -Fqx "rendezvous-server = '$endpoint'" "$RUSTDESK_USER_CONFIG" && grep -Fqx "relay-server = '$endpoint'" "$RUSTDESK_USER_CONFIG" -} -wg_fresh() { wg show wg0 latest-handshakes 2>/dev/null | awk -v now="$(date +%s)" -v max="$MAX_AGE" '$2 > 0 && now-$2 <= max {ok=1} END {exit !ok}'; } -central_verified() { python3 - "$STATE_FILE" <<'PY' -import json,sys -try: - c=json.load(open(sys.argv[1],encoding='utf-8'))['central_verification'] - ok=(c.get('status')=='verified' and bool(c.get('verification_id')) and all(isinstance(c.get(n),dict) and c[n].get('status')=='verified' and bool(c[n].get('verification_id')) for n in ('monitoring','remote','management'))) - raise SystemExit(0 if ok else 1) -except Exception: raise SystemExit(1) -PY -} -fail=0 -systemctl is-enabled --quiet xrdp 2>/dev/null && systemctl is-active --quiet xrdp 2>/dev/null && systemctl is-enabled --quiet xrdp-sesman 2>/dev/null && systemctl is-active --quiet xrdp-sesman 2>/dev/null && xrdp='enabled-active' || { xrdp='not-ready'; fail=1; } -dpkg-query -W -f='${Status}' rustdesk 2>/dev/null | grep -q 'install ok installed' && rustdesk_configured && rustdesk='configured' || { rustdesk='not-ready'; fail=1; } -wg_fresh && wireguard='fresh-handshake' || { wireguard='not-ready'; fail=1; } -if [[ -e "$MESH_CONFIG" ]]; then systemctl is-enabled --quiet meshagent 2>/dev/null && systemctl is-active --quiet meshagent 2>/dev/null && mesh='configured-active' || { mesh='not-ready'; fail=1; }; else mesh='not-configured'; fi -central_verified && central='verified' || { central='PENDING'; fail=1; } -printf 'xRDP: %s\nRustDesk: %s\nWireGuard: %s\nMesh: %s\nCentral verification: %s\n' "$xrdp" "$rustdesk" "$wireguard" "$mesh" "$central" -[[ "$fail" -eq 0 ]] - - #!/usr/bin/env bash # @@ -60812,6 +51605,132 @@ log "wrote ${TARBALL}" echo "${TARBALL}" + +#!/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()) + + #!/usr/bin/env bash # Base APT sources, disable unattended-upgrades, set timezone. @@ -60875,101 +51794,6 @@ fi log "OK: base packages installed, unattended-upgrades disabled, timezone $TIMEZONE" - -#!/usr/bin/env bash -# Blueforce maintenance user, restricted sudoers, mandatory administrator key. -set -euo pipefail -MODULE="03-user" -LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" -CHECK_MODE=0 -[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 -SUDOERS_FILE="/etc/sudoers.d/blueforce" -AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" -BF_ADMIN_PUBLIC_KEY="${BF_ADMIN_PUBLIC_KEY:-}" -BF_ADMIN_PUBLIC_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" -log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; } -valid_key_file() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } -load_admin_key() { - [[ -z "$BF_ADMIN_PUBLIC_KEY" && -n "$BF_ADMIN_PUBLIC_KEY_FILE" && -r "$BF_ADMIN_PUBLIC_KEY_FILE" ]] || { log "FAIL: a seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required; inline keys are refused"; return 1; } - [[ "$(stat -c '%U:%a' "$BF_ADMIN_PUBLIC_KEY_FILE")" =~ ^root:6[04]0$ ]] || { log "FAIL: administrator key file must be root-owned and not group/world writable"; return 1; } - grep -qE '^(ssh-(ed25519|rsa|ecdsa)|sk-ssh-ed25519)' "$BF_ADMIN_PUBLIC_KEY_FILE" && ! grep -q 'PRIVATE KEY' "$BF_ADMIN_PUBLIC_KEY_FILE" || { log "FAIL: administrator key file must contain a public SSH key only"; return 1; } - cat "$BF_ADMIN_PUBLIC_KEY_FILE" -} -if [[ "$CHECK_MODE" -eq 1 ]]; then - id blueforce >/dev/null 2>&1 || { log "FAIL(check): user blueforce missing"; exit 1; } - [[ -f "$SUDOERS_FILE" ]] && visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1 || { log "FAIL(check): sudoers invalid"; exit 1; } - valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; } - log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0 -fi -key_data="$(load_admin_key)" || exit 1 -[[ -n "${key_data//[[:space:]]/}" ]] || { log "FAIL: administrator key is empty"; exit 1; } -tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT -printf '%s\n' "$key_data" > "$tmp_key" -ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; } -if ! id blueforce >/dev/null 2>&1; then useradd -m -s /bin/bash blueforce; fi -passwd -l blueforce >/dev/null 2>&1 || true -install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh -install -m 600 -o blueforce -g blueforce "$tmp_key" "$AUTHORIZED_KEYS" -valid_key_file || { log "FAIL: atomic authorized_keys install did not validate"; exit 1; } -cat > /tmp/blueforce.sudoers <<'EOF' -# Managed by blueforce-installer (03-user). Local edits will be overwritten. -blueforce ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs * -EOF -visudo -cf /tmp/blueforce.sudoers -install -m 440 /tmp/blueforce.sudoers "$SUDOERS_FILE" -log "OK: user blueforce has a validated administrator key and restricted sudoers" - - - -#!/usr/bin/env bash -# Set hostname bf- from validated dealer number. -# Idempotent: safe to re-run. Supports --check for read-only verification. -# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). -set -euo pipefail - -MODULE="04-hostname" -LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" -DEALER_ID="${DEALER_ID:-}" -BF_HOSTNAME="${BF_HOSTNAME:-}" -BF_DEVICE="${BF_DEVICE:-}" -STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" - -log() { - local msg="$1" - mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [04-hostname] $msg" | tee -a "$LOG_FILE" -} - -CHECK_MODE=0 -if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi -[[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; } -[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; } -TARGET="bf-${DEALER_ID}" - -if [[ "$CHECK_MODE" -eq 1 ]]; then - [[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; } - log "OK(check): hostname is $TARGET" - exit 0 -fi - -if [[ "$(hostname)" == "$TARGET" ]]; then - log "hostname already $TARGET, keeping" -else - hostnamectl set-hostname "$TARGET" - if grep -q "127.0.1.1" /etc/hosts; then - sed -i "s/^127.0.1.1.*/127.0.1.1\t$TARGET/" /etc/hosts - else - echo -e "127.0.1.1\t$TARGET" >> /etc/hosts - fi - log "hostname set to $TARGET" -fi -mkdir -p "$STATE_DIR" -echo "$DEALER_ID" > "$STATE_DIR/dealer-id" -echo "bf-${DEALER_ID}" > "$STATE_DIR/hostname" -echo "BF-${DEALER_ID}" > "$STATE_DIR/device-id" -log "OK: identity files written (BF-${DEALER_ID} / bf-${DEALER_ID})" - - #!/usr/bin/env bash # WireGuard local identity: provisioning creates only a device keypair; enrollment owns wg0.conf. @@ -60995,186 +51819,6 @@ if [[ "$OFFLINE" == 1 ]]; then rm -f "$WG_DIR/wg0.conf"; systemctl disable --now log 'OK: WireGuard keypair ready; use bf-enroll --token-stdin to obtain hub configuration' - -#!/usr/bin/env bash -# Harden sshd only after a validated Blueforce administrator key is installed. -set -euo pipefail -MODULE="07-ssh" -LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" -CHECK_MODE=0 -[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 -DROPIN="/etc/ssh/sshd_config.d/99-blueforce.conf" -AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys" -log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [07-ssh] $1" | tee -a "$LOG_FILE"; } -key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } -ADMIN_KEY="${BF_ADMIN_PUBLIC_KEY:-}" -ADMIN_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" -install_admin_key() { - [[ -n "$ADMIN_KEY" || -n "$ADMIN_KEY_FILE" ]] || return 0 - [[ -z "$ADMIN_KEY" ]] || printf '%s\n' "$ADMIN_KEY" > /tmp/blueforce-admin-key - [[ -z "$ADMIN_KEY_FILE" ]] || { [[ -r "$ADMIN_KEY_FILE" ]] || { log "FAIL: BF_ADMIN_PUBLIC_KEY_FILE is unreadable"; exit 1; }; cp "$ADMIN_KEY_FILE" /tmp/blueforce-admin-key; } - ssh-keygen -l -f /tmp/blueforce-admin-key >/dev/null 2>&1 || { log "FAIL: BF_ADMIN_PUBLIC_KEY is not a valid public key"; exit 1; } - install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh - install -m 600 -o blueforce -g blueforce /tmp/blueforce-admin-key "$AUTHORIZED_KEYS" -} -read -r -d '' WANT <<'EOF' || true -# Managed by blueforce-installer (07-ssh). Local edits will be overwritten. -PermitRootLogin no -PasswordAuthentication no -PubkeyAuthentication yes -ChallengeResponseAuthentication no -UsePAM yes -X11Forwarding no -ClientAliveInterval 300 -ClientAliveCountMax 2 -MaxAuthTries 3 -AllowUsers blueforce -EOF -if [[ "$CHECK_MODE" -eq 1 ]]; then - [[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; } - key_ready || { log "FAIL(check): no valid non-empty blueforce authorized_keys"; exit 1; } - grep -qx 'AllowUsers blueforce' "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; } - sshd -t 2>/dev/null || { log "FAIL(check): sshd config test failed"; exit 1; } - log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0 -fi -export DEBIAN_FRONTEND=noninteractive -command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server -install_admin_key -key_ready || { log "FAIL: valid non-empty /home/blueforce/.ssh/authorized_keys or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; } -printf '%s\n' "$WANT" > /tmp/99-blueforce.conf.bf -install -m 644 /tmp/99-blueforce.conf.bf "$DROPIN" -sshd -t || { log "FAIL: sshd -t rejected the new config"; exit 1; } -if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi -systemctl enable "$SVC" >/dev/null 2>&1 || true -systemctl reload-or-restart "$SVC" -log "OK: hardened SSH installed after valid key verification; AllowUsers=blueforce" - - - -#!/usr/bin/env bash -# xRDP + GNOME session, terminal boot default, multi-user. -# Idempotent: safe to re-run. Supports --check for read-only verification. -# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). -set -euo pipefail - -MODULE="08-rdp" -LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" -DEALER_ID="${DEALER_ID:-}" -BF_HOSTNAME="${BF_HOSTNAME:-}" -BF_DEVICE="${BF_DEVICE:-}" -STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" -# shellcheck source=../offline-apt.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" - -log() { - local msg="$1" - mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [08-rdp] $msg" | tee -a "$LOG_FILE" -} - -CHECK_MODE=0 -if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi -if [[ "$CHECK_MODE" -eq 1 ]]; then - dpkg -s xrdp >/dev/null 2>&1 || { log "FAIL(check): xrdp not installed"; exit 1; } - systemctl is-enabled --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not enabled"; exit 1; } - systemctl is-active --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not active"; exit 1; } - systemctl is-enabled --quiet xrdp-sesman 2>/dev/null || { log "FAIL(check): xrdp-sesman not enabled"; exit 1; } - systemctl is-active --quiet xrdp-sesman 2>/dev/null || { log "FAIL(check): xrdp-sesman not active"; exit 1; } - [[ "$(systemctl get-default)" == "multi-user.target" ]] || { log "FAIL(check): default target is not multi-user"; exit 1; } - log "OK(check): xrdp installed+enabled, terminal boot default" - exit 0 -fi - -export DEBIAN_FRONTEND=noninteractive -dpkg -s xrdp >/dev/null 2>&1 || bf_apt_install xrdp -dpkg -s gnome-session >/dev/null 2>&1 || bf_apt_install gnome-session - -# Default GNOME session for RDP logins (per-user override still wins). -if [[ ! -f /etc/skel/.xsession ]]; then - echo "gnome-session" > /etc/skel/.xsession -fi -if id blueforce >/dev/null 2>&1 && [[ ! -f /home/blueforce/.xsession ]]; then - echo "gnome-session" > /home/blueforce/.xsession - chown blueforce:blueforce /home/blueforce/.xsession -fi - -# Module 13 starts xRDP only after the UFW WireGuard-only baseline succeeds. -systemctl set-default multi-user.target -log "OK: xrdp installed and configured; module 13 will enable it after firewall baseline" - - - -#!/usr/bin/env bash -# RustDesk is a required remote channel: only a pinned supplied package is accepted. -set -euo pipefail -MODULE="09-rustdesk" -LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" -STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" -# shellcheck source=../offline-apt.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" -CHECK_MODE=0 -[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 -RUSTDESK_SERVER="${RUSTDESK_SERVER:-}" -RUSTDESK_DEB="${RUSTDESK_DEB:-}" -SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml" -USER_CONFIG="/home/blueforce/.config/RustDesk/RustDesk2.toml" -log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; } -valid_server() { - local endpoint="$1" host port - [[ -n "$endpoint" ]] || return 1 - [[ "$endpoint" != *example* && "$endpoint" != *invalid* && "$endpoint" != *REPLACE* && "$endpoint" != *replace* && "$endpoint" != *'<'* && "$endpoint" != *'>'* && "$endpoint" != localhost* ]] || return 1 - [[ "$endpoint" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] || return 1 - host="${endpoint%%:*}" - [[ -n "$host" ]] || return 1 - if [[ "$endpoint" == *:* ]]; then - port="${endpoint##*:}" - ((10#$port <= 65535)) || return 1 - fi -} -config_endpoint() { - sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$SYSTEM_CONFIG" 2>/dev/null | head -n 1 -} -configured() { - local endpoint - endpoint="$(config_endpoint)" - valid_server "$endpoint" || return 1 - grep -Fqx "rendezvous-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && - grep -Fqx "relay-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && - grep -Fqx "rendezvous-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 && - grep -Fqx "relay-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 -} -if [[ "$CHECK_MODE" -eq 1 ]]; then - dpkg -s rustdesk >/dev/null 2>&1 || { log "FAIL(check): rustdesk remote channel is not installed"; exit 1; } - configured || { log "FAIL(check): RustDesk client config is missing, inconsistent, or uses a placeholder/non-self-hosted endpoint"; exit 1; } - rustdesk --get-id 2>/dev/null | grep -qE '^[0-9]+$' || { log "FAIL(check): RustDesk client cannot provide a device ID"; exit 1; } - log "OK(check): RustDesk remote channel is installed with a self-hosted client configuration" - exit 0 -fi -valid_server "$RUSTDESK_SERVER" || { log "FAIL: RUSTDESK_SERVER must be a non-placeholder self-hosted host or host:port"; exit 1; } -export DEBIAN_FRONTEND=noninteractive -if [[ "${BF_OFFLINE:-0}" == 1 ]]; then - dpkg -s rustdesk >/dev/null 2>&1 || bf_apt_install rustdesk -else - [[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; } - dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; } -fi -for cfgdir in /etc/rustdesk /home/blueforce/.config/RustDesk; do - install -d -m 700 "$cfgdir" - cat > "$cfgdir/RustDesk2.toml" </dev/null || true -configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; } -install -d -m 700 "$STATE_DIR" -rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true -[[ -s "$STATE_DIR/rustdesk-id" ]] || { log "FAIL: RustDesk client did not provide a device ID"; exit 1; } -log "OK: RustDesk pinned package installed and self-hosted channel configured" - - #!/usr/bin/env bash # Docker CE + daemon.json with log rotation, service enabled. @@ -61567,6 +52211,45 @@ systemctl enable blueforce-enroll.timer >/dev/null 2>&1 || true log 'OK: diagnostics installed; enrollment remains pending until an operator supplies a one-time token' + +#!/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" +} + + #!/usr/bin/env bash # Shared local APT transaction helpers. Offline mode never reads remote sources. @@ -64135,1078 +54818,50 @@ used anywhere in `ansible/` or in any Semaphore task template. - [ ] `tests/check-specs.sh` and `tests/check-configs.sh` pass before merge. - -#!/usr/bin/env bash -# Exercise the Field OS lifecycle, enrollment and readiness contracts with mocked local services. -# -# This host has no systemd, no apt and no internet, so every external dependency -# (systemctl, wg, ip, dpkg-query, curl) is a mock in $TMP/bin. Scenarios: -# -# 1 secret-safe enrollment request/header/response path -# 2 offline installer fails closed without a local repository -# 3 failed WireGuard activation rolls back and keeps the prior phase -# 4 ROLLBACK/READY: independent proof, fail-closed without central evidence -# 5 skeleton release manifest is never a production release -# 6 state machine: forward-only, no stage skipping, contract validation -# 7 bf-check-local passes without internet and fails closed on key hygiene -# 8 bf-check-enrollment requires central evidence, peer config and handshake -# 9 replay/backwards protection and atomic 0600 state writes -# 10 systemd attempt: PENDING without a token, bounded retry with one staged -# 11 --check dry run never mutates state or sends a request -# 12 enrollment schemas match the real client behaviour -# 13 boot unit cannot loop, carries no credential and stays bounded -set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BIN="$REPO_ROOT/scripts/diagnostics" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT -fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } -pass() { printf 'OK: %s\n' "$*"; } - -PUBLIC_KEY='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' -SERVER_KEY='BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' -DEALER='12345678' -ENDPOINT='https://control.blueforce.test/enroll' - -make_mock_bin() { - local d="$1" - mkdir -p "$d" - cat > "$d/systemctl" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >> "$MOCK_CALLS" -case "${1:-}" in - is-active) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; - is-enabled) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; - enable|restart) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; - get-default) printf 'multi-user.target\n' ;; - *) exit 0 ;; -esac -EOF - cat > "$d/curl" <<'EOF' -#!/usr/bin/env bash -# Mock curl: records argv, captures the request file and the header fd, then -# either writes the canned response or fails like a transport/protocol error. -set -euo pipefail -request='' output='' header='' code='' -while [[ $# -gt 0 ]]; do - case "$1" in - --data-binary) request="$2"; shift 2 ;; - --output) output="$2"; shift 2 ;; - --write-out) code="$2"; shift 2 ;; - -H|--header) header="$2"; shift 2 ;; - *) printf 'argv=%s\n' "$1" >> "$MOCK_CURL_LOG"; shift ;; - esac -done -[[ "$request" == @* ]] || exit 91 -cat "${request#@}" > "$MOCK_REQUEST_CAPTURE" -[[ "$header" == @* ]] || exit 92 -cat "${header#@}" > "$MOCK_HEADER_CAPTURE" -if [[ "${MOCK_CURL_FAIL:-0}" == 1 ]]; then - printf '%s' "${MOCK_HTTP_CODE:-000}" - exit "${MOCK_CURL_RC:-7}" -fi -printf '%s' "$MOCK_RESPONSE" > "$output" -[[ -z "$code" ]] || printf '%s' "${MOCK_HTTP_CODE:-200}" -EOF - cat > "$d/wg" <<'EOF' -#!/usr/bin/env bash -case "${1:-}" in - show) printf 'peer %s\n' "${MOCK_HANDSHAKE:-0}" ;; - pubkey) IFS= read -r _key || true; printf '%s\n' "${MOCK_PUBLIC_KEY:-}" ;; - *) exit 0 ;; -esac -EOF - cat > "$d/ip" <<'EOF' -#!/usr/bin/env bash -[[ "${MOCK_IP_FAIL:-0}" == 1 ]] && exit 1 -exit 0 -EOF - cat > "$d/dpkg-query" <<'EOF' -#!/usr/bin/env bash -printf 'install ok installed' -EOF - chmod +x "$d"/* -} - -MOCK_BIN="$TMP/bin"; make_mock_bin "$MOCK_BIN" -export PATH="$MOCK_BIN:$PATH" MOCK_CALLS="$TMP/calls" MOCK_CURL_LOG="$TMP/curl-argv" -export MOCK_REQUEST_CAPTURE="$TMP/request" MOCK_HEADER_CAPTURE="$TMP/header" -export MOCK_PUBLIC_KEY="$PUBLIC_KEY" -STATE_DIR="$TMP/state"; WG_DIR="$TMP/wg"; mkdir -p "$STATE_DIR" "$WG_DIR" -printf '%s\n' "$DEALER" > "$STATE_DIR/dealer-id" -printf '%s\n' 'private-test-key' > "$WG_DIR/privatekey" -printf '%s\n' "$PUBLIC_KEY" > "$WG_DIR/publickey" -chmod 600 "$WG_DIR/privatekey" "$WG_DIR/publickey" "$STATE_DIR/dealer-id" -export STATE_DIR WG_DIR BF_STATE_FILE="$STATE_DIR/state.json" -IDEM="$(python3 -c "import hashlib,sys;print(hashlib.sha256(('p-1|BF-'+sys.argv[1]+'|'+sys.argv[2]).encode()).hexdigest())" "$DEALER" "$PUBLIC_KEY")" - -write_state() { printf '%s\n' "$1" > "$BF_STATE_FILE"; chmod 600 "$BF_STATE_FILE"; } -state_field() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2]))" "$BF_STATE_FILE" "$1"; } -central_pending='{"status":"pending","verification_id":"cv-1","monitoring":{"status":"pending","verification_id":"mon-1"},"remote":{"status":"pending","verification_id":"rem-1"},"management":{"status":"pending","verification_id":"mgmt-1"}}' -central_verified='{"status":"verified","verification_id":"cv-1","monitoring":{"status":"verified","verification_id":"mon-1"},"remote":{"status":"verified","verification_id":"rem-1"},"management":{"status":"verified","verification_id":"mgmt-1"}}' -pending_state="{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" -enrolled_state() { printf '{"phase":"ENROLLED","enrollment_status":"ENROLLED","fleet_status":"PENDING","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; } -ready_state() { printf '{"phase":"READY","enrollment_status":"ENROLLED","fleet_status":"READY","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; } -mocked_response() { printf '{"status":"accepted","device_id":"BF-%s","idempotency_key":"%s","enrollment_id":"enr-1","wireguard":{"address":"10.20.0.17/32","endpoint":"wg.blueforce.test:51820","server_public_key":"%s","allowed_ips":"10.20.0.0/24"},"central_verification":%s}' "$DEALER" "$IDEM" "$SERVER_KEY" "$1"; } - -# --- scenario 1: the secret-safe enrollment path ---------------------------- -write_state "$pending_state" -export MOCK_RESPONSE="$(mocked_response "$central_verified")" -printf '%s\n' 'super-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null -[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'successful enrollment must commit ENROLLED' -[[ -s "$WG_DIR/wg0.conf" ]] || fail 'successful enrollment must install wg0.conf' -[[ "$(stat -c '%a' "$WG_DIR/wg0.conf")" == '600' ]] || fail 'wg0.conf must be mode 600' -[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state.json must be mode 600' -grep -q "BF-$DEALER" "$MOCK_REQUEST_CAPTURE" || fail 'curl must consume the generated request file' -grep -Fqx 'Authorization: Bearer super-secret-token' "$MOCK_HEADER_CAPTURE" || fail 'curl must receive the exact supplied token through process substitution' -! grep -R -F 'super-secret-token' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'token persisted outside the transient curl fixture' -! grep -R -F 'super-secret-token' "$MOCK_CURL_LOG" "$STATE_DIR" 2>/dev/null || fail 'token leaked to curl argv, output, or state' -pass 'curl request/header/response path is secret-safe and functional' - -# --- scenario 12 (early check): request body carries public facts only ------- -python3 - "$MOCK_REQUEST_CAPTURE" "$PUBLIC_KEY" <<'PY' -import json, sys -request = json.load(open(sys.argv[1], encoding='utf-8')) -if set(request) != {'device_id', 'wireguard_public_key', 'idempotency_key', 'hardware', 'release'}: - raise SystemExit('unexpected request field set: %s' % sorted(request)) -if request['wireguard_public_key'] != sys.argv[2]: - raise SystemExit('request must carry the local public key') -blob = json.dumps(request).lower() -for banned in ('private', 'secret', 'password', 'passwd', 'bearer', 'authorization'): - if banned in blob: - raise SystemExit('request carries a secret-like field: ' + banned) -PY -! grep -q 'private-test-key' "$MOCK_REQUEST_CAPTURE" || fail 'the WireGuard private key must never be sent' -! grep -q 'super-secret-token' "$MOCK_REQUEST_CAPTURE" || fail 'the one-time token must never be sent in the request body' -pass 'request carries public facts only: no private key, no token, no credential' - -# --- scenario 2: offline installer fails closed ----------------------------- -cat > "$MOCK_BIN/apt-get" <<'EOF' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$MOCK_APT_CALLS" -exit 90 -EOF -chmod +x "$MOCK_BIN/apt-get" -export MOCK_APT_CALLS="$TMP/apt-calls" -if STATE_DIR="$TMP/offline-state" LOG_FILE="$TMP/offline.log" BF_OFFLINE_REPO="$TMP/missing-offline-repo" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/offline.out" 2>&1; then fail 'offline installer must reject a missing local repository before modules'; fi -[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'offline repository rejection must not invoke apt-get' -! grep -Eqi '(curl|wget|https?://|apt[[:space:]]+download)' "$TMP/offline.out" "$TMP/offline.log" 2>/dev/null || fail 'offline rejection must not run or report network package acquisition' -pass 'offline installer fails closed before package modules when local repository is absent' - -# --- scenario 2b: a Git-LFS pointer is not a package ------------------------- -# The bundle pool travels through Git LFS. A checkout made with smudge disabled -# (which bf-bootstrap does deliberately, to avoid an 884 MB download) leaves -# small pointer texts where the .deb files belong. They satisfy every existence -# test, so the installer must recognise them by content and say what to run -- -# otherwise the run dies later inside `sha256sum -c` and reads as a corrupt -# bundle rather than unfetched payload. -STUB_REPO="$TMP/stub-offline-repo" -mkdir -p "$STUB_REPO/pool" -printf 'stub index\n' > "$STUB_REPO/Packages" -printf 'stub index\n' > "$STUB_REPO/Packages.gz" -printf 'FAKE-SHA256SUMS\n' > "$STUB_REPO/SHA256SUMS" -printf 'package\tversion\tarchitecture\tsha256\tsource\n' > "$STUB_REPO/packages.lock.tsv" -printf 'curl\t8.5.0-2ubuntu10\tamd64\t%s\tcurl_8.5.0-2ubuntu10_amd64.deb\n' \ - '0000000000000000000000000000000000000000000000000000000000000000' >> "$STUB_REPO/packages.lock.tsv" -cat > "$STUB_REPO/pool/curl_8.5.0-2ubuntu10_amd64.deb" <<'POINTER' -version https://git-lfs.github.com/spec/v1 -oid sha256:0000000000000000000000000000000000000000000000000000000000000000 -size 285342 -POINTER -rm -f "$MOCK_APT_CALLS" -if STATE_DIR="$TMP/stub-state" LOG_FILE="$TMP/stub.log" BF_OFFLINE_REPO="$STUB_REPO" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/stub.out" 2>&1; then fail 'offline installer must reject a bundle whose pool holds LFS pointers, not packages'; fi -grep -qi 'indirilmemiş\|işaretçi' "$TMP/stub.out" || fail 'the LFS-pointer refusal must name the cause (pointer text, not a .deb)' -grep -q 'git lfs pull' "$TMP/stub.out" || fail 'the LFS-pointer refusal must tell the operator the command that fixes it' -grep -qi 'checksum' "$TMP/stub.out" && fail 'an LFS pointer must not be reported as a checksum failure; that misdiagnoses unfetched payload as corruption' -[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'an LFS-pointer rejection must not invoke apt-get' -pass 'offline installer recognises a Git-LFS pointer and names the remedy' - -# --- scenario 3: activation failure rolls back, phase is preserved ---------- -write_state "$pending_state" -rm -f "$WG_DIR/wg0.conf" -export MOCK_SYSTEMCTL_FAIL=1 -if printf '%s\n' 'another-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin; then fail 'activation failure must fail enrollment'; fi -[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'activation failure must not commit ENROLLED' -[[ "$(state_field enrollment_status)" == 'PENDING' ]] || fail 'activation failure must leave enrollment PENDING' -[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'activation failure must roll back a new config' -[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic state writes must leave no temporary files' -unset MOCK_SYSTEMCTL_FAIL -pass 'state transition rolls back on WireGuard activation failure and stays PENDING' - -# --- scenario 4: READY proof is independent and fails closed ---------------- -printf '%s\n' 'third-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null -[[ -s "$WG_DIR/wg0.conf" ]] || fail 're-enrollment after a rolled-back attempt must install wg0.conf' -RUST_SYSTEM="$TMP/RustDesk-system.toml"; RUST_USER="$TMP/RustDesk-user.toml" -printf "rendezvous-server = 'rustdesk.blueforce.test'\nrelay-server = 'rustdesk.blueforce.test'\n" > "$RUST_SYSTEM" -cp "$RUST_SYSTEM" "$RUST_USER" -export MOCK_HANDSHAKE="$(date +%s)" BF_RUSTDESK_SYSTEM_CONFIG="$RUST_SYSTEM" BF_RUSTDESK_USER_CONFIG="$RUST_USER" BF_MESH_CONFIG="$TMP/no-mesh" -python3 - "$BF_STATE_FILE" "$central_verified" <<'PY' -import json, sys -path, verified = sys.argv[1], json.loads(sys.argv[2]) -state = json.load(open(path, encoding='utf-8')) -state['central_verification'] = verified -json.dump(state, open(path, 'w', encoding='utf-8')) -PY -"$BIN/bf-check-ready" >/dev/null || fail 'ENROLLED state with independent local and central evidence must become ready-check eligible' -python3 - "$BF_STATE_FILE" <<'PY' -import json, sys -path = sys.argv[1] -state = json.load(open(path, encoding='utf-8')) -del state['central_verification'] -json.dump(state, open(path, 'w', encoding='utf-8')) -PY -if "$BIN/bf-check-ready" > "$TMP/ready-failclosed.out" 2>&1; then fail 'missing central verification evidence must fail closed'; fi -if "$BIN/bf-check-enrollment" > "$TMP/enroll-failclosed.out" 2>&1; then fail 'the ENROLLED gate must fail closed without central evidence'; fi -grep -q 'central_verification is missing' "$TMP/enroll-failclosed.out" || fail 'the ENROLLED gate must name the missing central evidence' -pass 'READY proof is independent of persisted READY and fails closed without central evidence' - -# --- scenario 5: skeleton release manifest ---------------------------------- -RELEASE="$TMP/release.yaml" -printf 'release:\n name: blueforce-provisioning-skeleton\n version: 0.1.0\n status: skeleton\n commit: deadbeef\n' > "$RELEASE" -if BF_RELEASE_MANIFEST="$RELEASE" "$BIN/bf-release" > "$TMP/release.out"; then fail 'skeleton release must be nonzero'; fi -grep -q 'UNAPPROVED_SKELETON' "$TMP/release.out" || fail 'skeleton release must be labelled unapproved' -pass 'nested skeleton manifest is not represented as a production release' - -# --- scenario 6: state machine contract ------------------------------------- -write_state "$pending_state" -[[ "$("$BIN/bf-enrollment-status" --field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'canonical reader must report the provisioned phase' -write_state "$(enrolled_state "$central_pending")" -"$BIN/bf-enrollment-status" | grep -qx 'Enrollment: ENROLLED' || fail 'ENROLLED stage must be visible before READY' -write_state "$(ready_state "$central_verified")" -"$BIN/bf-enrollment-status" > "$TMP/ready-status.out" || fail 'READY with verified central evidence must be contract-valid' -grep -qx 'Lifecycle: READY' "$TMP/ready-status.out" || fail 'READY stage must be reported' -grep -qx 'Enrollment: COMPLETE' "$TMP/ready-status.out" || fail 'READY implies a complete enrollment' -grep -qx 'Fleet: READY' "$TMP/ready-status.out" || fail 'READY implies fleet READY' -write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" -if "$BIN/bf-enrollment-status" > "$TMP/skip.out" 2>/dev/null; then fail 'a skipped stage (PROVISIONED_OFFLINE -> READY) must be INVALID'; fi -grep -qx 'Lifecycle: INVALID' "$TMP/skip.out" || fail 'skipped stage must be reported as INVALID' -write_state "$(ready_state "$central_pending")" -if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'READY without verified channels must be INVALID'; fi -write_state "{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\"}" -if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'a state without provisioning_id must be INVALID'; fi -if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-enrollment-status" > "$TMP/absent.out" 2>/dev/null; then fail 'a missing state file must exit non-zero'; fi -grep -qx 'Lifecycle: PENDING' "$TMP/absent.out" || fail 'a missing state file must report PENDING' -pass 'state machine is forward-only, never skips a stage and validates its contract' - -# --- scenario 6b: bf-status lifecycle block --------------------------------- -write_state "$(enrolled_state "$central_pending")" -BF_STATE_FILE="$BF_STATE_FILE" "$BIN/bf-status" > "$TMP/status.out" 2>/dev/null || fail 'bf-status must always exit 0' -grep -qx 'Provisioning : COMPLETE' "$TMP/status.out" || fail 'bf-status must show Provisioning : COMPLETE once central accepted the device' -grep -qx 'Enrollment : ENROLLED' "$TMP/status.out" || fail 'bf-status must show Enrollment : ENROLLED' -grep -qx 'Fleet State : NOT READY' "$TMP/status.out" || fail 'bf-status must show Fleet State : NOT READY before READY' -write_state "$pending_state" -"$BIN/bf-status" > "$TMP/status-pending.out" 2>/dev/null -grep -qx 'Provisioning : PROVISIONED_OFFLINE' "$TMP/status-pending.out" || fail 'bf-status must show the offline provisioning stage' -grep -qx 'Enrollment : PENDING' "$TMP/status-pending.out" || fail 'bf-status must show a pending enrollment' -write_state "$(ready_state "$central_verified")" -"$BIN/bf-status" > "$TMP/status-ready.out" 2>/dev/null -grep -qx 'Fleet State : READY' "$TMP/status-ready.out" || fail 'bf-status must show Fleet State : READY' -write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" -"$BIN/bf-status" > "$TMP/status-invalid.out" 2>/dev/null -grep -q 'lifecycle state invalid' "$TMP/status-invalid.out" || fail 'bf-status must raise a critical alert for an invalid lifecycle state' -pass 'bf-status prints the Provisioning/Enrollment/Fleet State lifecycle block' - -# --- scenario 7: local gate is offline-safe --------------------------------- -write_state "$pending_state" -rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" -"$BIN/bf-check-local" > "$TMP/local.out" 2>&1 || fail 'bf-check-local must pass offline with no handshake and no wg0.conf' -grep -q 'local gates PASS' "$TMP/local.out" || fail 'bf-check-local must report a passing local gate' -! grep -q '^FAIL' "$TMP/local.out" || fail 'bf-check-local must not fail any local gate offline' -"$BIN/bf-check-local" --check >/dev/null 2>&1 || fail 'bf-check-local --check must behave like the default run' -[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'bf-check-local must not create enrollment artifacts' -! grep -Eq '(^|/)(curl|wget|apt|apt-get)( |$)' "$TMP/calls" 2>/dev/null || fail 'bf-check-local must not call network tools' -chmod 644 "$WG_DIR/privatekey" -if "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a world-readable WireGuard private key must fail the local gate'; fi -chmod 600 "$WG_DIR/privatekey" -MOCK_PUBLIC_KEY='CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=' "$BIN/bf-check-local" >/dev/null 2>&1 && fail 'a mismatched WireGuard keypair must fail the local gate' -if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a missing state file must fail the local gate'; fi -pass 'bf-check-local passes offline and fails closed on key hygiene' - -# --- scenario 8: ENROLLED gate needs evidence and a live tunnel ------------- -write_state "$pending_state" -rm -f "$WG_DIR/wg0.conf" -printf '%s\n' 'enrolled-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null -export MOCK_HANDSHAKE="$(date +%s)" -[[ -s "$WG_DIR/wg0.conf" ]] || fail 'a successful enrollment must install the assigned peer configuration' -[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'a successful enrollment must commit ENROLLED' -"$BIN/bf-check-enrollment" >/dev/null || fail 'ENROLLED gates must pass with peer config, live tunnel and a fresh handshake' -MOCK_HANDSHAKE=0 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing WireGuard handshake must fail the ENROLLED gate' -export MOCK_HANDSHAKE="$(date +%s)" -MOCK_IP_FAIL=1 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the ENROLLED gate' -MOCK_IP_FAIL=1 "$BIN/bf-check-ready" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the READY gate' -mv "$WG_DIR/wg0.conf" "$TMP/wg0.conf.keep" -"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing peer configuration must fail the ENROLLED gate' -mv "$TMP/wg0.conf.keep" "$WG_DIR/wg0.conf" -write_state "$pending_state" -"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'PROVISIONED_OFFLINE must never pass the ENROLLED gate' -write_state "$(enrolled_state "$central_pending")" -"$BIN/bf-check-enrollment" >/dev/null || fail 'a device with pending central channels is still ENROLLED' -if "$BIN/bf-check-ready" >/dev/null 2>&1; then fail 'pending central channels must never produce READY'; fi -pass 'bf-check-enrollment requires central evidence, peer configuration and a fresh handshake' - -# --- scenario 9: replay and backwards protection ---------------------------- -write_state "$pending_state" -printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null -REPLAY_BEFORE="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")" -if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin > "$TMP/replay.out" 2>&1; then fail 'a second enrollment without --reenroll must be refused'; fi -grep -q 'already ENROLLED' "$TMP/replay.out" || fail 'the replay refusal must explain the ENROLLED state' -REPLAY_AFTER="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")" -[[ "$REPLAY_BEFORE" == "$REPLAY_AFTER" ]] || fail 'a refused replay must not touch the state file' -printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll >/dev/null || fail '--reenroll must re-apply enrollment' -[[ "$(state_field phase)" == 'ENROLLED' ]] || fail '--reenroll must never move the phase backwards' -write_state "$(ready_state "$central_verified")" -if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll > "$TMP/ready-refusal.out" 2>&1; then fail 'a READY device must refuse enrollment even with --reenroll'; fi -grep -q 'never move backwards' "$TMP/ready-refusal.out" || fail 'the READY refusal must explain the forward-only rule' -[[ "$(state_field phase)" == 'READY' ]] || fail 'the READY refusal must not change the phase' -[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state writes must stay at mode 600' -[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic writes must not leave temporary files' -pass 'replay and backwards transitions are refused, state writes are atomic and 0600' - -# --- scenario 10: systemd attempt and staged token -------------------------- -write_state "$pending_state" -rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" -BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/pending.out" 2>&1 || fail 'the boot attempt must exit 0 while nothing is staged' -grep -q 'PENDING' "$TMP/pending.out" || fail 'the boot attempt must report PENDING' -[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a pending boot attempt must not install a config' -BF_STATE_FILE="$TMP/absent.json" BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/unprovisioned.out" 2>&1 || fail 'an unprovisioned device must exit 0 so the unit never loops' -grep -q 'PENDING' "$TMP/unprovisioned.out" || fail 'an unprovisioned device must report PENDING' -BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 || fail 'a boot attempt without central configuration must exit 0' -printf '%s\n' 'staged-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null || fail '--stage must store the token' -[[ "$(stat -c '%a' "$STATE_DIR/enrollment.token")" == '600' ]] || fail 'the staged token must be mode 600' -MOCK_CURL_FAIL=1 MOCK_CURL_RC=7 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/offline-attempt.out" 2>&1 && fail 'an unreachable endpoint must fail the attempt so the bounded retry can run' -[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'a transient failure must keep the staged token for the bounded retry' -[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'a failed attempt must keep the lifecycle at PROVISIONED_OFFLINE' -[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a failed attempt must not leave a WireGuard config behind' -unset MOCK_CURL_FAIL MOCK_CURL_RC -MOCK_CURL_FAIL=1 MOCK_CURL_RC=22 MOCK_HTTP_CODE=403 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 && fail 'a definitive rejection must fail the attempt' -[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'a definitive rejection must discard the consumed token' -unset MOCK_CURL_FAIL MOCK_CURL_RC MOCK_HTTP_CODE -printf '%s\n' 'staged-secret-token-two' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null -touch -d '2 hours ago' "$STATE_DIR/enrollment.token" -BF_ENROLLMENT_TOKEN_MAX_AGE=1800 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'an expired staged token must exit 0 as PENDING' -[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'an expired staged token must be discarded' -printf '%s\n' 'staged-secret-token-three' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll-now" --stage >/dev/null || fail 'the technician wrapper must stage a token' -[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'bf-enroll-now --stage must leave the token staged' -BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'the boot attempt must enroll when a valid token is staged and central is reachable' -[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'the boot attempt must commit ENROLLED' -[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'the staged token must be deleted after use' -! grep -R -F 'staged-secret-token-three' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'the staged token must not leak into state, logs or argv' -pass 'the boot attempt stays PENDING without a token and joins automatically with one' - -# --- scenario 11: --check dry run ------------------------------------------- -write_state "$pending_state" -rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" -BEFORE="$(cat "$BF_STATE_FILE")" -BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --check > "$TMP/check.out" 2>&1 || fail '--check must succeed on a valid pre-enrollment device' -grep -q 'would enroll' "$TMP/check.out" || fail '--check must describe the planned transition' -[[ "$BEFORE" == "$(cat "$BF_STATE_FILE")" ]] || fail '--check must not touch the state file' -[[ ! -e "$WG_DIR/wg0.conf" ]] || fail '--check must not write a WireGuard config' -[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail '--check must not stage a token' -"$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1 && fail '--check must fail without an endpoint' -if BF_ENROLLMENT_ENDPOINT='https://example.invalid/enroll' "$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1; then fail '--check must reject placeholder endpoints'; fi -pass '--check validates without mutating or sending anything' - -# --- scenario 12: schemas match the client behaviour ------------------------ -python3 - "$REPO_ROOT" "$MOCK_REQUEST_CAPTURE" "$MOCK_RESPONSE" <<'PY' -import json, sys -root, request_capture, response_sample = sys.argv[1:4] -request_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-request.schema.json', encoding='utf-8')) -response_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-response.schema.json', encoding='utf-8')) -request = json.load(open(request_capture, encoding='utf-8')) -response = json.loads(response_sample) -if set(request_schema['properties']) != set(request_schema['required']): - raise SystemExit('request schema must close every property it requires') -if set(request) != set(request_schema['required']): - raise SystemExit('captured request does not match the request schema') -for section, keys in (('hardware', ('machine_id', 'product_uuid', 'sys_vendor')), ('release', ('os_release', 'kernel'))): - if set(request_schema['properties'][section]['required']) != set(keys): - raise SystemExit('%s schema fields drifted' % section) - if set(request[section]) != set(keys): - raise SystemExit('captured %s does not match the schema' % section) -required_response = set(response_schema['required']) -if set(response) - set(response_schema['properties']): - raise SystemExit('canned response carries fields the schema forbids') -if not required_response <= set(response): - raise SystemExit('canned response is missing required fields') -if set(response['wireguard']) != set(response_schema['properties']['wireguard']['required']): - raise SystemExit('WireGuard response fields drifted') -if set(response['central_verification']) != set(response_schema['properties']['central_verification']['required']): - raise SystemExit('central verification fields drifted') -blob = json.dumps(response_schema).lower() -for banned in ('private_key', 'privkey', 'password', 'bearer'): - if banned in blob: - raise SystemExit('response schema must not define a secret field: ' + banned) -PY -! grep -q 'BF-12345678' "$REPO_ROOT/provisioning/enrollment/README.md" || fail 'provisioning docs must not embed a device identity' -pass 'enrollment schemas match the implemented request and response contract' - -# --- scenario 13: boot unit cannot loop and carries no credential ----------- -UNIT="$REPO_ROOT/config/systemd/blueforce-enroll.service" -TIMER="$REPO_ROOT/config/systemd/blueforce-enroll.timer" -grep -q '^Restart=on-failure$' "$UNIT" || fail 'the boot unit must retry only failures' -grep -q '^Restart=always' "$UNIT" && fail 'the boot unit must never restart unconditionally' -grep -q '^RestartSec=' "$UNIT" || fail 'the boot unit must delay its bounded retries' -grep -q '^StartLimitBurst=' "$UNIT" || fail 'the boot unit must bound its retries' -grep -q '^StartLimitIntervalSec=' "$UNIT" || fail 'the boot unit must bound its retry window' -grep -q 'ExecStart=/usr/local/bin/bf-enroll --systemd' "$UNIT" || fail 'the boot unit must run the controlled attempt' -grep -q 'Unit=blueforce-enroll.service' "$TIMER" || fail 'the timer must trigger the enrollment attempt' -grep -q 'OnBootSec=' "$TIMER" || fail 'the timer must attempt shortly after boot' -! grep -Eqi '(token|password|secret|bearer)[[:space:]]*=' "$UNIT" "$TIMER" || fail 'the units must not embed a credential' -exec_lines="$(grep -E '^Exec[A-Za-z]*=' "$UNIT" || true)" -[[ -n "$exec_lines" ]] || fail 'the boot unit must define its attempt command' -! printf '%s\n' "$exec_lines" | grep -Eqi 'reboot|shutdown' || fail 'the boot unit must never reboot or shut down the device' -grep -q 'never reboots' "$UNIT" || fail 'the boot unit must document that it never reboots the device' -pass 'the enrollment units retry in a bounded way and carry no credential' - -printf 'test-field-os-lifecycle: all scenarios passed\n' - - - -#!/usr/bin/env bash -# Static and sandboxed behavior test for the first-boot terminal wizard. -# -# It never runs systemd, never touches apt, and never touches the real /var state: every -# scenario works inside a throwaway sandbox with a fake installer. Scenarios that require -# root run in a separate user namespace (unshare -r) and are reported as SKIP when the -# host does not permit that. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TEST_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" -BF="$REPO_ROOT/provisioning/firstboot/bf-firstboot" -UNIT="$REPO_ROOT/provisioning/firstboot/blueforce-firstboot.service" -README_FILE="$REPO_ROOT/provisioning/firstboot/README.md" - -WORK="$(mktemp -d)" -PASSED=0 -FAILED=0 -SKIPPED=0 - -trap 'rm -rf "$WORK"' EXIT - -ok() { - if [[ -n "${BF_TEST_RESULTS:-}" ]]; then - printf 'PASS\t%s\n' "$*" >>"$BF_TEST_RESULTS" - else - printf 'OK %s\n' "$*" - PASSED=$((PASSED + 1)) - fi -} -bad() { - if [[ -n "${BF_TEST_RESULTS:-}" ]]; then - printf 'FAIL\t%s\n' "$*" >>"$BF_TEST_RESULTS" - else - printf 'FAIL %s\n' "$*" >&2 - FAILED=$((FAILED + 1)) - fi -} -skip() { - if [[ -n "${BF_TEST_RESULTS:-}" ]]; then - printf 'SKIP\t%s\n' "$*" >>"$BF_TEST_RESULTS" - else - printf 'SKIP %s\n' "$*" - SKIPPED=$((SKIPPED + 1)) - fi -} -section() { [[ -n "${BF_TEST_RESULTS:-}" ]] || printf '\n== %s ==\n' "$*"; } - -assert_rc() { - if [[ "$1" == "$2" ]]; then ok "$3"; else bad "$3 (expected exit $1, got $2)"; fi -} -assert_output() { - if [[ "$2" == *"$1"* ]]; then ok "$3"; else bad "$3 (output lacks: $1)"; fi -} -assert_no_output() { - if [[ "$2" != *"$1"* ]]; then ok "$3"; else bad "$3 (output unexpectedly contains: $1)"; fi -} -assert_file() { - if [[ -f "$1" ]]; then ok "$2"; else bad "$2 (missing: $1)"; fi -} -assert_no_file() { - if [[ ! -e "$1" ]]; then ok "$2"; else bad "$2 (unexpected path: $1)"; fi -} -assert_eq() { - if [[ "$1" == "$2" ]]; then ok "$3"; else bad "$3 (expected '$1', got '$2')"; fi -} -assert_grep() { - if grep -qF -- "$1" "$2" 2>/dev/null; then ok "$3"; else bad "$3 (pattern not found in $2)"; fi -} -assert_no_grep() { - if grep -qF -- "$1" "$2" 2>/dev/null; then bad "$3 (forbidden pattern found in $2)"; else ok "$3"; fi -} -assert_no_directive() { - if grep -qE "^[[:space:]]*$1=" "$2" 2>/dev/null; then bad "$3 (forbidden unit directive in $2)"; else ok "$3"; fi -} - -ENV_ARGS=() -SB='' -OUT='' -RC=0 - -new_sandbox() { - SB="$(mktemp -d "$WORK/sb.XXXXXX")" - { - printf '#!/usr/bin/env bash\n' - printf 'printf "%%s\\n" "$*" >> "%s/calls.log"\n' "$SB" - } >"$SB/fake-install.sh" - chmod 755 "$SB/fake-install.sh" - printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplePublicKeyForStaticFirstbootTest field@blueforce\n' >"$SB/key.pub" - chmod 600 "$SB/key.pub" - ENV_ARGS=( - "BF_FIRSTBOOT_MARKER=$SB/marker" - "STATE_DIR=$SB/state" - "BF_INSTALLER_PATH=$SB/fake-install.sh" - "BF_ADMIN_PUBLIC_KEY_FILE=$SB/key.pub" - "BF_REIDENTITY_AUDIT_LOG=$SB/audit.log" - "BF_FIRSTBOOT_INPUT_TIMEOUT=10" - ) -} - -installer_calls() { - if [[ -f "$SB/calls.log" ]]; then wc -l <"$SB/calls.log" | tr -d ' '; else printf '0'; fi -} -installer_args() { - if [[ -f "$SB/calls.log" ]]; then cat "$SB/calls.log"; else printf ''; fi -} -sandbox_listing() { - (cd "$SB" && ls -1A | sort | tr '\n' ',') -} -write_marker() { - printf '%s\n' "$1" >"$SB/marker" - chmod 600 "$SB/marker" -} -marker_content() { - if [[ -f "$SB/marker" ]]; then tr -d '[:space:]' <"$SB/marker"; else printf ''; fi -} -file_mode() { - if [[ -e "$1" ]]; then stat -c '%a' "$1"; else printf ''; fi -} - -# Non-interactive invocation: stdin is /dev/null, so the wizard can never prompt. -run_bf() { - set +e - OUT="$(timeout 60 env "${ENV_ARGS[@]}" "$@" 2>&1 &1)" - RC=$? - set -e -} - -# Same, but with no bytes written to the pseudo terminal at all (timeout path). -run_bf_tty_silent() { - local inner - inner="$(pty_command "$@")" - set +e - OUT="$(printf '' | env SHELL=/bin/bash "${ENV_ARGS[@]}" timeout 60 script -qec "$inner" /dev/null 2>&1)" - RC=$? - set -e -} - -# --------------------------------------------------------------------------- -# Source, unit and documentation checks (no execution). -# --------------------------------------------------------------------------- -check_sources() { - section 'static: script, unit, README' - - [[ -x "$BF" ]] && ok 'bf-firstboot is executable' || bad 'bf-firstboot is executable' - assert_eq '#!/usr/bin/env bash' "$(head -n 1 "$BF")" 'bf-firstboot has a bash shebang' - if bash -n "$BF" 2>/dev/null; then ok 'bf-firstboot parses (bash -n)'; else bad 'bf-firstboot parses (bash -n)'; fi - assert_grep 'set -euo pipefail' "$BF" 'bf-firstboot uses strict mode' - assert_grep '^[0-9]{8}$' "$BF" 'bf-firstboot validates the dealer number with the shared regex' - assert_grep '# BLUEFORCE FIELD OS' "$BF" 'bf-firstboot prints the field console banner' - assert_grep "wizard_write '> '" "$BF" 'bf-firstboot prompts with the wizard input marker' - assert_grep '/dev/tty' "$BF" 'bf-firstboot opens /dev/tty when stdin is not a terminal' - assert_grep '/etc/blueforce/dealer-id' "$BF" 'bf-firstboot supports the seeded dealer id file' - assert_grep '--offline' "$BF" 'bf-firstboot calls the installer offline-only' - assert_grep 'blueforce-install.sh' "$BF" 'bf-firstboot targets the local installer' - assert_grep 'BF_FIRSTBOOT_TEST_MODE' "$BF" 'bf-firstboot keeps the mutation-free test mode' - assert_grep '--resume' "$BF" 'bf-firstboot resumes a partial install' - assert_grep '--force-reidentity' "$BF" 'bf-firstboot exposes the admin re-identity path' - assert_grep 'BF_ALLOW_REIDENTITY' "$BF" 'the re-identity path is gated by an operator switch' - assert_grep 'already completed' "$BF" 'bf-firstboot short-circuits on the completion marker' - - assert_no_grep 'curl' "$BF" 'bf-firstboot fetches nothing from the network' - assert_no_grep 'wget' "$BF" 'bf-firstboot does not download via wget' - assert_no_grep 'set -x' "$BF" 'bf-firstboot never traces (no accidental key dump)' - if grep -nE '(^|[^A-Za-z_])(cat|tee|head|tail|awk|printf|echo|cp|scp)[[:space:]]+[^|]*\$KEY_FILE' "$BF" >/dev/null 2>&1; then - bad 'the administrator key file is never read out into a message or a copy' - else - ok 'the administrator key file is never read out into a message or a copy' - fi - - assert_file "$UNIT" 'the first-boot unit is present' - assert_grep 'ConditionPathExists=/etc/blueforce/admin-authorized-key.pub' "$UNIT" 'the unit requires the administrator public key' - assert_grep 'ConditionPathExists=!/var/lib/blueforce/firstboot-complete' "$UNIT" 'the unit is skipped once the marker exists' - assert_grep 'TimeoutStartSec=' "$UNIT" 'the unit bounds its runtime' - assert_grep 'StandardInput=tty-force' "$UNIT" 'the unit attaches the wizard to the console' - assert_grep 'Type=oneshot' "$UNIT" 'the unit is a one-shot service' - assert_grep 'RemainAfterExit=yes' "$UNIT" 'the unit records its result' - assert_grep 'WantedBy=multi-user.target' "$UNIT" 'the unit runs once at boot' - assert_no_directive 'ProtectSystem' "$UNIT" 'the unit does not sandbox paths the offline installer must write' - assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps /home/blueforce writable for the user module' - - assert_file "$README_FILE" 'the first-boot README is present' - assert_grep 'BLUEFORCE FIELD OS' "$README_FILE" 'the README documents the wizard screen' - assert_grep 'force-reidentity' "$README_FILE" 'the README documents the admin identity change procedure' - assert_grep 'BF_ALLOW_REIDENTITY' "$README_FILE" 'the README documents that identity change is off by default' - assert_grep 'public SSH key' "$README_FILE" 'the README states the administrator key rule' - assert_grep '/var/log/blueforce-reidentity.log' "$README_FILE" 'the README documents the identity change audit record' - assert_grep 'ConditionPathExists' "$README_FILE" 'the README explains the unit conditions' -} - -# --------------------------------------------------------------------------- -# Command line behavior: check mode, validation, automation path, no terminal. -# --------------------------------------------------------------------------- -check_cli() { - section 'cli: validation, check mode, no-terminal fallback' - - new_sandbox - run_bf bash "$BF" --check --dealer-id 12010193 - assert_rc 0 "$RC" 'check mode accepts a valid dealer number' - assert_output 'no changes were made' "$OUT" 'check mode reports that it made no changes' - assert_no_file "$SB/marker" 'check mode writes no marker' - assert_no_file "$SB/state" 'check mode creates no state directory' - assert_eq '0' "$(installer_calls)" 'check mode never runs the installer' - assert_eq 'fake-install.sh,key.pub,' "$(sandbox_listing)" 'check mode leaves the sandbox untouched' - - for candidate in 1234567 123456789 1234abcd '' '12 34 5678'; do - new_sandbox - run_bf bash "$BF" --check --dealer-id "$candidate" - assert_rc 2 "$RC" "invalid dealer number is rejected: '${candidate:-}'" - assert_output '8 digits' "$OUT" "rejection of '${candidate:-}' explains the expected format" - assert_no_file "$SB/marker" "rejection of '${candidate:-}' writes no marker" - done - - new_sandbox - run_bf bash "$BF" --check - assert_rc 2 "$RC" 'check mode without any dealer number fails with a clear error' - assert_no_file "$SB/marker" 'a failed check mode writes no marker' - - new_sandbox - run_bf bash "$BF" --not-a-flag - assert_rc 2 "$RC" 'an unknown argument is rejected' - assert_output 'unknown argument' "$OUT" 'an unknown argument is reported explicitly' - - new_sandbox - run_bf bash "$BF" --dealer-id 1234567 - assert_rc 2 "$RC" 'a 7-digit dealer number never reaches the installer' - assert_no_file "$SB/marker" 'a rejected dealer number writes no marker' - assert_eq '0' "$(installer_calls)" 'a rejected dealer number never runs the installer' - - # Non-interactive mode with no seeded file and no flag must fail closed, without hanging. - new_sandbox - run_bf bash "$BF" --non-interactive - assert_rc 2 "$RC" 'non-interactive mode without any dealer number fails' - if [[ "$OUT" == *'dealer id file is missing or unreadable'* || "$OUT" == *'invalid dealer number'* ]]; then - ok 'non-interactive mode names the unusable dealer id input' - else - bad 'non-interactive mode names the unusable dealer id input' - fi - assert_no_file "$SB/marker" 'the no-input path writes no marker' - - # Explicit file path (automation) is honoured and never opens the wizard. - new_sandbox - printf '12010193\n' >"$SB/dealer-id" - run_bf bash "$BF" --dealer-id-file "$SB/dealer-id" - assert_no_output '# BLUEFORCE FIELD OS' "$OUT" 'the automation path never opens the wizard' - assert_no_output '8 Haneli Bayi Numarası' "$OUT" 'the automation path asks nothing at the console' - assert_eq '0' "$(installer_calls)" 'the automation path runs no installer when it cannot install' - if [[ "$(id -u)" -eq 0 ]]; then - assert_rc 0 "$RC" 'the dealer id file path completes when the test runs as root' - else - assert_rc 1 "$RC" 'the dealer id file path fails closed without root' - assert_output 'must run as root' "$OUT" 'the automation path reports the missing privilege' - assert_no_file "$SB/marker" 'the automation path writes no marker when it cannot install' - fi - - new_sandbox - printf 'nope\n' >"$SB/dealer-id" - run_bf bash "$BF" --dealer-id-file "$SB/dealer-id" - assert_rc 2 "$RC" 'an invalid value in the dealer id file is rejected' - assert_output 'invalid dealer number' "$OUT" 'an invalid file value names the format rule' - - new_sandbox - printf '12010193\n' >"$SB/dealer-id" - run_bf BF_FIRSTBOOT_NONINTERACTIVE=1 bash "$BF" --dealer-id-file "$SB/dealer-id" - assert_no_output '# BLUEFORCE FIELD OS' "$OUT" 'explicit non-interactive mode never opens the wizard' - assert_eq '0' "$(installer_calls)" 'explicit non-interactive mode runs no installer when it cannot install' -} - -# --------------------------------------------------------------------------- -# Wizard behavior through a pseudo terminal. -# --------------------------------------------------------------------------- -check_wizard() { - section 'wizard: pty input, confirmation, attempt limit' - - new_sandbox - run_bf_tty '1234567 -abcd -nope' bash "$BF" - assert_rc 2 "$RC" 'three malformed entries abort the wizard' - assert_output '# BLUEFORCE FIELD OS' "$OUT" 'the wizard shows the field console banner' - assert_output '8 Haneli Bayi Numarası' "$OUT" 'the wizard shows the Turkish dealer number prompt' - assert_output 'deneme 3/3' "$OUT" 'the wizard counts attempts and never accepts malformed input' - assert_output 'not accepted after 3 attempts' "$OUT" 'the wizard aborts with an explicit error after the limit' - assert_no_file "$SB/marker" 'the aborted wizard writes no marker' - assert_eq '0' "$(installer_calls)" 'the aborted wizard never runs the installer' - - new_sandbox - run_bf_tty '1201 0193 -12010193 -e' bash "$BF" - assert_output 'Geçersiz giriş' "$OUT" 'the wizard rejects a malformed entry that contains a space' - assert_output 'deneme 1/3' "$OUT" 'the wizard re-asks once a malformed entry is rejected' - assert_output 'BF-12010193' "$OUT" 'the wizard accepts the corrected entry after a rejection' - - new_sandbox - run_bf_tty '12010193 -e' bash "$BF" - assert_output 'BF-12010193' "$OUT" 'the confirmed wizard shows Device ID BF-' - assert_output 'bf-12010193' "$OUT" 'the confirmed wizard shows hostname bf-' - assert_eq '0' "$(installer_calls)" 'the wizard run does not reach the installer without root' - if [[ "$(id -u)" -eq 0 ]]; then - assert_rc 0 "$RC" 'the confirmed wizard completes when the test runs as root' - else - assert_rc 1 "$RC" 'the confirmed wizard stops at the privilege gate without root' - assert_output 'must run as root' "$OUT" 'the confirmed wizard proceeds past identity validation' - fi - - new_sandbox - run_bf_tty '12010193 -n -12010193 -n -12010193 -n' bash "$BF" - assert_rc 2 "$RC" 'refusing the confirmation three times aborts the wizard' - assert_output 'Onay verilmedi' "$OUT" 'the wizard re-asks when the identity is not confirmed' - assert_eq '0' "$(installer_calls)" 'a refused confirmation never runs the installer' - - new_sandbox - run_bf_tty_silent bash "$BF" - assert_rc 2 "$RC" 'a silent console aborts the wizard instead of waiting forever' - assert_output 'timed out' "$OUT" 'the silent console path explains the timeout' - assert_eq '0' "$(installer_calls)" 'the silent console path never runs the installer' -} - -# --------------------------------------------------------------------------- -# Root-only behavior in a separate user namespace. -# --------------------------------------------------------------------------- -check_root_paths() { - section 'root (namespace): install run, marker, key gate, re-identity' - - new_sandbox - run_bf BF_FIRSTBOOT_TEST_MODE=1 bash "$BF" --dealer-id 12010193 - assert_rc 0 "$RC" 'test mode completes successfully as root' - assert_output "TEST: would run $SB/fake-install.sh --offline --dealer-id 12010193 --yes" "$OUT" 'test mode builds the exact offline installer command' - assert_no_file "$SB/marker" 'test mode is mutation-free (no marker)' - assert_eq '0' "$(installer_calls)" 'test mode never runs the installer' - - new_sandbox - mkdir -p "$SB/state" - printf '01-precheck=OK\n' >"$SB/state/install-state" - run_bf BF_FIRSTBOOT_TEST_MODE=1 bash "$BF" --dealer-id 12010193 - assert_rc 0 "$RC" 'a partial install state is accepted' - assert_output '--resume' "$OUT" 'a partial install is resumed instead of restarted' - - new_sandbox - run_bf bash "$BF" --dealer-id 12010193 - assert_rc 0 "$RC" 'first boot completes with the (fake) installer' - assert_file "$SB/marker" 'the completion marker is written' - assert_eq '600' "$(file_mode "$SB/marker")" 'the marker is mode 600' - assert_eq '12010193' "$(marker_content)" 'the marker records the provisioned dealer number' - assert_eq '--offline --dealer-id 12010193 --yes' "$(installer_args)" 'the installer is called offline with the validated dealer number' - - run_bf bash "$BF" --dealer-id 12010193 - assert_rc 0 "$RC" 'a second run exits successfully' - assert_output 'already completed' "$OUT" 'a provisioned device reports that first boot is done' - assert_eq '1' "$(installer_calls)" 'a provisioned device never runs the installer again' - - run_bf bash "$BF" --dealer-id 87654321 - assert_rc 0 "$RC" 'a different dealer number does not fail the run' - assert_output 'identity changes use the admin procedure' "$OUT" 'a different dealer number points at the admin procedure' - assert_eq '12010193' "$(marker_content)" 'the provisioned identity is unchanged' - assert_eq '1' "$(installer_calls)" 'the installer is not re-run for a different dealer number' - - run_bf_tty '87654321 -e' bash "$BF" - assert_eq '12010193' "$(marker_content)" 'the wizard cannot change a provisioned identity' - assert_eq '1' "$(installer_calls)" 'the wizard does not re-run the installer on a provisioned device' - - new_sandbox - printf -- '-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAB3NzaC1yc2EFIRSTBOOTSECRETBODY\n-----END OPENSSH PRIVATE KEY-----\n' >"$SB/priv.key" - chmod 600 "$SB/priv.key" - run_bf BF_ADMIN_PUBLIC_KEY_FILE="$SB/priv.key" bash "$BF" --dealer-id 12010193 - assert_rc 1 "$RC" 'private key material is refused as an administrator key' - assert_output 'must contain only a public SSH key' "$OUT" 'the refusal states the public-key rule' - assert_no_output 'FIRSTBOOTSECRETBODY' "$OUT" 'no key material is echoed in the error output' - assert_no_output 'PRIVATE KEY' "$OUT" 'the private key header is never echoed' - assert_no_file "$SB/marker" 'a refused key writes no marker' - assert_eq '0' "$(installer_calls)" 'a refused key never runs the installer' - - new_sandbox - chmod 644 "$SB/key.pub" - run_bf bash "$BF" --dealer-id 12010193 - assert_rc 1 "$RC" 'a group/world readable administrator key file is refused' - assert_output 'root-owned' "$OUT" 'the refusal states the ownership and mode rule' - assert_no_file "$SB/marker" 'a refused key mode writes no marker' - - new_sandbox - run_bf BF_ADMIN_PUBLIC_KEY_FILE="$SB/missing.pub" bash "$BF" --dealer-id 12010193 - assert_rc 1 "$RC" 'a missing administrator key file blocks the offline install' - assert_output 'OFFLINE BLOCKED' "$OUT" 'a missing administrator key is reported as an offline block' - - new_sandbox - run_bf bash "$BF" --check --dealer-id 12010193 - assert_rc 0 "$RC" 'check mode succeeds as root' - assert_no_file "$SB/marker" 'check mode writes no marker as root' - assert_no_file "$SB/state" 'check mode creates no state directory as root' - assert_eq '0' "$(installer_calls)" 'check mode runs no installer as root' - assert_eq 'fake-install.sh,key.pub,' "$(sandbox_listing)" 'check mode leaves the sandbox untouched as root' - - new_sandbox - write_marker 12010193 - run_bf bash "$BF" --force-reidentity --dealer-id 55555555 - assert_rc 1 "$RC" 're-identity is refused without the operator gate' - assert_output 'disabled by default' "$OUT" 'the refusal explains that identity change is off by default' - assert_eq '12010193' "$(marker_content)" 'a refused re-identity leaves the marker unchanged' - assert_eq '0' "$(installer_calls)" 'a refused re-identity runs no installer' - assert_no_file "$SB/audit.log" 'a refused re-identity writes no audit record' - - new_sandbox - write_marker 12010193 - run_bf_tty '99999999' BF_ALLOW_REIDENTITY=1 bash "$BF" --force-reidentity --dealer-id 55555555 - assert_rc 2 "$RC" 'a mismatching confirmation aborts the identity change' - assert_output 'confirmation did not match' "$OUT" 'the aborted identity change says why' - assert_eq '12010193' "$(marker_content)" 'the aborted identity change leaves the marker unchanged' - assert_eq '0' "$(installer_calls)" 'the aborted identity change runs no installer' - - new_sandbox - write_marker 12010193 - printf 'not a directory\n' >"$SB/blocker" - run_bf_tty '55555555' BF_ALLOW_REIDENTITY=1 BF_REIDENTITY_AUDIT_LOG="$SB/blocker/audit.log" bash "$BF" --force-reidentity --dealer-id 55555555 - assert_rc 1 "$RC" 'an unwritable audit log fails the identity change closed' - assert_output 'audit log is not writable' "$OUT" 'the failure names the unwritable audit log' - assert_eq '12010193' "$(marker_content)" 'a failed audit leaves the marker unchanged' - assert_eq '0' "$(installer_calls)" 'a failed audit runs no installer' - - new_sandbox - write_marker 12010193 - run_bf_tty '55555555' BF_ALLOW_REIDENTITY=1 bash "$BF" --force-reidentity --dealer-id 55555555 - assert_rc 0 "$RC" 'an admitted identity change completes' - assert_eq '55555555' "$(marker_content)" 'the marker carries the new dealer number' - assert_eq '--offline --dealer-id 55555555 --yes' "$(installer_args)" 'the installer is re-run with the new dealer number' - if compgen -G "$SB/marker.reidentified-*" >/dev/null; then - ok 'the previous identity marker is archived' - else - bad 'the previous identity marker is archived' - fi - assert_output 'AUDIT: identity-change applied' "$OUT" 'the identity change is announced in the run log' - assert_file "$SB/audit.log" 'the identity change writes an audit record' - assert_eq '600' "$(file_mode "$SB/audit.log")" 'the audit record is mode 600' - assert_grep 'identity-change applied old=12010193 new=BF-55555555' "$SB/audit.log" 'the audit record names the old and the new identity' - - new_sandbox - run_bf_tty '12010193 -e' bash "$BF" - assert_rc 0 "$RC" 'the wizard completes the first boot as root' - assert_output '# BLUEFORCE FIELD OS' "$OUT" 'the interactive run shows the banner' - assert_output 'BF-12010193' "$OUT" 'the interactive run shows the Device ID' - assert_eq '12010193' "$(marker_content)" 'the interactive run writes the marker for the confirmed identity' - assert_eq '--offline --dealer-id 12010193 --yes' "$(installer_args)" 'the interactive run calls the installer offline' -} - -if [[ -n "${BF_TEST_RESULTS:-}" ]]; then - # Child mode: only the root-required scenarios run; results go to the parent's file. - cd "$REPO_ROOT" - check_root_paths - exit 0 -fi - -check_sources -check_cli -check_wizard - -section 'root (namespace): install run, marker, key gate, re-identity' -RESULTS_FILE="$WORK/root-results.tsv" -: >"$RESULTS_FILE" -if unshare -r true >/dev/null 2>&1 && [[ "$(unshare -r id -u 2>/dev/null)" == 0 ]]; then - CHILD_LOG="$WORK/child.log" - if unshare -r env BF_TEST_RESULTS="$RESULTS_FILE" bash "$TEST_SCRIPT" >"$CHILD_LOG" 2>&1; then - : - else - printf 'FAIL the privileged (namespace) section did not complete; see the log below\n' >&2 - sed -n '1,20p' "$CHILD_LOG" >&2 - FAILED=$((FAILED + 1)) - fi - if [[ -s "$RESULTS_FILE" ]]; then - while IFS=$'\t' read -r status name; do - case "$status" in - PASS) ok "$name";; - SKIP) skip "$name";; - *) bad "$name";; - esac - done <"$RESULTS_FILE" - else - skip 'the privileged (namespace) section produced no results' - fi -else - skip 'unshare -r is unavailable here: root-only first-boot scenarios were not run' -fi - -printf '\ntest-firstboot-static: %s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED" -if [[ "$FAILED" -ne 0 ]]; then - printf 'test-firstboot-static: FAILED\n' >&2 - exit 1 -fi -printf 'test-firstboot-static: all passed\n' - - - -/* - * 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; -} + +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() @@ -65551,2563 +55206,1788 @@ bfos = "bfos.cli:main" packages = ["bfos"] - -#!/usr/bin/env bash -# -# bf-bootstrap.sh — one-command install of the Blueforce Linux admin toolbox. -# -# Documented entry points: -# curl -fsSL https://raw.githubusercontent.com/emirhanmamak-blueforce/Blueforce-Linux/main/admin/bf-bootstrap.sh | sudo bash -# sudo admin/bf-bootstrap.sh # from a checkout -# -# Steps, in order: -# 1. root check (apply mode only; --check is read-only and needs no root), -# 2. Ubuntu release check against the supported baseline (24.04 / 26.04), -# 3. dependency install: git, curl, ansible-core, -# 4. clone the repository into (default /opt/blueforce-linux) or -# fast-forward an existing checkout (never resets, never discards work), -# 5. link admin/bf-*, scripts/diagnostics/bf-* and scripts/maintenance/bf-* -# into /usr/local/bin (idempotent; a re-run refreshes the links), -# 6. make admin/bf-menu executable so `sudo bf-menu` starts the on-device -# installer menu, -# 7. print a clear summary. -# -# Safety -# * This script writes no secret and installs code only: credentials stay on -# the device and are never fetched by the installer (see admin/bf-creds). -# * Re-running it is safe: the checkout is fast-forwarded and the tool links -# are re-created. Nothing is deleted and no `git reset` is ever performed, -# so local work in the checkout is never discarded. -# * --check reports the same plan without touching the system. -# -set -euo pipefail + +#!/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." "" "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). -REPO_URL="${BF_REPO_URL:-https://github.com/emirhanmamak-blueforce/Blueforce-Linux.git}" -BRANCH_DEFAULT="main" -INSTALL_DIR_DEFAULT="/opt/blueforce-linux" -BIN_DIR="${BF_BIN_DIR:-/usr/local/bin}" -SUPPORTED_UBUNTU=("24.04" "26.04") -# Command name / apt package name pairs for the installer prerequisites. -DEPENDENCY_COMMANDS=(git curl ansible-playbook) -DEPENDENCY_PACKAGES=(git curl ansible-core) +The file is valid POSIX shell *and* valid Python: -CHECK_MODE=0 -ASSUME_YES=0 -BRANCH="$BRANCH_DEFAULT" -DIR="" -LINKED=0 -MENU_READY=0 +* run as ``admin/bf`` (or as the ``bf`` symlink 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 + the Python console is the single operator surface, +* 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; + all logic is in the ``bfos`` package next to it. -usage() { - cat <<'EOF' -Usage: sudo bf-bootstrap.sh [--check] [--dir DIR] [--branch BRANCH] [--yes] [--help] - -Install (or refresh) the Blueforce Linux admin toolbox from git: fetch the -repository, install the prerequisites, link the bf-* tools into -/usr/local/bin, and make `sudo bf-menu` available for the on-device setup. - -Options: - --check Read-only: report what would happen. It never mutates - anything and never requires root. - --dir DIR Checkout directory. Default: the checkout this script lives - in, otherwise /opt/blueforce-linux. - --branch BRANCH Branch to track (default: main). - --yes, -y Do not ask for confirmation. - --help, -h Show this help and exit. - -Environment: - BF_REPO_URL Repository URL (default: the upstream GitHub repository). - BF_INSTALL_DIR Checkout directory; same as --dir. - BF_BIN_DIR Tool link directory (default: /usr/local/bin). - -Notes: - * Installer prerequisites are git, curl and ansible-core (ansible-playbook). - * Supported baseline is Ubuntu 24.04 / 26.04; any other release only warns. - * This script installs code only. It never writes, fetches, or prints a - secret; device credentials are handled by admin/bf-creds. -EOF -} - -say() { printf 'bf-bootstrap: %s\n' "$*"; } -warn() { printf 'bf-bootstrap: WARN: %s\n' "$*" >&2; } -die() { printf 'bf-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; } - -# Git-LFS smudge is disabled for every git operation this script performs. -# -# Why: the offline package pool is ~884 MB of .deb payload tracked through Git -# LFS, and a default clone materialises all of it (measured: 2.7 GB working tree -# against 20 MB with smudge off, a 135x difference). A device that only needs to -# reach the console and the manifest pays that cost on every clone for bytes it -# may never install from. -# -# The trade is explicit: with smudge off the pool files on disk are 131-byte -# pointer texts, and the installer refuses such a bundle rather than installing -# from it ("Nokta dosyası" below). Fetching the payload is therefore a separate, -# deliberate step -- `git lfs pull --include='provisioning/offline-repo/built/pool/*'` -# -- so that "I cloned the repository" and "I have the packages" stay two facts. -# -# This is deliberately the smudge-off half only. Enabling a full `git lfs pull` -# here would reintroduce the 884 MB download this prevents. -export GIT_LFS_SKIP_SMUDGE=1 - -run() { - if [[ "$CHECK_MODE" -eq 1 ]]; then - printf 'bf-bootstrap: check: would run: %s\n' "$*" - return 0 - fi - "$@" -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --check) CHECK_MODE=1; shift ;; - --dir) DIR="${2:?missing value for --dir}"; shift 2 ;; - --branch) BRANCH="${2:?missing value for --branch}"; shift 2 ;; - --yes|-y) ASSUME_YES=1; shift ;; - --help|-h) usage; exit 0 ;; - *) printf 'bf-bootstrap: ERROR: unknown argument: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; - esac -done - -# --------------------------------------------------------------------------- # -# Where does this script live? When it runs from a checkout, that checkout is -# the default target; the piped one-command form has no checkout, so the -# default install directory is used. -# --------------------------------------------------------------------------- # -SCRIPT_SOURCE="${BASH_SOURCE[0]:-}" -if [[ -n "$SCRIPT_SOURCE" && -r "$SCRIPT_SOURCE" ]]; then - SCRIPT_REAL="$(readlink -f "$SCRIPT_SOURCE" 2>/dev/null || printf '%s' "$SCRIPT_SOURCE")" - CANDIDATE="$(cd "$(dirname "$SCRIPT_REAL")/.." 2>/dev/null && pwd || true)" - if [[ -n "$CANDIDATE" && -f "$CANDIDATE/admin/bf-bootstrap.sh" && -d "$CANDIDATE/scripts/diagnostics" ]]; then - CHECKOUT_ROOT="$CANDIDATE" - fi -fi -if [[ -z "$DIR" ]]; then - DIR="${BF_INSTALL_DIR:-${CHECKOUT_ROOT:-$INSTALL_DIR_DEFAULT}}" -fi - -if [[ -z "$BRANCH" ]]; then - die 'branch must not be empty' -fi - -# --------------------------------------------------------------------------- # -# 1. Root check. --check only reports, so it works as an unprivileged user. -# --------------------------------------------------------------------------- # -if [[ "$CHECK_MODE" -eq 1 ]]; then - if [[ "$(id -u)" -ne 0 ]]; then - say 'check: not running as root; apply mode will require sudo' - fi -else - if [[ "$(id -u)" -ne 0 ]]; then - die "must run as root: sudo $0 (or: sudo bf-bootstrap.sh)" - fi -fi - -# --------------------------------------------------------------------------- # -# 2. Ubuntu release check. A different release is only a warning: the goal is -# to install the toolbox, not to block a working host. -# --------------------------------------------------------------------------- # -DISTRO_ID="" -DISTRO_VERSION="" -DISTRO_PRETTY="unknown" -if [[ -r /etc/os-release ]]; then - # shellcheck disable=SC1091 - . /etc/os-release - DISTRO_ID="${ID:-}" - DISTRO_VERSION="${VERSION_ID:-}" - DISTRO_PRETTY="${PRETTY_NAME:-unknown}" -fi -if [[ "$DISTRO_ID" == "ubuntu" ]]; then - SUPPORTED=0 - for release in "${SUPPORTED_UBUNTU[@]}"; do - if [[ "$DISTRO_VERSION" == "$release" ]]; then - SUPPORTED=1 - fi - done - if [[ "$SUPPORTED" -eq 1 ]]; then - say "ubuntu ${DISTRO_VERSION} is a supported baseline" - else - warn "Ubuntu ${DISTRO_VERSION:-unknown} is outside the tested baseline (${SUPPORTED_UBUNTU[*]}); continuing" - fi -else - warn "this is not an Ubuntu host (ID=${DISTRO_ID:-unknown}, ${DISTRO_PRETTY}); the Field OS baseline is Ubuntu ${SUPPORTED_UBUNTU[*]}" -fi - -# --------------------------------------------------------------------------- # -# Operator confirmation. Only a terminal prompts: the documented one-command -# form (`curl ... | sudo bash`) has no terminal on stdin and never blocks. -# --------------------------------------------------------------------------- # -if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 && -t 0 ]]; then - printf 'Install or update the Blueforce toolbox in %s? [y/N] ' "$DIR" - ANSWER="" - read -r ANSWER || ANSWER="" - if [[ ! "$ANSWER" =~ ^[Yy]([Ee][Ss])?$ ]]; then - say 'aborted by the operator' - exit 2 - fi -fi - -# --------------------------------------------------------------------------- # -# 3. Dependencies. -# --------------------------------------------------------------------------- # -MISSING_COMMANDS=() -MISSING_PACKAGES=() -say 'dependency check' -for index in "${!DEPENDENCY_COMMANDS[@]}"; do - if ! command -v "${DEPENDENCY_COMMANDS[$index]}" >/dev/null 2>&1; then - MISSING_COMMANDS+=("${DEPENDENCY_COMMANDS[$index]}") - MISSING_PACKAGES+=("${DEPENDENCY_PACKAGES[$index]}") - fi -done -if [[ ${#MISSING_PACKAGES[@]} -eq 0 ]]; then - say "dependencies present: ${DEPENDENCY_COMMANDS[*]}" -elif [[ "$CHECK_MODE" -eq 1 ]]; then - say "check: would install with apt-get: ${MISSING_PACKAGES[*]} (missing: ${MISSING_COMMANDS[*]})" -else - if ! command -v apt-get >/dev/null 2>&1; then - die "apt-get not found; install these packages first: ${MISSING_PACKAGES[*]}" - fi - say "installing dependencies: ${MISSING_PACKAGES[*]}" - export DEBIAN_FRONTEND=noninteractive - run apt-get update - run apt-get install -y --no-install-recommends "${MISSING_PACKAGES[@]}" -fi - -# --------------------------------------------------------------------------- # -# 4. Repository: clone once, then fast-forward only. -# --------------------------------------------------------------------------- # -update_checkout() { - local remote="" origin_url="" dirty="" - if ! git -C "$DIR" rev-parse --verify --quiet HEAD >/dev/null 2>&1; then - warn "checkout has no commits yet: $DIR; skipping the update" - return 0 - fi - # Prefer `origin`, otherwise accept the checkout's single remote (a local - # clone of this project may name it `github`, as the upstream checkout does). - if git -C "$DIR" remote get-url origin >/dev/null 2>&1; then - remote="origin" - else - remote="$(git -C "$DIR" remote 2>/dev/null | head -n 1 || true)" - fi - if [[ -z "$remote" ]]; then - warn "checkout has no git remote: $DIR; skipping the update" - return 0 - fi - origin_url="$(git -C "$DIR" remote get-url "$remote" 2>/dev/null || true)" - if [[ "$origin_url" != *"Blueforce-Linux"* ]]; then - warn "remote '$remote' looks unexpected: $origin_url" - fi - dirty="$(git -C "$DIR" status --porcelain 2>/dev/null || true)" - if [[ -n "$dirty" ]]; then - warn "local changes present in $DIR; skipping the update (nothing was reset or stashed)" - return 0 - fi - if [[ "$CHECK_MODE" -eq 1 ]]; then - say "check: would fast-forward $DIR to $remote/$BRANCH" - return 0 - fi - say "updating $DIR (fast-forward only, remote $remote)" - if ! git -C "$DIR" fetch --prune --quiet "$remote" "$BRANCH"; then - warn "fetch failed (offline?); keeping the current checkout" - return 0 - fi - if ! git -C "$DIR" merge --ff-only --quiet "$remote/$BRANCH"; then - warn "cannot fast-forward to $remote/$BRANCH; keeping the current checkout (no reset performed)" - fi - return 0 -} - -if [[ -e "$DIR" && ! -d "$DIR" ]]; then - die "install directory exists and is not a directory: $DIR" -fi -if [[ -d "$DIR" ]]; then - if [[ -d "$DIR/.git" ]]; then - say "existing checkout: $DIR" - update_checkout - else - die "directory exists but is not a git checkout; refusing to touch it: $DIR" - fi -elif [[ "$CHECK_MODE" -eq 1 ]]; then - say "check: would clone $REPO_URL (branch $BRANCH) into $DIR" -else - say "cloning $REPO_URL (branch $BRANCH) into $DIR" - run install -d -m 0755 "$(dirname "$DIR")" - run git clone --branch "$BRANCH" --single-branch -- "$REPO_URL" "$DIR" -fi - -# --------------------------------------------------------------------------- # -# 5. Link the tools into /usr/local/bin. Idempotent: `ln -sfn` refreshes an -# existing link, and a re-run after `git pull` needs no extra step. -# --------------------------------------------------------------------------- # -link_tools() { - local source="" base="" - for source in "$DIR"/admin/bf-* "$DIR"/scripts/diagnostics/bf-* "$DIR"/scripts/maintenance/bf-*; do - if [[ ! -f "$source" ]]; then - continue - fi - base="$(basename "$source")" - if [[ "$CHECK_MODE" -eq 1 ]]; then - say "check: would link $source -> $BIN_DIR/$base" - else - chmod 0755 "$source" - ln -sfn "$source" "$BIN_DIR/$base" - fi - LINKED=$((LINKED + 1)) - done -} - -if [[ -d "$DIR" ]]; then - run install -d -m 0755 "$BIN_DIR" - link_tools - if [[ "$LINKED" -eq 0 ]]; then - warn "no bf-* tools found under $DIR (admin/, scripts/diagnostics/, scripts/maintenance/)" - elif [[ "$CHECK_MODE" -eq 0 ]]; then - say "linked $LINKED tools into $BIN_DIR" - fi -elif [[ "$CHECK_MODE" -eq 1 ]]; then - say "check: would link the bf-* tools from $DIR into $BIN_DIR" -fi - -# --------------------------------------------------------------------------- # -# 6. Operator console entry point. -# -# The Python console (`admin/bf` -> bfos) is the primary path; the bash -# `bf-menu` stays linked as a fallback so a host without python3 can still be -# operated. -# --------------------------------------------------------------------------- # -PY_CONSOLE="$DIR/admin/bf" -MENU_SOURCE="$DIR/admin/bf-menu" -MENU_READY=0 - -if [[ -f "$PY_CONSOLE" ]]; then - if [[ "$CHECK_MODE" -eq 1 ]]; then - say "check: would make $PY_CONSOLE executable and link $BIN_DIR/bf" - else - chmod 0755 "$PY_CONSOLE" - ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf" - say "bf ready: $BIN_DIR/bf (Python console)" - fi - MENU_READY=1 -else - warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout" -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. -# --------------------------------------------------------------------------- # -if [[ "$CHECK_MODE" -eq 1 ]]; then - printf '\nbf-bootstrap: check complete -- no changes were made.\n' - printf ' checkout : %s\n' "$DIR" - printf ' branch : %s\n' "$BRANCH" - printf ' tools : %s tool(s) would be linked into %s\n' "$LINKED" "$BIN_DIR" - printf ' next : sudo bf-bootstrap.sh --yes\n' - exit 0 -fi - -REVISION="" -if [[ -d "$DIR/.git" ]]; then - REVISION="$(git -C "$DIR" rev-parse --short HEAD 2>/dev/null || true)" -fi -printf '\nbf-bootstrap: summary\n' -printf ' repository : %s\n' "$DIR" -printf ' branch : %s\n' "$BRANCH" -if [[ -n "$REVISION" ]]; then - printf ' revision : %s\n' "$REVISION" -fi -printf ' tools : %s linked into %s\n' "$LINKED" "$BIN_DIR" -if [[ -x "$BIN_DIR/bf" ]]; then - printf ' console : %s (Python, primary)\n' "$BIN_DIR/bf" -else - printf ' console : pending (admin/bf is not in the checkout yet)\n' -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 - printf ' next : sudo bf\n' -else - printf ' next : sudo bf-menu\n' -fi -printf '\nInstallation complete. To get started: sudo bf\n' -printf 'Kurulum tamam. Başlamak için: sudo bf\n' - - - -"""Command line interface and operator flows for the bfos console. - -Responsibilities: - - * argument parsing (``--full``, ``--dry-run``, ``--check``, ``--repo``, - ``--inventory``, ``--log-file``, ``--version``, ``--lang``, ``--list``, - ``--list-targets``, ``--no-color``), - * repository discovery and the startup checks (inventory, Ansible, audit log), - * the two presentations: the simple default view and ``--full`` detailed mode, - * every operator flow: local install, fleet operations with their gates, the - central preparation screens, the tools and the read-only information. - -Everything that decides *what may run* lives in :mod:`bfos.operations`, and -everything that *runs* lives in :mod:`bfos.runner`; this module only wires the -two together and talks to the operator. +Usage: sudo bfos [--full] [--dry-run] [--check] [--repo YOL] [--version] [--lang tr] """ -from __future__ import annotations - -import argparse import os import sys -from typing import Dict, List, Optional, Sequence, Tuple -from . import config, operations, runner, tui -from .operations import ( - GateStep, - GatesGranted, - Inventory, - Operation, - Refused, - Repository, - Target, -) +MIN_PYTHON = (3, 8) - -class Quit(Exception): - """The operator asked to leave the console.""" - - -# --------------------------------------------------------------------------- -# Argument parsing -# --------------------------------------------------------------------------- -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog=config.PROGRAM_NAME, - description="{0} — {1}".format(config.PROGRAM_DESCRIPTION, config.VERSION), - formatter_class=argparse.RawDescriptionHelpFormatter, - add_help=False, - epilog=( - "Örnekler:\n" - " sudo bfos basit mod: 1) Kur 2) Durum 3) Çıkış\n" - " sudo bfos --full detaylı mod: 21 playbook, merkez hazırlığı, araçlar\n" - " bfos --dry-run --full komutları yalnız göster, hiçbir şey çalıştırma\n" - " bfos --list işlem kataloğu (etkileşimsiz)\n" - " bfos --list-targets envanterdeki dalgalar, gruplar ve cihazlar\n" - "\n" - "Güvenlik: her komut önce ekranda gösterilir ve onay ister; production dalgası\n" - "ayrı onay (+ production_override=true), sürüm kapılı işlemler yazılı dalga onayı,\n" - "yıkıcı işlemler ikinci onay ister. Denetim kaydı: {log} (mod 600).\n" - ).format(log=config.DEFAULT_LOG_FILE), +if sys.version_info < MIN_PYTHON: # pragma: no cover - depends on the interpreter + sys.stderr.write( + "HATA: Python sürümü çok eski: {0}. bfos için Python {1}.{2}+ gerekir.\n".format( + sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1] + ) ) - parser.add_argument("-h", "--help", action="help", help="bu yardı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", - help="grafik arayüzü aç (varsayılan davranış)") - parser.add_argument("--tui", action="store_true", - help="terminal konsolunu aç (grafik arayüz açılamıyorsa yedek)") - parser.add_argument("--full", action="store_true", - help="detaylı mod: tüm kategoriler ve 21 playbook görünür") - parser.add_argument("--dry-run", action="store_true", - help="komutları yaz, hiçbir şey çalıştırma") - parser.add_argument("--check", action="store_true", - help="komutları --check ile üret (salt okunur; değişiklik uygulanmaz)") - parser.add_argument("--repo", metavar="YOL", default="", - help="Blueforce deposu (varsayılan: konsolun bulunduğu depo)") - parser.add_argument("--inventory", metavar="YOL", default="", - help="Ansible envanteri (varsayılan: /ansible/inventory/hosts.yml)") - parser.add_argument("--log-file", metavar="YOL", default=config.DEFAULT_LOG_FILE, - help="denetim kaydı (varsayılan: {0}, mod 600)".format(config.DEFAULT_LOG_FILE)) - parser.add_argument("--lang", metavar="DİL", default=config.DEFAULT_LANG, - help="arayüz dili (yalnız: {0})".format(", ".join(config.SUPPORTED_LANGS))) - parser.add_argument("--no-color", action="store_true", help="ANSI renklerini kapat (NO_COLOR da geçerli)") - parser.add_argument("--list", action="store_true", - help="işlem kataloğunu yaz ve çık (etkileşimsiz)") - parser.add_argument("--list-targets", action="store_true", - help="envanter hedeflerini yaz ve çık (etkileşimsiz)") - return parser - - -def resolve_repo(explicit: str = "") -> Repository: - """Locate the Blueforce checkout. - - An explicit ``--repo`` (or ``BF_REPO``) is a commitment, not a hint: when it - does not look like the repository the console refuses to run instead of - silently falling back to another checkout. - """ - for candidate in (explicit, os.environ.get("BF_REPO", "")): - if not candidate: - continue - repo = Repository(candidate) - if repo.looks_valid(): - return repo - raise Refused(config.MSG_REPO_INVALID.format(path=candidate)) - package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - for candidate in (package_parent, os.getcwd()): - repo = Repository(candidate) - if repo.looks_valid(): - return repo - raise Refused(config.MSG_REPO_INVALID.format(path=package_parent)) - - -# --------------------------------------------------------------------------- -# Application -# --------------------------------------------------------------------------- -class App: - """The operator flows. All I/O goes through :class:`bfos.tui.Console`.""" - - def __init__( - self, - repo: Repository, - console: tui.Console, - runner_: runner.Runner, - inventory: Inventory, - full_mode: bool = False, - check_mode: bool = False, - inventory_error: str = "", - inventory_arg: str = "", - ) -> None: - self.repo = repo - self.console = console - self.runner = runner_ - self.inventory = inventory - self.full_mode = full_mode - self.check_mode = check_mode - self.inventory_error = inventory_error - self.inventory_arg = inventory_arg - - # -- infrastructure ---------------------------------------------------- - def require_inventory(self) -> bool: - """Fleet operations need a readable inventory: explain, never guess.""" - try: - path = self.repo.find_inventory(self.inventory_arg) - except Refused as exc: - self.console.exception(exc) - return False - if path != self.inventory.path: - self.inventory = Inventory.load(path) - return True - - def require_ansible(self) -> bool: - if self.runner.have_ansible(): - return True - self.console.error(config.MSG_ANSIBLE_MISSING) - return False - - def require_root(self) -> bool: - if self.runner.is_root(): - return True - self.console.error(config.MSG_ROOT_REQUIRED) - return False - - def ask_menu(self, screen: tui.Screen) -> Optional[str]: - """Render a screen until a valid key is chosen. - - Returns the chosen key, or ``None`` when the operator goes back. - Raises :class:`Quit` when the operator quits. - """ - while True: - self.console.screen(screen) - outcome, payload = self.console.ask_menu_choice(screen) - if outcome == tui.CHOICE_QUIT: - raise Quit() - if outcome == tui.CHOICE_BACK: - return None - if outcome == tui.CHOICE_ITEM: - return payload - - def pause(self) -> None: - self.console.pause() - - # -- entry point ------------------------------------------------------- - def run(self) -> int: - try: - if self.full_mode: - self.console.note(config.MSG_FULL_MODE_HINT) - self.detailed_loop() - else: - self.simple_loop() - except Quit: - self.console.note(config.MSG_QUIT) - return config.EXIT_OK - - # ------------------------------------------------------------------ - # Simple mode - # ------------------------------------------------------------------ - def simple_loop(self) -> None: - while True: - key = self.ask_menu(tui.simple_menu()) - if key is None: - self.console.note(config.MSG_AT_TOP) - continue - if key == "1": - self.simple_install() - elif key == "2": - self.simple_status() - elif key == "3": - raise Quit() - elif key == "d": - self.full_mode = True - self.console.note(config.MSG_FULL_MODE_HINT) - self.detailed_loop() - else: # pragma: no cover - ask_menu only returns known keys - self.console.warn(config.MSG_UNKNOWN_CHOICE.format(choice=key)) - - def simple_install(self) -> None: - """Guided local install: dealer number, then the installer.""" - self.console.section("Kurulum — bu cihaz") - self.console.note("Bu cihaz Field OS standardına getirilir. Yalnız bu cihaz; filo cihazlarına dokunulmaz.") - dealer = self.ask_dealer() - if dealer is None: - return - try: - installer = self.repo.installer_path() - except Refused as exc: - self.console.exception(exc) - self.pause() - return - offline = self.console.confirm("Çevrimdışı kurulum (yerel paket deposu) kullanılsın mı?") - if offline is None: - raise Quit() - if not self.require_root(): - self.pause() - return - argv = operations.build_install_command( - installer, dealer, offline=offline, check_mode=self.check_mode - ) - label = "kurulum: {0}".format(operations.dealer_to_device_id(dealer)) - self.console.info("Hedef cihaz: {0} ({1})".format( - operations.dealer_to_device_id(dealer), operations.dealer_to_host(dealer))) - self.runner.confirm_and_run(argv, label=label, destructive=True) - self.pause() - - def simple_status(self) -> None: - """Read-only status: identity, provisioning state, health tool.""" - self.console.section("Durum — bu cihaz") - self.console.show_lines(operations.identity_lines()) - self.console.write("") - self.console.show_lines(operations.state_lines()) - self.console.write("") - result = self.run_tool("bf-status", title="bf-status (salt okunur)") - if result is None: - self.console.note("bf-status kurulu değil; yukarıdaki özet gösterildi.") - self.console.note("Bu ekran hiçbir şeyi değiştirmez.") - self.pause() - - def detailed_loop(self) -> None: - while True: - key = self.ask_menu(tui.detailed_menu()) - if key is None: - self.console.note(config.MSG_AT_TOP) - continue - if key == "1": - self.category_install() - elif key == "2": - self.category_devices() - elif key == "3": - self.category_central() - elif key == "4": - self.category_tools() - elif key == "5": - self.category_info() - elif key == "s": - self.full_mode = False - self.console.note(config.MSG_SIMPLE_MODE_HINT) - self.simple_loop() - return - - # ------------------------------------------------------------------ - # Shared input helpers - # ------------------------------------------------------------------ - def ask_dealer(self) -> Optional[str]: - """Ask for a dealer number until it matches ^[0-9]{8}$ (or back/quit).""" - while True: - value = self.console.read(config.MSG_PROMPT_DEALER) - if value is None: - return None - value = value.strip() - if value == "" or value == "0": - return None - if operations.dealer_is_valid(value): - return value - self.console.warn(config.MSG_TARGET_DEALER_INVALID.format(value=value)) - - def ask_extra_vars(self, operation: Operation) -> Optional[List[Tuple[str, str]]]: - """Collect and validate the extra -e variables for one operation.""" - prompt = operation.prompt - if prompt == operations.PROMPT_NONE: - return [] - answers: Dict[str, str] = {} - if prompt == operations.PROMPT_PACKAGES: - self.console.note(config.MSG_PKG_HINT) - value = self.console.read(config.MSG_PROMPT_PACKAGES) - answers["packages"] = "" if value is None else value - elif prompt == operations.PROMPT_SERVICE: - self.console.note(config.MSG_SERVICE_HINT.format(allowlist=" ".join(config.SERVICE_ALLOWLIST))) - value = self.console.read(config.MSG_PROMPT_SERVICE) - answers["service"] = "" if value is None else value - elif prompt == operations.PROMPT_SCRIPT: - self.console.note("İzinli dizinler: {0}".format(" ".join(config.SCRIPT_DIRS))) - path = self.console.read(config.MSG_PROMPT_SCRIPT) - answers["script"] = "" if path is None else path - args = self.console.read(config.MSG_PROMPT_SCRIPT_ARGS) - answers["script_args"] = "" if args is None else args - elif prompt == operations.PROMPT_GRACE: - value = self.console.ask_with_default( - config.MSG_PROMPT_GRACE.format(default=config.DEFAULT_REBOOT_GRACE), - str(config.DEFAULT_REBOOT_GRACE), - ) - answers["grace"] = "" if value is None else value - elif prompt == operations.PROMPT_DISK_PCT: - value = self.console.read(config.MSG_PROMPT_DISK) - answers["disk_pct"] = "" if value is None else value - elif prompt == operations.PROMPT_LOG_LINES: - value = self.console.ask_with_default( - config.MSG_PROMPT_LOG_LINES.format(default=config.DEFAULT_LOG_LINES), - str(config.DEFAULT_LOG_LINES), - ) - answers["log_lines"] = "" if value is None else value - elif prompt == operations.PROMPT_VERSION: - value = self.console.read(config.MSG_PROMPT_VERSION) - answers["expected_version"] = "" if value is None else value - try: - return operations.build_extra_vars(prompt, answers) - except Refused as exc: - self.console.exception(exc) - self.pause() - return None - - # ------------------------------------------------------------------ - # Target selection - # ------------------------------------------------------------------ - def select_target(self, mode: str) -> Optional[Target]: - """Interactive target picker (mirrors bf_target_select).""" - while True: - screen = tui.target_menu() - if mode == config.MODE_RELEASE: - screen = tui.Screen( - title=screen.title, - subtitle="Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez", - entries=screen.entries, - notes=("Yalnız onaylı dalga grupları kabul edilir; filo geneli reddedilir.",), - ) - self.console.screen(screen) - value = self.console.read(config.MSG_PROMPT_TARGET) - if value is None: - return None - choice = value.strip() - if choice == "" or choice == "0": - return None - if choice.lower() == "q": - raise Quit() - if choice == "1": - dealer = self.ask_dealer() - if dealer is None: - continue - word = dealer - elif choice == "2": - word = self.pick_group(mode) - if word is None: - continue - elif choice == "3": - word = "all" - elif choice == "4": - typed = self.console.read("Hedef (bayi no, grup ya da 'all'): ") - if typed is None: - return None - word = typed.strip() - else: - # Anything else is treated as a directly typed target, exactly - # like the bash prompt's "type the target" option. - word = choice - try: - return operations.resolve_target(word, mode, self.inventory) - except Refused as exc: - self.console.exception(exc) - self.pause() - - def pick_group(self, mode: str) -> Optional[str]: - if not self.inventory.resolved: - self.console.warn(config.MSG_INVENTORY_MISSING.format( - dir=self.repo.path(config.INVENTORY_DIR_REL))) - return None - groups = self.inventory.group_names() if mode == config.MODE_ANY else self.inventory.waves_present() - if not groups: - self.console.warn("Envanterde kullanılabilir grup yok.") - return None - self.console.section("Envanterdeki gruplar") - for index, group in enumerate(groups, start=1): - count = len(self.inventory.hosts_of(group)) - suffix = "onaylı dalga" if operations.is_wave(group) else "grup" - self.console.write(tui.render_item(str(index), group, "{0} cihaz, {1}".format(count, suffix), - self.console.palette)) - value = self.console.read("Grup numarası > ") - if value is None: - return None - value = value.strip() - if value.isdigit() and 1 <= int(value) <= len(groups): - return groups[int(value) - 1] - self.console.warn(config.MSG_MENU_RANGE.format(max=len(groups))) - return None - - # ------------------------------------------------------------------ - # Fleet operations - # ------------------------------------------------------------------ - def run_operation(self, operation: Operation) -> None: - """Full flow for one operation: checks, target, gates, execution.""" - try: - scope = self.repo.verify_scope(operation) - except Refused as exc: - self.console.exception(exc) - self.pause() - return - if not self.require_ansible(): - self.pause() - return - if not self.require_inventory(): - self.pause() - return - - self.console.section(operation.label) - self.console.note(operation.description) - if operation.declared_scope == config.SCOPE_RELEASE: - self.console.note("Sürüm kapılı işlem: yalnız onaylı dalga, yazılı dalga onayı zorunlu.") - target = self.select_target(operation.target_mode) - if target is None: - self.console.note(config.MSG_TARGET_NONE) - return - extra_vars = self.ask_extra_vars(operation) - if extra_vars is None: - return - - if scope == config.SCOPE_WAVE and target.kind == operations.KIND_ALL: - self.run_fleet_waves(operation, scope, target, extra_vars) - return - wave = target.wave - if scope in (config.SCOPE_WAVE, config.SCOPE_RELEASE) and not wave: - self.console.error(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=target.limit)) - self.pause() - return - self.gated_run(operation, scope, target, wave, extra_vars) - self.pause() - - def run_fleet_waves( - self, - operation: Operation, - scope: str, - target: Target, - extra_vars: Sequence[Tuple[str, str]], - ) -> None: - """Wave-scoped operation on the whole fleet: one wave at a time. - - The sequence stops at the first wave that is refused or fails, so no - later wave is ever touched by accident. - """ - sequence = operations.wave_sequence(self.inventory) - if not sequence: - self.console.error("Envanterde onaylı dalga yok; filo geneli işlem yapılamaz.") - self.pause() - return - self.console.section("Filo geneli — dalga dalga") - self.console.warn(config.MSG_GATE_WAVE_BY_WAVE.format(order=config.MSG_GATE_ORDER)) - if not self.console.confirm(config.MSG_GATE_START_FIRST): - self.console.note(config.MSG_CANCELLED) - return - for wave, count in sequence: - self.console.section("Dalga '{0}' — {1} cihaz".format(wave, count)) - answer = self.console.read(config.MSG_GATE_WAVE_LOOP_ASK.format(wave=wave, count=count)) - if answer is None: - return - if answer.strip() != wave: - self.console.info(config.MSG_GATE_WAVE_SKIPPED.format(wave=wave)) - self.runner.audit.write(config.AUDIT_CANCEL, "dalga atlandı: {0}".format(wave)) - continue - wave_target = Target( - kind=operations.KIND_GROUP, limit=wave, wave=wave, count=count, - label="dalga '{0}' ({1} cihaz)".format(wave, count), - ) - result = self.gated_run(operation, scope, wave_target, wave, extra_vars, - pause=False, wave_token_already_given=True) - if result is None or not result.ok: - self.console.error(config.MSG_GATE_WAVE_FAILED.format( - wave=wave, rc=(result.rc if result is not None else "-"))) - self.runner.audit.write(config.AUDIT_STOP, "{0} dalgasında durdu: {1}".format( - operation.ident, wave)) - self.pause() - return - self.pause() - - def gated_run( - self, - operation: Operation, - scope: str, - target: Target, - wave: str, - extra_vars: Sequence[Tuple[str, str]], - pause: bool = True, - wave_token_already_given: bool = False, - ): - """Apply the confirmation plan, build the command and run it.""" - try: - wave_gate_supported = self.repo.playbook_uses( - operation.playbook, operations.VAR_WAVE_GATE_CONFIRMED - ) - except Refused as exc: - self.console.exception(exc) - return None - - plan = operations.confirmation_plan( - operation, scope, target, wave=wave, wave_gate_supported=wave_gate_supported - ) - if wave_token_already_given: - # The fleet-wide loop already made the operator type this wave's - # name: it is never asked twice for the same wave. - plan = [step for step in plan if step.key != "wave_token"] - gates = GatesGranted() - if plan: - self.console.section(config.MSG_GATE_PLAN_TITLE) - for step in plan: - granted = self.apply_gate_step(step) - if granted is None: - return None - gates = gates.grant(step) - - self.console.section("İşlem özeti") - self.console.kv("İşlem", operation.label) - self.console.kv("Playbook", "{0}/{1}".format(config.PLAYBOOK_DIR_REL, operation.playbook)) - self.console.kv("Kapsam", scope) - self.console.kv("Envanter", self.inventory.labels()) - self.console.kv("Hedef", target.label) - if wave: - self.console.kv("Dalga", wave) - - try: - argv = operations.build_ansible_command( - self.repo, - self.inventory.path or "", - operation, - scope, - target, - wave=wave, - extra_vars=list(extra_vars), - gates=gates, - check_mode=self.check_mode, - ) - except Refused as exc: - self.console.exception(exc) - if pause: - self.pause() - return None - - self.runner.audit.write( - config.AUDIT_TARGET, - "{0} hedef={1} tür={2} dalga={3}".format( - operation.ident, target.limit, target.kind, wave or "yok"), - ) - result = self.runner.confirm_and_run( - argv, label="{0} — {1}".format(operation.label, target.label), - destructive=operation.destructive, - ) - if pause: - self.pause() - return result - - def apply_gate_step(self, step: GateStep) -> Optional[bool]: - """Ask one confirmation. Returns None when the operator does not grant it.""" - if step.kind == operations.STEP_TOKEN: - granted = self.console.confirm_token(step.message, step.token, step.prompt) - if granted is None: - return None - if not granted: - self.console.warn(self._token_refusal(step)) - self.pause() - return None - return True - granted = self.console.confirm(step.prompt) - if granted is None: - return None - if not granted: - self.console.warn(self._approval_refusal(step)) - self.pause() - return None - return True - - @staticmethod - def _token_refusal(step: GateStep) -> str: - if step.key == "production_token": - return config.MSG_GATE_PRODUCTION_REFUSED - if step.key == "fleet_token": - return config.MSG_GATE_FLEET_REFUSED - return config.MSG_GATE_WAVE_REFUSED - - @staticmethod - def _approval_refusal(step: GateStep) -> str: - if step.key == "production_approval": - return config.MSG_GATE_PRODUCTION_REFUSED - return config.MSG_CANCELLED_RUN - - # ------------------------------------------------------------------ - # Category: Kurulum (this device) - # ------------------------------------------------------------------ - def category_install(self) -> None: - while True: - key = self.ask_menu(tui.install_menu(self.install_items())) - if key is None: - return - if key == "1": - self.install_preflight() - elif key == "2": - self.install_apply(resume=False) - elif key == "3": - self.install_apply(resume=True) - elif key == "4": - self.install_single_module() - elif key == "5": - self.show_file(config.INSTALL_STATE_FILE, "Kurulum modül durumu") - self.pause() - elif key == "6": - self.tail_file(config.INSTALL_LOG_FILE, "Kurulum kaydı (son 40 satır)") - self.pause() - - @staticmethod - def install_items() -> Tuple[Tuple[str, str], ...]: - return ( - ("Ön kontrol (--check)", "kurulum betiği --check; hiçbir şey yazmaz"), - ("Bu cihazı kur", "tam kurulum; bayi numarası sorulur"), - ("Başarısız kurulumu sürdür", "kurulum betiği --resume"), - ("Tek modül çalıştır", "kurulum betiği --only"), - ("Modül durumunu göster", config.INSTALL_STATE_FILE), - ("Kurulum kaydını göster", "{0} son 40 satır".format(config.INSTALL_LOG_FILE)), - ) - - def install_preflight(self) -> None: - installer = self.require_installer() - if installer is None: - return - dealer = self.ask_dealer() - if dealer is None: - return - self.console.note("Salt okunur ön kontrol: --check modunda kayıt, durum ve dizin oluşturulmaz.") - argv = operations.build_install_command(installer, dealer, check_mode=True) - self.runner.run_readonly(argv, title="Ön kontrol: {0}".format( - operations.dealer_to_device_id(dealer))) - self.pause() - - def install_apply(self, resume: bool) -> None: - installer = self.require_installer() - if installer is None: - return - dealer = self.ask_dealer() - if dealer is None: - return - if not self.require_root(): - self.pause() - return - offline = False - if not resume: - answer = self.console.confirm("Çevrimdışı kurulum (yerel paket deposu) kullanılsın mı?") - if answer is None: - raise Quit() - offline = answer - resume_answer = self.console.confirm("Daha önce OK olan modüller atlanılsın mı (--resume)?") - if resume_answer is None: - raise Quit() - resume = resume_answer - argv = operations.build_install_command( - installer, dealer, offline=offline, resume=resume, check_mode=self.check_mode - ) - self.console.info("Hedef cihaz: {0} ({1})".format( - operations.dealer_to_device_id(dealer), operations.dealer_to_host(dealer))) - self.runner.confirm_and_run( - argv, label="kurulum: {0}".format(operations.dealer_to_device_id(dealer)), destructive=True - ) - self.pause() - - def install_single_module(self) -> None: - installer = self.require_installer() - if installer is None: - return - modules = operations.module_names(self.repo) - if not modules: - self.console.warn("Kurulum modülü bulunamadı: scripts/install/installer/modules/") - self.pause() - return - self.console.section("Kurulum modülleri") - for index, name in enumerate(modules, start=1): - self.console.write(tui.render_item(str(index), name, "", self.console.palette)) - value = self.console.read("Modül numarası > ") - if value is None: - return - value = value.strip() - if not value.isdigit() or not (1 <= int(value) <= len(modules)): - self.console.warn(config.MSG_MENU_RANGE.format(max=len(modules))) - self.pause() - return - module = modules[int(value) - 1] - dealer = self.ask_dealer() - if dealer is None: - return - if not self.require_root(): - self.pause() - return - argv = operations.build_install_command( - installer, dealer, only=module, check_mode=self.check_mode - ) - self.runner.confirm_and_run( - argv, label="tek modül ({0}): {1}".format(module, operations.dealer_to_device_id(dealer)), - destructive=True, - ) - self.pause() - - def require_installer(self) -> Optional[str]: - try: - return self.repo.installer_path() - except Refused as exc: - self.console.exception(exc) - self.pause() - return None - - # ------------------------------------------------------------------ - # Category: Cihaz işlemleri (fleet) - # ------------------------------------------------------------------ - def category_devices(self) -> None: - while True: - key = self.ask_menu(tui.devices_menu( - len(operations.REPORT_OPERATIONS), len(operations.MAINTENANCE_OPERATIONS))) - if key is None: - return - if key == "1": - self.operation_list(operations.REPORT_OPERATIONS, - "Salt okunur filo raporları", - "Bu listedeki hiçbir playbook cihazı değiştirmez") - elif key == "2": - self.operation_list(operations.MAINTENANCE_OPERATIONS, - "Filo bakım işlemleri", - "Yıkıcı işlemler iki kez onay ister") - elif key == "3": - if self.require_inventory(): - self.console.show_lines(operations.targets_lines(self.inventory)) - self.pause() - - def operation_list(self, ops: Sequence[Operation], title: str, subtitle: str) -> None: - screen = tui.Screen( - title=title, - subtitle=subtitle, - entries=tuple(tui.item(op.label, op.description) for op in ops), - ) - key = self.ask_menu(screen) - if key is None: - return - index = int(key) - 1 - if 0 <= index < len(ops): - self.run_operation(ops[index]) - - # ------------------------------------------------------------------ - # Category: Merkez hazırlığı - # ------------------------------------------------------------------ - def category_central(self) -> None: - while True: - key = self.ask_menu(tui.central_menu()) - if key is None: - return - if key == "1": - self.console.show_lines(operations.central_readiness_lines( - self.repo, self.inventory, self.inventory_error)) - elif key == "2": - self.console.show_lines(operations.central_endpoint_lines(self.repo)) - elif key == "3": - self.console.show_lines(operations.central_probe_lines(self.repo)) - elif key == "4": - self.console.show_lines(operations.wave_gate_state_lines(self.inventory)) - elif key == "5": - self.console.show_lines(operations.central_checklist_lines()) - self.pause() - - # ------------------------------------------------------------------ - # Category: Araçlar - # ------------------------------------------------------------------ - def category_tools(self) -> None: - while True: - key = self.ask_menu(tui.tools_menu()) - if key is None: - return - if key == "1": - self.tool_git_pull() - elif key == "2": - self.console.show_lines(operations.environment_lines( - self.repo, self.runner.audit.path or self.runner.audit.requested, - self.inventory, self.runner.dry_run, self.runner.check_mode)) - self.console.kv("Denetim kaydı modu", operations.file_mode( - self.runner.audit.path or "")) - self.pause() - elif key == "3": - self.console.show_lines(catalogue_lines()) - self.pause() - elif key == "4": - self.run_tool("bf-check-local", args=("--check",)) - self.pause() - elif key == "5": - self.run_tool("bf-check-enrollment", args=("--check",)) - self.pause() - elif key == "6": - self.run_tool("bf-check-ready", args=("--check",)) - self.pause() - elif key == "7": - if self.run_tool("bf-diagnostics") is None: - self.run_tool("bf-status") - self.pause() - elif key == "8": - self.run_tool(config.TOOL_LIVE_HW_CHECK) - self.pause() - elif key == "9": - self.run_tool("bf-remote-status", args=("--check",)) - self.pause() - elif key == "10": - self.run_tool("bf-release") - self.pause() - elif key == "11": - self.tool_hardware_inventory() - elif key == "12": - self.tool_support_bundle() - elif key == "13": - self.tail_file(self.runner.audit.path or config.DEFAULT_LOG_FILE, - "Konsol denetim kaydı") - self.pause() - - def run_tool(self, tool: str, args: Sequence[str] = (), title: str = ""): - """Run a repository diagnostics tool (read-only screen).""" - try: - path = self.repo.tool_path(tool) - except Refused as exc: - self.console.exception(exc) - return None - argv = [path] + list(args) - return self.runner.run_readonly(argv, title=title or tool) - - def tool_git_pull(self) -> None: - self.runner.confirm_and_run( - ["git", "-C", self.repo.root, "pull", "--ff-only"], - label="depoyu güncelle ({0})".format(self.repo.root), destructive=True, - ) - self.console.note("Depo değişmiş olabilir; konsolu yeniden başlatın.") - self.pause() - - def tool_hardware_inventory(self) -> None: - try: - path = self.repo.tool_path("bf-hardware-inventory.sh") - except Refused as exc: - self.console.exception(exc) - self.pause() - return - dealer = self.ask_dealer() - if dealer is None: - return - out_dir = "/var/lib/blueforce/inventory" if self.runner.is_root() \ - else os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), - "blueforce", "inventory") - argv = [path, "--dealer-id", dealer, "--out-dir", out_dir] - self.runner.run_readonly(argv, title="Donanım envanteri: {0}".format( - operations.dealer_to_device_id(dealer))) - self.pause() - - def tool_support_bundle(self) -> None: - self.run_tool("bf-support-bundle", args=("--out-dir", config.SUPPORT_BUNDLE_OUT_DIR)) - self.pause() - - # ------------------------------------------------------------------ - # Category: Cihaz bilgisi (read-only) - # ------------------------------------------------------------------ - def category_info(self) -> None: - while True: - key = self.ask_menu(tui.info_menu()) - if key is None: - return - if key == "1": - self.console.show_lines(operations.identity_lines()) - elif key == "2": - self.run_tool("bf-status") - elif key == "3": - self.console.show_lines(operations.state_lines()) - elif key == "4": - self.info_network() - elif key == "5": - self.info_resources() - elif key == "6": - self.info_packages() - elif key == "7": - self.info_services() - elif key == "8": - self.info_journal() - elif key == "9": - if self.require_inventory(): - self.console.show_lines(operations.targets_lines(self.inventory)) - elif key == "10": - self.info_resolve_target() - continue - self.pause() - - def _run_readonly(self, argv: Sequence[str], title: str) -> None: - self.runner.run_readonly(argv, title=title) - - def info_network(self) -> None: - self._run_readonly(["ip", "-brief", "address"], "Ağ arayüzleri") - self._run_readonly(["ip", "route", "show", "default"], "Varsayılan rotalar") - if os.path.isfile("/etc/resolv.conf"): - self._run_readonly(["awk", "/^nameserver/{print}", "/etc/resolv.conf"], - "DNS sunucuları (yalnız nameserver satırları)") - self._run_readonly(["wg", "show", "wg0", "listen-port"], "WireGuard dinleme portu") - self._run_readonly(["sh", "-c", "wg show wg0 peers | wc -l"], "WireGuard eş sayısı") - self._run_readonly(["wg", "show", "wg0", "latest-handshakes"], - "WireGuard el sıkışma zamanları (anahtar yok)") - self.console.note("WireGuard özel ve ön paylaşımlı anahtarları asla istenmez, gösterilmez ve kaydedilmez.") - - def info_resources(self) -> None: - self._run_readonly(["df", "-h"], "Dosya sistemleri") - self._run_readonly(["free", "-h"], "Bellek") - self._run_readonly(["uptime"], "Yük ortalaması ve açık kalma süresi") - - def info_packages(self) -> None: - self._run_readonly( - ["dpkg-query", "-W", "-f=${Package} ${Version} ${Status}\n", - "docker-ce", "wireguard", "rustdesk", "xrdp", "openssh-server"], - "Kurulu sabit paketler", - ) - - def info_services(self) -> None: - self._run_readonly( - ["systemctl", "is-active", "ssh", "sshd", "xrdp", "rustdesk", "docker", - "wg-quick@wg0", "meshagent", "cron"], - "Servis durumu (salt okunur)", - ) - - def info_journal(self) -> None: - self._run_readonly(["journalctl", "-p", "err", "-n", "20", "--no-pager"], - "Son journal hataları (20)") - - def info_resolve_target(self) -> None: - self.console.section("Hedefi çözümle") - self.console.note("Bayi numarası, grup adı, 'all' ya da bf- ana makine adı kabul edilir.") - value = self.console.read(config.MSG_PROMPT_TARGET) - if value is None or not value.strip(): - return - if not self.require_inventory(): - self.pause() - return - mode = config.MODE_ANY - answer = self.console.confirm("Bu hedef dalga kapsamlı bir işlem için mi?") - if answer: - mode = config.MODE_WAVE - try: - target = operations.resolve_target(value, mode, self.inventory) - except Refused as exc: - self.console.exception(exc) - self.pause() - return - self.console.kv("Tür", target.kind) - self.console.kv("Ansible --limit", target.limit) - self.console.kv("Dalga", target.wave or "yok") - self.console.kv("Cihaz sayısı", str(target.count)) - self.console.note("Örnek: ansible-playbook -i {0} {1}/bf-ping.yml --limit {2}".format( - self.inventory.labels(), config.PLAYBOOK_DIR_REL, target.limit)) - self.pause() - - # ------------------------------------------------------------------ - # Local helpers - # ------------------------------------------------------------------ - def show_file(self, path: str, title: str) -> None: - self.console.section(title) - try: - with open(path, "r", encoding="utf-8") as handle: - lines = handle.read().splitlines() - except OSError: - self.console.warn("{0} okunamadı veya yok.".format(path)) - return - for line in lines: - self.console.write(" " + line) - - def tail_file(self, path: str, title: str, lines: int = 40) -> None: - if not os.path.isfile(path): - self.console.warn("{0} yok.".format(path)) - return - self.runner.run_readonly(["tail", "-n", str(lines), path], title=title) - - -# --------------------------------------------------------------------------- -# Non-interactive listings -# --------------------------------------------------------------------------- -def catalogue_lines() -> List[str]: - lines: List[str] = [] - for title, ops in ((config.LIST_TITLE_REPORTS, operations.REPORT_OPERATIONS), - (config.LIST_TITLE_MAINTENANCE, operations.MAINTENANCE_OPERATIONS)): - lines.append("") - lines.append(title) - for index, op in enumerate(ops, start=1): - lines.append(" {0:>2}) {1:<34} {2:<36} kapsam={3:<8} yıkıcı={4}".format( - index, op.ident, op.playbook, op.declared_scope, "evet" if op.destructive else "hayır")) - lines.append("") - lines.append(config.LIST_NOTE_WAVE) - lines.append(config.LIST_NOTE_RELEASE) - lines.append(config.LIST_NOTE_PLAYBOOK) - return lines - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- -def _wants_tui(args: argparse.Namespace) -> bool: - """Whether this invocation belongs to the terminal console. - - The GUI is the default. A few flags exist only in the terminal console -- - they print text and exit, or tune terminal rendering -- so an invocation - carrying one of them must stay there instead of being swallowed by the - router. ``--tui`` forces it explicitly. - """ - if args.tui: - return True - if args.gui: - return False - # --list prints a text catalogue (the TUI's report format) and exits; it is - # a report, not an interactive surface, so it stays in the terminal console. - return bool(args.list or args.check or args.list_targets or args.no_color - or args.log_file != config.DEFAULT_LOG_FILE - or args.lang != config.DEFAULT_LANG) - - -def main(argv: Optional[Sequence[str]] = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - if args.tui and args.gui: - sys.stderr.write("HATA: --tui ve --gui birlikte kullanılamaz.\n") - return config.EXIT_USAGE - - if args.version: - sys.stdout.write("{0} {1}\n".format(config.PROGRAM_NAME, config.VERSION)) - return config.EXIT_OK - - if args.lang not in config.SUPPORTED_LANGS: - sys.stderr.write(config.MSG_LANG_UNSUPPORTED.format( - value=args.lang, supported=", ".join(config.SUPPORTED_LANGS)) + "\n") - return config.EXIT_USAGE - - if not _wants_tui(args): - # Imported lazily: the GUI pulls in tkinter, and the terminal console - # must keep working on a device where that import is unavailable. - from . import ui_gui - gui_argv: List[str] = [] - if args.full: - gui_argv.append("--full") - if args.dry_run: - gui_argv.append("--dry-run") - if args.repo: - gui_argv.extend(["--repo", args.repo]) - if args.inventory: - gui_argv.extend(["--inventory", args.inventory]) - if args.list: - gui_argv.append("--list") - return ui_gui.main(gui_argv) - - try: - repo = resolve_repo(args.repo) - except Refused as exc: - sys.stderr.write(exc.message + "\n") - if exc.detail: - sys.stderr.write(exc.detail + "\n") - return config.EXIT_USAGE - - palette = config.Palette(tui.color_enabled( - no_color=args.no_color, mode="auto", is_tty=tui.stream_is_tty(sys.stdout))) - console = tui.Console(palette=palette) - - audit = runner.AuditLog(requested=args.log_file) - audit.initialise() - if audit.warning: - console.warn(audit.warning) - - inventory_error = "" - try: - inventory_path = repo.find_inventory(args.inventory) - inventory = Inventory.load(inventory_path) - except Refused as exc: - inventory = Inventory(path=None, groups={}) - inventory_error = exc.message + ("\n" + exc.detail if exc.detail else "") - except (OSError, ValueError) as exc: - inventory = Inventory(path=None, groups={}) - inventory_error = config.MSG_INVENTORY_UNREADABLE.format(path=args.inventory) + "\n" + str(exc) - - # Non-interactive listings first: they need no terminal and no Ansible. - if args.list: - console.show_lines(catalogue_lines()) - return config.EXIT_OK - if args.list_targets: - console.show_lines(operations.targets_lines(inventory)) - return config.EXIT_OK - - console.note("{0} {1} — {2}".format(config.PROGRAM_NAME, config.VERSION, config.SCREEN_SUBTITLE)) - console.kv("Depo", repo.root) - console.kv("Envanter", inventory.labels()) - console.kv("Denetim kaydı", audit.path or "yok") - if args.dry_run: - console.warn(config.MSG_DRY_RUN) - if args.check: - console.info(config.MSG_CHECK_MODE) - if not audit.ready: - console.warn(config.MSG_LOG_NONE) - if not inventory.resolved: - console.warn(inventory_error or config.MSG_INVENTORY_MISSING.format( - dir=repo.path(config.INVENTORY_DIR_REL))) - console.note("Filo kategorileri çalışmayacak; yerel kurulum ve bilgi ekranları kullanılabilir.") - if not runner.Runner.have_ansible(): - console.warn(config.MSG_ANSIBLE_MISSING) - - audit.write(config.AUDIT_SESSION, ( - "{0} {1} başlangıç uid={2} repo={3} dry_run={4} check={5} full={6}" - ).format(config.PROGRAM_NAME, config.VERSION, os.getuid(), repo.root, - args.dry_run, args.check, args.full)) - - app = App( - repo=repo, - console=console, - runner_=runner.Runner(console, audit=audit, dry_run=args.dry_run, check_mode=args.check), - inventory=inventory, - full_mode=args.full, - check_mode=args.check, - inventory_error=inventory_error, - inventory_arg=args.inventory, + sys.stderr.write("Python 3.8+ kurulumu gereklidir.\n") + raise SystemExit(3) + + +def _repository_root(): + """First candidate directory that actually contains the bfos package.""" + here = os.path.dirname(os.path.realpath(__file__)) + candidates = [ + os.environ.get("BF_REPO", ""), + os.path.dirname(here), # /admin/bf -> + here, + os.getcwd(), + ] + for candidate in candidates: + if candidate and os.path.isfile(os.path.join(candidate, "bfos", "__init__.py")): + return candidate + return "" + + +ROOT = _repository_root() +if not ROOT: + sys.stderr.write( + "HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n" + "ya da --repo YOL ile depo yolunu belirtin.\n" + "Python konsolunu depo kökünden çalıştırın veya --repo YOL kullanın.\n" ) - try: - code = app.run() - except KeyboardInterrupt: - console.write("") - console.note("Kullanıcı tarafından kesildi (Ctrl+C).") - return 130 - return code + raise SystemExit(3) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) -if __name__ == "__main__": # pragma: no cover +from bfos.cli import main # noqa: E402 (sys.path is prepared above) + +if __name__ == "__main__": raise SystemExit(main()) - -"""Static configuration for the bfos operator console. + +# Blueforce operator console (`bf`) -Everything an operator can read lives here, in Turkish, so the wording can be -reviewed and changed in one place. Identifiers and comments stay English. +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 +runs, and the dangerous ones ask twice. -Rules kept in this module: - * standard library only (Python 3.8+), no third-party import, - * no side effect at import time (no directory is created, nothing is read), - * thresholds and allowlists are shared with the Ansible playbooks/docs: the - canonical wave order and the dealer-number pattern come from docs/02 and - docs/10 exactly as admin/lib/targets.sh defines them. -""" +Three layers, one screen: -# --------------------------------------------------------------------------- -# Identity and version -# --------------------------------------------------------------------------- -PROGRAM_NAME = "bfos" -VERSION = "1.0.0" -SCREEN_TITLE = "BLUEFORCE FIELD OS" -SCREEN_SUBTITLE = "Saha cihazı operatör konsolu" -PROGRAM_DESCRIPTION = "Blueforce saha cihazı operatör konsolu (Türkçe)" +| Layer | What it is | Where | +|---|---|---| +| Local install | This device, brought to the Field OS standard | category **1** | +| Fleet operations | The repository's existing Ansible playbooks, always with an explicit `--limit` | category **2** | +| Read-only information | Device and fleet state, no change anywhere | categories **3, 4, 5** | -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- -DEFAULT_LOG_FILE = "/var/log/blueforce-console.log" -LOG_FALLBACK_SUBDIR = ("blueforce", "blueforce-console.log") -LOG_FILE_MODE = 0o600 -LOG_DIR_MODE = 0o700 +The console is a thin front end. It does not reimplement installer logic, does +not invent playbooks, and does not talk to devices itself: it selects the right +`bf-*` tool, playbook and target, then executes exactly that. -INSTALLER_REL = "scripts/install/blueforce-install.sh" -PLAYBOOK_DIR_REL = "ansible/playbooks" -DIAGNOSTICS_DIR_REL = "scripts/diagnostics" -GROUP_VARS_REL = "ansible/inventory/group_vars/all.yml" -INVENTORY_DIR_REL = "ansible/inventory" -INVENTORY_EXAMPLE_REL = "ansible/inventory/hosts.example.yml" -INVENTORY_CANDIDATES = ("hosts.yml", "hosts.yaml", "hosts", "hosts.ini") +--- -STATE_FILE = "/var/lib/blueforce/state.json" # nosec - read-only whitelist -INSTALL_STATE_FILE = "/var/lib/blueforce/install-state" -INSTALL_LOG_FILE = "/var/log/blueforce-install.log" -TOOL_BIN_DIR = "/usr/local/bin" -ADMIN_KEY_FILE = "~/.ssh/blueforce_id_ed25519" -SUPPORT_BUNDLE_OUT_DIR = "/var/tmp" +## 1. Requirements -# --------------------------------------------------------------------------- -# Rules, allowlists and thresholds (mirrors admin/lib/targets.sh + docs/10) -# --------------------------------------------------------------------------- -DEALER_PATTERN = r"^[0-9]{8}$" -HOST_PATTERN = r"^bf-[0-9]{8}$" +- 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 + drives fleet operations (categories 2 and 3). A field device that only installs + itself needs neither Ansible nor an inventory. +- `sudo` for category 1 (local install) and for writing to + `/var/log/blueforce-console.log`. +- Optional, per category: `python3` (reads `state.json`), `wireguard-tools` + (`wg`), `systemd`, `dpkg`. -# Canonical rollout order. One place, so the menu, the gate checks and the -# fleet-wide loop can never disagree. -WAVES = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production") -PRODUCTION_WAVE = "production" +## 2. Quick start -# Target selection modes. -MODE_ANY = "any" -MODE_WAVE = "wave" -MODE_RELEASE = "release" +```bash +git clone Blueforce-Linux +cd Blueforce-Linux -# Scope names derived from the playbook file itself. -SCOPE_SIMPLE = "simple" -SCOPE_WAVE = "wave" -SCOPE_RELEASE = "release" +sudo admin/bf-bootstrap.sh # optional: links the tools into /usr/local/bin +sudo bf # after bootstrap +sudo bf --tui # explicit terminal surface +``` -SERVICE_ALLOWLIST = ("blueforce-agent", "docker", "ssh", "sshd", "systemd-journald", "cron") -SCRIPT_DIRS = ("/opt/blueforce/bin", "/usr/local/sbin") +An install is `git`-based on purpose: the running revision is traceable with +`git log`, and the device identity (dealer number) is never baked into an image +— it is typed in the first console session. ISO/firstboot remain a secondary, +optional path (`docs/26`, `docs/27`). -DISK_WARN_MIN = 1 -DISK_WARN_MAX = 100 -DEFAULT_REBOOT_GRACE = 300 -DEFAULT_LOG_LINES = 500 +Options: -# Non-secret identity/lifecycle keys of state.json (whitelist, mirrors bf-menu). -STATE_ALLOWED_KEYS = ("phase", "enrollment_status", "fleet_status", "device_id", "provisioning_id") +``` +-h, --help usage +-V, --version console version + --repo PATH repository root (default: parent of this script, symlinks resolved) + --inventory PATH Ansible inventory (default: /ansible/inventory/hosts.yml) + --log-file PATH audit log (default: /var/log/blueforce-console.log) + --dry-run print every command instead of running it + --no-color disable ANSI colors (NO_COLOR is honored too) + --list print the operation catalogue and exit (non-interactive) + --list-targets print inventory groups and host counts, then exit +``` -# Central endpoint keys read from group_vars/all.yml for the preparation screens. -CENTRAL_ENDPOINT_KEYS = ( - "wg_subnet", - "wg_interface", - "wg_hub_endpoint", - "monitoring_endpoint", - "monitoring_job", - "update_repo_url", - "update_repo_suite", - "meg_image_tag", - "support_bundle_dir", -) +Environment: `BF_REPO`, `BF_INVENTORY`, `BF_CONSOLE_LOG` mirror the options. -# Local diagnostics tools the console can drive (resolved in /usr/local/bin -# first, then in the checkout under scripts/diagnostics/). -DIAGNOSTIC_TOOLS = ( - "bf-status", - "bf-diagnostics", - "bf-check-local", - "bf-check-enrollment", - "bf-check-ready", - "bf-release", - "bf-remote-status", - "bf-support-bundle", - "bf-hardware-inventory.sh", -) +Exit status: `0` success or clean quit, `1` failure, `2` usage error. -TOOL_ENROLL_STATUS = "bf-enrollment-status" -TOOL_LIVE_HW_CHECK = "bf-live-hw-check" +## 3. Menu map -# Language: this console is Turkish-only on purpose (user decision). The option -# exists so a future translation has a single switch to grow into. -SUPPORTED_LANGS = ("tr",) -DEFAULT_LANG = "tr" +`0` is **Back** on every screen, `q` is **Quit** on every screen. The console +never changes anything from a menu that only displays information. -# Layout and exit codes (the bash console uses the same contract). -CONSOLE_WIDTH = 78 -EXIT_OK = 0 -EXIT_FAIL = 1 -EXIT_USAGE = 2 +**1) Installation — this device, locally** -# Minimal Python version this console supports. -MIN_PYTHON = (3, 8) +| # | Action | Under the hood | +|---|---|---| +| 1 | Pre-flight check | `blueforce-install.sh --dealer-id --check` (writes nothing) | +| 2 | Install this device | `--dealer-id [--offline] [--resume]` | +| 3 | Resume a failed run | `--dealer-id --resume` | +| 4 | Run a single module | `--dealer-id --only ` | +| 5 | Show module state | `/var/lib/blueforce/install-state` | +| 6 | Show the installer log | `tail -n 40 /var/log/blueforce-install.log` | -# --------------------------------------------------------------------------- -# ANSI palette -# --------------------------------------------------------------------------- -ANSI = { - "reset": "\033[0m", - "bold": "\033[1m", - "dim": "\033[2m", - "red": "\033[31m", - "green": "\033[32m", - "yellow": "\033[33m", - "blue": "\033[34m", - "magenta": "\033[35m", - "cyan": "\033[36m", -} +Dealer numbers are validated against `^[0-9]{8}$` before anything runs; a wrong +format stops the flow immediately. Actions 2–4 require root and will say so. +**2) Device operations — the fleet, via Ansible** -class Palette: - """ANSI codes, or empty strings when colour is switched off.""" +*2.1 Read-only reports* (11 operations, nothing is changed): - __slots__ = ( - "enabled", - "reset", - "bold", - "dim", - "red", - "green", - "yellow", - "blue", - "magenta", - "cyan", - ) +| # | Operation | Playbook | +|---|---|---| +| 1 | Ping / reachability | `bf-ping.yml` | +| 2 | Uptime | `bf-uptime.yml` | +| 3 | Disk usage | `bf-disk-check.yml` | +| 4 | Docker status | `bf-docker-status.yml` | +| 5 | Package versions | `bf-package-check.yml` | +| 6 | Pending security updates | `bf-security-updates-check.yml` | +| 7 | Provisioning phase report | `bf-provisioning-status.yml` | +| 8 | Enrollment + central evidence | `bf-enrollment-status.yml` | +| 9 | Field OS version report | `bf-fieldos-version.yml` | +| 10 | Remote access channels | `bf-remote-channels-check.yml` | +| 11 | Offline readiness report | `bf-offline-ready-check.yml` | - def __init__(self, enabled: bool) -> None: - self.enabled = enabled - for name, code in ANSI.items(): - setattr(self, name, code if enabled else "") +*2.2 Maintenance* (10 operations, destructive ones ask twice): - def paint(self, text: str, *names: str) -> str: - """Wrap TEXT in the given colour names (ignored when disabled).""" - if not self.enabled or not names: - return text - prefix = "".join(getattr(self, name) for name in names) - return "{0}{1}{2}".format(prefix, text, self.reset) +| # | Operation | Playbook | Scope | +|---|---|---|---| +| 1 | Restart RDP | `bf-rdp-restart.yml` | simple | +| 2 | Restart RustDesk | `bf-rustdesk-restart.yml` | simple | +| 3 | Restart WireGuard | `bf-wireguard-restart.yml` | simple | +| 4 | Restart an allowlisted service | `bf-service-restart.yml` | simple | +| 5 | GUI off (headless) | `bf-gui-off.yml` | wave | +| 6 | GUI on (maintenance) | `bf-gui-on.yml` | wave | +| 7 | Collect logs / support bundle | `bf-collect-logs.yml` | wave | +| 8 | Run an approved script | `bf-run-script.yml` | wave | +| 9 | Controlled reboot | `bf-reboot.yml` | release | +| 10 | Deploy approved update | `bf-deploy-update.yml` | release | +*2.3 Show inventory targets* lists the waves with their device counts. -# --------------------------------------------------------------------------- -# Turkish messages -# --------------------------------------------------------------------------- -# Generic messages -MSG_QUIT = "Konsoldan çıkılıyor." -MSG_BACK = "Geri" -MSG_QUIT_ITEM = "Çıkış" -MSG_AT_TOP = "Zaten ana menüdesiniz." -MSG_UNKNOWN_CHOICE = "Geçersiz seçim: '{choice}'. Lütfen menüdeki numaralardan birini yazın." -MSG_MENU_RANGE = "Lütfen 1 ile {max} arasında bir numara yazın." -MSG_PRESS_ENTER = "Devam etmek için Enter'a basın" -MSG_CANCELLED = "İptal edildi; hiçbir şey çalıştırılmadı." -MSG_CANCELLED_RUN = "İşlem iptal edildi, komut çalıştırılmadı." -MSG_INVALID_INPUT = "Girdi anlaşılamadı: {value}" -MSG_REQUIRED = "Bu alan boş bırakılamaz." -MSG_NOT_A_TTY = "Bu ekran etkileşimli bir uçbirim gerektiriyor; çıkılıyor." +The console cross-checks its own table against the playbook file before running +(see §5); a playbook whose gates changed refuses to run rather than run ungated. -# Interpreter / environment -MSG_PYTHON_MISSING = ( - "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" -) -MSG_PYTHON_OLD = ( - "HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n" - "Yedek yol (bash): sudo admin/bf-menu" -) -MSG_LANG_UNSUPPORTED = "Bu konsol yalnızca Türkçe çalışır; '{value}' desteklenmiyor. Kullanılabilir: {supported}." -MSG_REPO_INVALID = ( - "HATA: {path} bir Blueforce deposu gibi görünmüyor (ansible/ ve scripts/ bulunamadı).\n" - "Depoyu klonlayıp konsolu oradan çalıştırın veya --repo YOL ile yolu belirtin." -) -MSG_ANSIBLE_MISSING = ( - "ansible-playbook bu makinede kurulu değil; filo işlemleri çalıştırılamaz.\n" - "Merkez makinaya kurun: sudo apt install ansible (veya: pipx install ansible-core)" -) -MSG_INVENTORY_MISSING = ( - "Ansible envanteri bulunamadı: {dir}\n" - "Gerçek envanteri oluşturun: cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml " - "ve bf-<8 haneli bayi no> cihazları doldurun." -) -MSG_INVENTORY_EXAMPLE_ONLY = ( - "Yalnızca örnek envanter var (örnek cihaz kimlikleri içerir): {path}\n" - "Gerçek cihazlara karşı kullanmayın; kopyalayıp gerçek kimlikleri yazın." -) -MSG_INVENTORY_UNREADABLE = "Envanter okunamıyor: {path}" -MSG_ROOT_REQUIRED = ( - "Bu işlem BU cihazda root yetkisi gerektirir.\n" - "Konsolu tekrar sudo ile başlatın: sudo admin/bf" -) -MSG_TOOL_MISSING = ( - "{tool} bulunamadı (/usr/local/bin altında ve depo içinde yok).\n" - "Kurulacak komut: sudo install -m 0755 scripts/diagnostics/{tool} /usr/local/bin/{tool}" -) -MSG_INSTALLER_MISSING = ( - "Kurulum betiği bulunamadı: {path}\n" - "--repo ile doğru depo yolunu gösterin." -) -MSG_PLAYBOOK_MISSING = ( - "Playbook bulunamadı: {path}\n" - "Konsolu deponun içinden çalıştırın (--repo YOL)." -) +**3) Central (preparation)** — verifies and prepares central integration; it +installs no central component. Central readiness report (inventory, placeholders +in `group_vars/all.yml`, admin key path), configured endpoints, a read-only +DNS/TCP probe of those endpoints, the per-wave gate state, and the preparation +checklist. -# Target selection -MSG_TARGET_NONE = "Hedef seçilmedi; komut üretilmedi." -MSG_TARGET_EMPTY = "Cihaz seçilmedi." -MSG_TARGET_DEALER_INVALID = ( - "Geçersiz bayi numarası: '{value}'. 8 hane olmalı, boşluk ve harf olmamalı (örn. 12010101)." -) -MSG_TARGET_UNKNOWN = "'{value}' bir bayi numarası (8 hane), grup ya da 'all' değil." -MSG_TARGET_DEVICE_UNKNOWN = ( - "{host} envanterde yok ({inventory}).\n" - "Cihazı önce ilgili dalga grubuna ekleyin." -) -MSG_TARGET_DEVICE_NO_WAVE = ( - "{host} onaylı bir dalgada değil. Dalga kapsamlı işlemler böyle bir cihazı reddeder (fail-closed)." -) -MSG_TARGET_GROUP_UNKNOWN = "'{group}' grubu envanterde yok. Bilinen gruplar: {groups}" -MSG_TARGET_GROUP_NOT_WAVE = ( - "'{group}' onaylı bir dalga değil. Dalga kapsamlı işlemler için: {waves}" -) -MSG_TARGET_GROUP_EMPTY = "'{group}' grubu boş; boş hedef reddedildi." -MSG_TARGET_ALL_EMPTY = "Envanterde hiç cihaz yok; boş hedef reddedildi." -MSG_TARGET_RELEASE_DEVICE = ( - "Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez, dalga grubu seçin." -) -MSG_TARGET_RELEASE_ALL = ( - "Sürüm kapılı işlemler için filo geneli reddedildi: güncelleme dalga dalga yapılır." -) -MSG_TARGET_KNOWN_GROUPS = "Bilinen gruplar: {groups}" +**4) Tools** — `git pull --ff-only` of the checkout, environment/reference info, +the operation catalogue, the local gates (`bf-check-local`, `bf-check-enrollment`, +`bf-check-ready`), `bf-diagnostics`, `bf-live-hw-check`, `bf-remote-status --check`, +`bf-release`, `bf-hardware-inventory.sh`, `bf-support-bundle`, and the console's +own log. -# Gates -MSG_GATE_PLAN_TITLE = "Güvenlik kapıları" -MSG_GATE_ALREADY = "Bu işlem için ek kapı onayı gerekmiyor." -MSG_GATE_WAVE_TOKEN = ( - "Dalga onayı istendi: '{wave}'. Sürüm kapılı işlemler yalnızca yazılı dalga onayıyla koşar." -) -MSG_GATE_WAVE_TOKEN_ASK = "Onay için dalga adını aynen yazın ({wave}): " -MSG_GATE_WAVE_REFUSED = "Dalga onayı verilmedi; işlem yapılmadı." -MSG_GATE_GATE_FLAG = "Onayla birlikte playbook'a -e wave_gate_confirmed=true iletilecek." -MSG_GATE_PRODUCTION = ( - "DALGA 'production' CANLI FİLODUR. Playbook, operation/üretim onayı olmadan koşmayı reddeder." -) -MSG_GATE_PRODUCTION_ASK = "Canlı filo onayı için 'production' yazın: " -MSG_GATE_PRODUCTION_APPROVAL = ( - "ÜRETİM ONAYI: bu komut canlı filoyu değiştirir ve -e production_override=true iletilecek. Onaylıyor musunuz?" -) -MSG_GATE_PRODUCTION_REFUSED = "Üretim onayı verilmedi; işlem yapılmadı." -MSG_GATE_FLEET_TOKEN = ( - "FİLO GENELİ: komut envanterdeki tüm cihazları ({count}) hedefliyor. Bu en geniş kapsamdır." -) -MSG_GATE_FLEET_TOKEN_ASK = "Filo geneli onayı için 'all' yazın: " -MSG_GATE_FLEET_REFUSED = "Filo geneli onayı verilmedi; işlem yapılmadı." -MSG_GATE_WAVE_BY_WAVE = ( - "Bu playbook dalga kapsamlı: filo, dalga dalga işlenir ve ilk hatada DURUR.\n" - "Sıra: {order}" -) -MSG_GATE_WAVE_LOOP_ASK = "Dalga '{wave}' ({count} cihaz) için onay vermek üzere dalga adını yazın: " -MSG_GATE_WAVE_SKIPPED = "'{wave}' dalgası operatör tarafından atlandı." -MSG_GATE_WAVE_FAILED = "'{wave}' dalgası başarısız oldu (çıkış kodu {rc}); sıra DURDURULDU, sonraki dalgaya dokunulmadı." -MSG_GATE_WAVE_NOT_RUN = "'{wave}' dalgası tamamlanmadı; sıra DURDURULDU." -MSG_GATE_START_FIRST = "İlk dalga ile başlansın mı?" -MSG_GATE_ORDER = "lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production" -MSG_GATE_SCOPE_MISMATCH = ( - "RED: {playbook} için konsol kapsamı '{declared}' bekliyor, playbook '{actual}' diyor.\n" - "Playbook kendi kapılarının kaynağıdır; konsol tablosu güncellenmeden bu playbook çalıştırılmaz." -) -MSG_GATE_NO_TARGET = "Hedef seçilmedi; komut üretilmedi (kapı kuralı)." +**5) Device information** — identity (hostname, `BF-`, dealer number), +`bf-status`, provisioning/enrollment state, network (addresses, routes, DNS, +WireGuard **peer counts and handshake ages**), disk/memory/CPU, installed pinned +packages, service status, recent journal errors, plus the fleet inventory summary +and a "resolve a target" helper that shows the exact `--limit` for copy/paste. -# Prompts / extra variables -MSG_PROMPT_DEALER = "Bayi numarası (8 hane): " -MSG_PROMPT_TARGET = "Hedef (bayi no, grup ya da 'all'): " -MSG_PROMPT_CHOICE = "Seçim > " -MSG_PROMPT_PACKAGES = "Paketler (virgülle ayrılmış, paket=sürüm): " -MSG_PROMPT_SERVICE = "Servis adı: " -MSG_PROMPT_SCRIPT = "Betik yolu (mutlak): " -MSG_PROMPT_SCRIPT_ARGS = "Betik argümanları (isteğe bağlı): " -MSG_PROMPT_GRACE = "Yeniden başlatma için bekleme süresi (saniye) [{default}]: " -MSG_PROMPT_DISK = "Uyarı eşiği yüzdesi (boş = playbook varsayılanı 80): " -MSG_PROMPT_LOG_LINES = "Cihaz başına log satırı [{default}]: " -MSG_PROMPT_VERSION = "Beklenen Field OS sürümü (boş = sapma denetimi yok): " +## 4. Target selection -MSG_PKG_HINT = "Yalnız kesin sürüm: paket=sürüm (aralık, 'latest' ve boş liste reddedilir)." -MSG_PKG_REQUIRED = "En az bir sabitlenmiş paket gerekir." -MSG_PKG_INVALID = "Geçersiz pin: '{value}'. Beklenen biçim: paket=sürüm (örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)." -MSG_PKG_LATEST = ( - "Reddedildi: '{value}' kesin bir sürüm değil. 'latest' politikası yoktur; " - "pinlenmiş sürüm yazın (docs/10)." -) -MSG_SERVICE_HINT = "İzinli servisler: {allowlist}" -MSG_SERVICE_INVALID = "Reddedildi: '{value}' izinli servis listesinde değil (playbook da reddederdi)." -MSG_SCRIPT_ABSOLUTE = "Betik yolu mutlak olmalı (/ ile başlamalı)." -MSG_SCRIPT_OUTSIDE = "Reddedildi: '{value}' izinli dizinlerin dışında ({dirs})." -MSG_GRACE_INVALID = "Bekleme süresi tam sayı saniye olmalı." -MSG_PCT_INVALID = "Eşik {min} ile {max} arasında olmalı." -MSG_LINES_INVALID = "Satır sayısı pozitif bir tam sayı olmalı." +Every device operation asks for a target first — never a default: -# Command / run flow -MSG_COMMAND_TITLE = "Çalıştırılacak komut" -MSG_RUN_ASK = "Bu komut çalıştırılsın mı? [y=çalıştır / c=--check ile çalıştır / N=iptal]: " -MSG_DESTRUCTIVE_WARN = "DURUM DEĞİŞTİREN İŞLEM: {label}" -MSG_DESTRUCTIVE_SUB = "Onaylamadan önce yukarıdaki komutu okuyun; seçilen cihazları değiştirir." -MSG_ROOT_REQUIRED = ( - "Bu işlem BU cihazda root yetkisi gerektirir.\n" - "Konsolu tekrar sudo ile başlatın: sudo bf" -) -MSG_DESTRUCTIVE_GATE = ( - "Yıkıcı işlem kapısı: {label} seçilen cihazları değiştirir. " - "Bu onaydan sonra komut üretilir ve çalıştırmadan önce ayrıca sorulur." -) -MSG_ASK_YES_NO = "{question} [e/H]: " -MSG_CHECK_MODE = "Check modu: komut --check ile çalışacak, hiçbir değişiklik uygulanmayacak." -MSG_DRY_RUN = "DENEME (dry-run) MODU: komutlar yazılır, asla çalıştırılmaz." -MSG_DRY_RUN_WARN = "dry-run: yukarıdaki komut ÇALIŞTIRILMADI." -MSG_RUN_OK = "Komut başarıyla tamamlandı (çıkış kodu 0)." -MSG_RUN_FAIL = "Komut {rc} çıkış koduyla başarısız oldu." -MSG_RUN_ABORTED = "Komut {signal} ile kesildi." -MSG_LOG_FALLBACK = "{requested} yazılamıyor; denetim kaydı {fallback} dosyasına yazılıyor (mod 600)." -MSG_LOG_NONE = "Yazılabilir denetim kaydı bulunamadı; bu oturum kaydedilmeyecek." +``` +Target selection (mode: any|wave|release) + 1) Single device by dealer number -> bf-<8 digits> + 2) Group / wave -> lab, pilot_1, pilot_2, wave_1, wave_2, production + 3) All devices in the fleet -> --limit all + 9) Type the target directly -> dealer number, group, hostname or 'all' + 0) Back q) Quit +``` -# Simple (default) menu -SIMPLE_MENU_TITLE = SCREEN_TITLE -SIMPLE_MENU_SUBTITLE = "Bu cihazı kurmak ve durumunu görmek için 1 veya 2 yazın" -SIMPLE_ITEM_INSTALL_LABEL = "Kur" -SIMPLE_ITEM_INSTALL_DESC = "Bu cihazı Field OS standardına getir (kurulum ekranı)" -SIMPLE_ITEM_STATUS_LABEL = "Durum" -SIMPLE_ITEM_STATUS_DESC = "Kurulum ve cihaz durumunu göster (salt okunur)" -SIMPLE_ITEM_EXIT_LABEL = "Çıkış" -SIMPLE_ITEM_EXIT_DESC = "Konsoldan çık" -SIMPLE_ADVANCED_LABEL = "Detaylı mod" -SIMPLE_ADVANCED_DESC = "Filo işlemleri, merkez hazırlığı, araçlar ve 21 playbook" +Rules the console enforces: -# Full (detailed) menu -FULL_MENU_TITLE = "Detaylı mod" -FULL_MENU_SUBTITLE = "Tüm operasyonlar; güvenlik kapıları her işlemde geçerlidir" -CAT_INSTALL = "Kurulum" -CAT_DEVICES = "Cihaz işlemleri" -CAT_CENTRAL = "Merkez hazırlığı" -CAT_TOOLS = "Araçlar" -CAT_INFO = "Cihaz bilgisi" +- **Single device** — the dealer number must match `^[0-9]{8}$` and the device + must exist in the inventory. The wave is derived from the inventory, so a + wave-scoped playbook gets `-e operation_wave=`. A device outside + every wave cannot be used for a wave-scoped operation. +- **Group / wave** — the group must exist in the inventory. For wave-scoped and + release-gated operations only the six approved waves are offered. +- **All devices** — allowed for simple and wave-scoped playbooks. It shows how + many devices are affected and requires typing `all` before anything runs. For + wave-scoped playbooks the fleet is processed **one wave at a time** in + `lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production`, each wave shown and + confirmed on its own, stopping at the first failure so no later wave is touched. +- **Release-gated playbooks** (`bf-reboot.yml`, `bf-deploy-update.yml`) accept an + approved wave group only: no single device, no fleet-wide run. That is the + playbooks' own gate (docs/10) and the console does not work around it. -MSG_FULL_MODE_HINT = "Detaylı mod açık: filo işlemleri ve araçlar görünür." -MSG_SIMPLE_MODE_HINT = "Basit mod: 1) Kur, 2) Durum, 3) Çıkış. Detaylı özellikler için 'd' yazın ya da --full ile başlatın." +## 5. What gets added to a command -# Audit log -AUDIT_SESSION = "SESSION" -AUDIT_TARGET = "TARGET" -AUDIT_RUN = "RUN" -AUDIT_DRYRUN = "DRYRUN" -AUDIT_OK = "OK" -AUDIT_FAIL = "FAIL" -AUDIT_CANCEL = "CANCEL" -AUDIT_STOP = "STOP" +The console derives the gate variables from the playbook file itself (its +non-comment lines), so the table in the menu can never silently drop a gate: -# Non-interactive listings -LIST_TITLE_REPORTS = "Salt okunur filo raporları (cihazlara dokunmaz)" -LIST_TITLE_MAINTENANCE = "Filo bakım işlemleri (yıkıcı olanlar iki kez onay ister)" -LIST_NOTE_WAVE = "scope=wave -> -e operation_wave= -e operation_confirmed=true" -LIST_NOTE_RELEASE = "scope=release -> -e release_wave= + yazılı dalga onayı" -LIST_NOTE_PLAYBOOK = "Kapsam her playbook dosyasından okunur; uyuşmazsa çalıştırılmaz." +| Scope | Detected by | Variables added | +|---|---|---| +| `simple` | neither wave variable | only `--limit` | +| `wave` | uses `operation_wave` | `-e operation_wave= -e operation_confirmed=true`, plus `-e production_override=true` for the production wave | +| `release` | uses `release_wave` | `-e release_wave=`, `-e reboot_confirmed=true` / `-e wave_gate_confirmed=true` when the playbook declares them, plus the production override for the production wave | + +Confirmations, in order: + +1. the target and the full command are printed; +2. for `release` scope, the **wave gate** must be typed as `GATE` (the console + never sets `wave_gate_confirmed=true` on its own — only after the previous + wave's health gate and central approval, docs/10); +3. for the production wave, the word `production` must be typed; +4. for fleet-wide scope, the word `all` must be typed; +5. `y` runs, `c` runs with `--check` (dry run of the playbook), anything else cancels. + +Extra variables are validated with the same rules the playbooks enforce, so the +operator gets a clear message instead of an Ansible assertion failure: package +pins must be `package=version`; service names must be in the playbook allowlist; +script paths must be inside `/opt/blueforce/bin` or `/usr/local/sbin`; the reboot +grace period must be a whole number of seconds. + +## 6. Dry runs + +- `--dry-run` prints every command and executes nothing. +- Answering `c` at the run prompt executes the command with `--check` appended, + which is a real Ansible dry run. +- Category 1's pre-flight already is the installer's own `--check`. + +## 7. Audit log + +Default `/var/log/blueforce-console.log`, created with mode **600** (it is +re-`chmod`ed to 600 if it already exists). If it is not writable, the console +warns and falls back to `$XDG_STATE_HOME/blueforce/blueforce-console.log`; it +never logs into a world-readable file. + +Each run records one line per event: session start, target, the exact command, +and the exit status. It does **not** store raw playbook output: tool output may +contain whatever a device script prints, and copying it into a log is how a +secret ends up on disk. Output is shown on screen and stays there. + +Before anything reaches the log it passes a single sanitizer that redacts +`password=`, `token=`, `secret=`, `api_key=`, `private_key=`, `passphrase=`, +`psk=` and `BEGIN … PRIVATE KEY` payloads. The console itself never asks for a +secret: no password, token or private key is typed into or stored by it. + +## 8. Security notes + +- **No secret material is read, printed or logged.** The console never runs bare + `wg show` (private and preshared keys stay off the screen) — only the listen + port, peer count and handshake ages. +- **`state.json` is read through a whitelist** of non-secret keys (`phase`, + `enrollment_status`, `fleet_status`, `device_id`, `provisioning_id`). A future + token field cannot leak into the UI or the log. +- **No ungated update.** There is no `apt upgrade`, no `latest` pull and no + free-form command anywhere in the menu; updates go through the release-gated + playbook or not at all (docs/10). +- **Destructive actions ask twice**: a warning plus an explicit `y`, and the + highest-risk scopes need a typed word (`GATE`, `production`, `all`, or the wave + name in a fleet-wide loop). +- **Fail-closed on drift**: if the playbook's declared gates do not match the + console's expectations, the console refuses to run that playbook at all. +- **Root is used where the underlying tool needs it** (local installer, support + bundle). Read-only information screens are careful about what they read. +- The console **never edits the inventory**. Adding a device stays a reviewed + edit of `ansible/inventory/hosts.yml`: + +```yaml + pilot_1: + hosts: + bf-12010193: + ansible_host: 10.42.1.20 +``` + +## 9. Deliberately out of scope + +- **No distro/ISO build.** ISO and firstboot remain a secondary path (`docs/26`, + `docs/27`); the primary install path is `git clone` + install. Category 4 does + not offer `provisioning/iso/build-blueforce-iso.sh`. +- **No password generation.** Credential files are the separate, optional + `admin/bf-creds` tool and default to **off**; the console does not call it. +- **No central server installation.** Category 3 verifies and prepares; + installing the hub, monitoring or the token service is infrastructure work. +- **No new playbooks, no free-form commands.** If an operation is not one of the + 21 existing playbooks, the console does not fake it. + +## 10. Troubleshooting + +| Symptom | Why | What to do | +|---|---|---| +| `ansible-playbook was not found on this machine` | categories 2/3 need Ansible on the driving machine | `sudo apt install ansible` (or `pipx install ansible-core`), or run the command from another admin host | +| `No Ansible inventory found under …` | no `ansible/inventory/hosts.yml` yet | `cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml`, then fill in real `bf-` hosts | +| Only `hosts.example.yml` exists | it holds example IDs and is never used automatically | copy it and replace the example devices | +| `Invalid dealer number ''` | must be exactly 8 digits | type the number as printed on the device record, leading zeros included | +| `bf- is not in the inventory` | the device is not registered | add it to the right wave group (§8), then retry | +| `… is not an approved wave` | a wave-scoped or release-gated operation got a non-wave group | pick one of `lab, pilot_1, pilot_2, wave_1, wave_2, production` | +| `Refusing to run : the console expects scope …` | the playbook's gates changed since the console table was written | update `admin/bf-menu` before using that playbook | +| `This operation needs root on THIS device` | local install runs as root | `sudo bf-menu` | +| Log warning about `/var/log/blueforce-console.log` | not writable, another admin owns it, or the mode is wrong | run with `sudo`, or pass `--log-file ` | + +## 11. Files + +| File | Role | +|---|---| +| `admin/bf-menu` | entry point: menu screens, operation catalogue, command building, confirmations, audit log | +| `admin/lib/tui.sh` | presentation and input helpers (screens, items, prompts, confirmations, messages) | +| `admin/lib/targets.sh` | device identity, inventory parsing, target selection, `--limit` construction | + +Library contract: `bf-menu` sets `BF_REPO`/`BF_INVENTORY` and sources both +libraries; `targets.sh` fills `BF_TARGET_KIND`, `BF_TARGET_LIMIT`, +`BF_TARGET_WAVE`, `BF_TARGET_COUNT`, `BF_TARGET_LABEL` and never renders UI or +executes anything; `tui.sh` never exits the process except in `tui_die`. + +Both libraries are safe to source on their own, which is how the target logic and +the inventory parser can be tested without a terminal. + +## 12. Verifying a change + +```bash +bash -n admin/bf-menu admin/lib/tui.sh admin/lib/targets.sh +shellcheck -x admin/bf-menu admin/lib/tui.sh admin/lib/targets.sh +admin/bf-menu --list # catalogue, no inventory needed +admin/bf-menu --list-targets # groups, hosts and counts +``` + +`--list` and `--list-targets` are the non-interactive entry points: they exercise +the catalogue and the inventory parser without touching a device. + +The console was checked on an admin workstation with a stub `ansible-playbook` +that echoes its arguments, covering: every scope/target combination and the exact +`-e` variables produced, the refusal paths (single device for release scope, +fleet-wide release run, wrong wave for a wave-scoped run, bad dealer number, +unknown device, bad package pin, service outside the allowlist, script outside +the approved directories, production/gate/fleet-wide words withheld), `--dry-run` +executing nothing, `--check` being appended, the missing-Ansible and +missing-inventory explanations, the log's 600 mode and its redaction of a test +secret, and clean EOF behaviour on a closed stdin. Playbook coverage was +cross-checked programmatically: all 21 playbooks in `ansible/playbooks/` appear +exactly once and every declared scope matches the playbook's own gate variables. +Real devices have not been touched yet — the LAB run is what turns these +expectations into evidence. - -"""Command execution and the audit log for the bfos console. + +--- +# Blueforce uzak erişim kanalı raporu -- READ-ONLY, dalga kapsamlı. +# Ne yapar: SSH, xRDP, RustDesk, MeshCentral ve WireGuard kanallarının birim (unit) durumunu +# raporlar; MeshCentral ve AnyDesk OPSİYONELDİR (AnyDesk kurulu değilse açıkça +# "NOT INSTALLED (OPTIONAL)" yazılır). Kanonik /usr/local/bin/bf-remote-status --check +# çıktısı bağımsız doğrulama olarak eklenir (docs/07, docs/11 K-04). +# Hiçbir görev durum değiştirmez: yalnız stat ve read-only CLI çıktısı okunur; tüm command +# görevleri changed_when: false taşır. +# Kuru koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-remote-channels-check.yml -e operation_wave=lab -e operation_confirmed=true --check +# Koşu: ansible-playbook -i inventory/hosts.yml playbooks/bf-remote-channels-check.yml -e operation_wave=lab -e operation_confirmed=true +# Semaphore Pro bağımlılığı yok. +# Dalga kapısı: operation_wave + operation_confirmed (+ production_override) rapor play'inden ÖNCE, +# hiçbir hosta dokunmadan doğrulanır; --limit dalga ile kesişmezse koşu reddedilir (fail-closed). +- name: Dalga ve onay kapısı -- hiçbir hosta dokunmadan önce (mutasyon yok) + hosts: "{{ ansible_limit | default('localhost') }}" + gather_facts: false + become: false + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + operation_wave_gate: "{{ operation_wave | default('invalid-wave', true) }}" + tasks: + - name: Require an approved confirmed operation wave before any host is touched + ansible.builtin.assert: + that: + - operation_wave_gate in approved_operation_waves + - operation_confirmed | default(false) | bool + - operation_wave_gate in groups + - groups[operation_wave_gate] | length > 0 + - (operation_wave_gate != 'production') or (production_override | default(false) | bool) + fail_msg: "operation_wave must name a non-empty approved inventory group (lab/pilot_1/pilot_2/wave_1/wave_2/production), operation_confirmed=true is required, and production additionally requires production_override=true." + run_once: true -Separation of duties: + - name: Require the --limit selection to intersect the declared wave + ansible.builtin.assert: + that: + - (((ansible_limit | default('') | split(',') | intersect(groups[operation_wave_gate]) | length) > 0) + or ((ansible_limit | default('') | split(',') | select('in', groups) | map('extract', groups) | flatten | unique | intersect(groups[operation_wave_gate]) | length) > 0)) + fail_msg: "--limit selection '{{ ansible_limit }}' does not intersect the '{{ operation_wave_gate }}' wave; refusing a mis-scoped run." + when: + - (ansible_limit | default('') | length) > 0 + - not (ansible_limit | default('') is search('[][*?]')) + run_once: true - * :mod:`bfos.operations` decides *what* may run and produces an argv list, - * this module decides *whether* it runs (confirmation, dry-run, check mode), - executes it with :mod:`subprocess`, reports the exit code, and writes the - audit record. +- name: Uzak erişim kanallarının durumunu raporla (read-only) + hosts: "{{ operation_wave | default('invalid-wave', true) }}" + gather_facts: false + become: true # Yalnız wg show ve root okuma gerektiren kanıtı (birim/state) OKUMAK için; mutasyon yok. + vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false + remote_channel_units: [ssh, sshd, xrdp, xrdp-sesman, rustdesk, meshagent, wg-quick@wg0] + remote_channel_props: [is-enabled, is-active] + remote_wg_interface: "{{ wg_interface | default('wg0') }}" + remote_wg_handshake_max_age: "{{ wg_handshake_max_age_seconds | default(180) }}" + remote_rustdesk_system_config: /etc/rustdesk/RustDesk2.toml + remote_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_status_bin: /usr/local/bin/bf-remote-status + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - groups[operation_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true -Guarantees kept from the bash console (``admin/bf-menu``): + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true + tasks: + - name: Kanal yapılandırma dosyalarını denetle (read-only) + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ remote_rustdesk_system_config }}" + - "{{ remote_rustdesk_user_config }}" + - "{{ remote_mesh_config }}" + - "{{ remote_status_bin }}" + register: remote_channel_files - * the command is always printed **before** it is confirmed and run, - * ``--dry-run`` executes nothing at all, - * the audit log is mode 600 (directories 700) and falls back to - ``$XDG_STATE_HOME/blueforce/`` when ``/var/log`` is not writable, - * no secret reaches the log: a single sanitizer redacts - ``password=``/``token=``/``secret=``/``api_key=``/``private_key=``/``psk=`` - style values and whole PEM blocks, - * raw command output is shown on screen and never copied into the log. + - name: Yapılandırma durumlarını yol ile indeksle + ansible.builtin.set_fact: + remote_channel_files_by_path: "{{ remote_channel_files.results | items2dict(key_name='item', value_name='stat') }}" + + - name: Birim etkin/aktif durumlarını oku (systemctl, read-only) + ansible.builtin.command: + argv: + - systemctl + - "{{ item[1] }}" + - --quiet + - "{{ item[0] }}" + loop: "{{ remote_channel_units | product(remote_channel_props) | list }}" + register: remote_channel_probe + changed_when: false + failed_when: false + + - name: Birim durumlarını tek sözlükte topla + ansible.builtin.set_fact: + bf_unit_states: "{{ bf_unit_states | default({}) | combine({ item.item[0] ~ ':' ~ item.item[1]: (item.rc == 0) }) }}" + loop: "{{ remote_channel_probe.results }}" + + - name: RustDesk paket durumunu oku (read-only) + ansible.builtin.command: + argv: + - dpkg-query + - -W + - -f=${Status} + - rustdesk + register: remote_rustdesk_pkg + changed_when: false + failed_when: false + + - name: AnyDesk (opsiyonel) paket durumunu oku (read-only) + ansible.builtin.command: + argv: + - dpkg-query + - -W + - -f=${Status} + - anydesk + register: remote_anydesk_pkg + changed_when: false + failed_when: false + + - name: WireGuard son handshake kaydını oku (read-only) + ansible.builtin.command: + argv: + - wg + - show + - "{{ remote_wg_interface }}" + - latest-handshakes + register: remote_wg_handshakes + changed_when: false + failed_when: false + + - name: Kontrol saatinin epoch değerini oku (read-only) + ansible.builtin.command: date +%s + register: remote_now_epoch + changed_when: false + + - name: Kanonik uzak erişim doğrulamasını çalıştır (bf-remote-status --check, read-only) + ansible.builtin.command: "{{ remote_status_bin }} --check" + register: remote_status_cli + changed_when: false + failed_when: false + when: remote_channel_files_by_path[remote_status_bin].exists | default(false) + + - name: Kanal hükümlerini hesapla + ansible.builtin.set_fact: + bf_ch_ssh: >- + {{ 'enabled-active' + if ((bf_unit_states.get('ssh:is-enabled', false) | bool and bf_unit_states.get('ssh:is-active', false) | bool) + or (bf_unit_states.get('sshd:is-enabled', false) | bool and bf_unit_states.get('sshd:is-active', false) | bool)) + else 'not-ready' }} + bf_ch_xrdp: >- + {{ 'enabled-active' + if (bf_unit_states.get('xrdp:is-enabled', false) | bool and bf_unit_states.get('xrdp:is-active', false) | bool + and bf_unit_states.get('xrdp-sesman:is-enabled', false) | bool and bf_unit_states.get('xrdp-sesman:is-active', false) | bool) + else 'not-ready' }} + bf_ch_rustdesk: >- + {{ 'installed-active' + if (remote_rustdesk_pkg.rc | default(1) == 0 and bf_unit_states.get('rustdesk:is-active', false) | bool) + else ('not-installed' if remote_rustdesk_pkg.rc | default(1) != 0 else 'not-ready') }} + bf_ch_meshcentral: >- + {{ 'not-configured' if not (remote_channel_files_by_path[remote_mesh_config].exists | default(false)) + else ('configured-active' + if (bf_unit_states.get('meshagent:is-enabled', false) | bool and bf_unit_states.get('meshagent:is-active', false) | bool) + else 'not-ready') }} + bf_ch_anydesk: "{{ 'installed (optional)' if remote_anydesk_pkg.rc | default(1) == 0 else 'NOT INSTALLED (OPTIONAL)' }}" + bf_ch_rustdesk_config: >- + {{ 'configured' if (remote_channel_files_by_path[remote_rustdesk_system_config].exists | default(false) + and remote_channel_files_by_path[remote_rustdesk_user_config].exists | default(false)) + else 'not-configured' }} + bf_ch_canonical_ready: "{{ 'ALL-READY' if remote_status_cli.rc | default(1) == 0 else 'NOT-READY' }}" + + - name: WireGuard handshake epoch listesini çıkar (read-only kanıt) + ansible.builtin.set_fact: + bf_wg_epochs: "{{ remote_wg_handshakes.stdout | default('') | regex_findall('(?m)([0-9]+)$') | map('int') | list }}" + + - name: WireGuard en yeni handshake kaydını belirle + ansible.builtin.set_fact: + bf_wg_latest_epoch: "{{ (bf_wg_epochs | max | int) if bf_wg_epochs | length > 0 else 0 }}" + + - name: WireGuard handshake yaşını hesapla + ansible.builtin.set_fact: + bf_wg_handshake_age: "{{ (remote_now_epoch.stdout | default('0') | int) - (bf_wg_latest_epoch | int) }}" + + - name: WireGuard kanal hükmünü yaz + ansible.builtin.set_fact: + bf_ch_wireguard: >- + {{ 'no-handshake' if bf_wg_latest_epoch | int <= 0 + else ('fresh-handshake' if bf_wg_handshake_age | int <= remote_wg_handshake_max_age | int else 'stale-handshake') }} + + - name: Zorunlu kanal hazır/ready satırını yaz + ansible.builtin.set_fact: + bf_required_channel_line: >- + ssh:{{ 'READY' if bf_ch_ssh == 'enabled-active' else 'NOT-READY' }} + xrdp:{{ 'READY' if bf_ch_xrdp == 'enabled-active' else 'NOT-READY' }} + rustdesk:{{ 'READY' if bf_ch_rustdesk == 'installed-active' else 'NOT-READY' }} + wireguard:{{ 'READY' if bf_ch_wireguard == 'fresh-handshake' else 'NOT-READY' }} + + - name: Zorunlu kanalların hazır durumunu ve sayısını belirle + ansible.builtin.set_fact: + bf_required_channel_ready: "{{ [bf_ch_ssh == 'enabled-active', bf_ch_xrdp == 'enabled-active', bf_ch_rustdesk == 'installed-active', bf_ch_wireguard == 'fresh-handshake'] | select | list | length }}" + + - name: Kanal satırlarını raporla + ansible.builtin.debug: + msg: + - "{{ bf_device_label }} ssh={{ bf_ch_ssh }} xrdp={{ bf_ch_xrdp }} rustdesk={{ bf_ch_rustdesk }} wireguard={{ bf_ch_wireguard }}" + - "{{ bf_device_label }} meshcentral={{ bf_ch_meshcentral }} (opsiyonel) anydesk={{ bf_ch_anydesk }} rustdesk_config={{ bf_ch_rustdesk_config }}" + - "{{ bf_device_label }} hazır zorunlu kanal={{ bf_required_channel_ready }}/4 kanonik_bf_remote_status={{ bf_ch_canonical_ready }}" + vars: + bf_device_label: "{{ inventory_hostname | upper }}" + + - name: Kanonik doğrulama çıktısını yaz (bağımsız kanıt) + ansible.builtin.debug: + msg: "bf-remote-status --check rc={{ remote_status_cli.rc | default(1) }} cikti={{ remote_status_cli.stdout_lines | default([]) }}" + when: remote_channel_files_by_path[remote_status_bin].exists | default(false) + + - name: Hazır olmayan kanalı işaretle (opsiyoneller hariç) + ansible.builtin.debug: + msg: "UYARI {{ inventory_hostname | upper }}: kanal hazır değil -> {{ bf_not_ready_channels }}" + vars: + bf_not_ready_channels: >- + {{ (['ssh'] if bf_ch_ssh != 'enabled-active' else []) + + (['xrdp'] if bf_ch_xrdp != 'enabled-active' else []) + + (['rustdesk'] if bf_ch_rustdesk != 'installed-active' else []) + + (['wireguard'] if bf_ch_wireguard != 'fresh-handshake' else []) }} + when: bf_not_ready_channels | length > 0 + + - name: MeshCentral (opsiyonel) kanalının kapalı olduğunu bilgi olarak yaz + ansible.builtin.debug: + msg: "BİLGİ {{ inventory_hostname | upper }}: MeshCentral durumu={{ bf_ch_meshcentral }}; MeshCentral/AnyDesk opsiyoneldir, READY kapısının parçası değildir." + when: bf_ch_meshcentral != 'configured-active' + + - name: Kanal başına hazır cihaz sayısını hesapla + ansible.builtin.set_fact: + bf_channel_ready_counts: "{{ bf_channel_ready_counts | default({}) | combine({ item: bf_channel_lines | select('search', item ~ ':READY') | list | length }) }}" + loop: [ssh, xrdp, rustdesk, wireguard] + vars: + bf_channel_lines: "{{ ansible_play_hosts_all | map('extract', hostvars, 'bf_required_channel_line') | map('default', '', true) | list }}" + run_once: true + + - name: Dalga kanal özetini yaz + ansible.builtin.debug: + msg: + - "Dalga {{ operation_wave }} | raporlanan cihaz: {{ ansible_play_hosts_all | length }}" + - "Hazır kanal başına cihaz sayısı: {{ bf_channel_ready_counts | default({}) }}" + - "İki kanaldan az erişimi olan cihazlar: {{ bf_single_channel_hosts }}" + vars: + bf_single_channel_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_required_channel_ready') | map('default', 0) | map('int') | list)) | dict2items | selectattr('value', 'lt', 2) | map(attribute='key') | list }}" + run_once: true + + - name: Kanonik doğrulamada hazır olmayan cihazların dalga listesini yaz + ansible.builtin.debug: + msg: "bf-remote-status --check başarısız (merkez kanıtı dahil): {{ bf_canonical_not_ready_hosts }}" + vars: + bf_canonical_not_ready_hosts: "{{ dict(ansible_play_hosts_all | zip(ansible_play_hosts_all | map('extract', hostvars, 'bf_ch_canonical_ready') | map('default', 'NOT-READY', true) | list)) | dict2items | selectattr('value', 'equalto', 'NOT-READY') | map(attribute='key') | list }}" + run_once: true + + + +"""Operation model for the bfos operator console. + +This module is the behaviour contract of the console, ported from the proven +Python GUI and TUI surfaces so all entry +points can never disagree: + + * the 21 Ansible playbooks of ``ansible/playbooks/`` with their declared + scope (simple / wave / release) and destructive flag, + * the local install flow (``scripts/install/blueforce-install.sh``), + * the local diagnostics tools (``bf-status``, ``bf-diagnostics``, + ``bf-support-bundle``, ``bf-release``, ``bf-remote-status`` ...), + * target selection: dealer number ``^[0-9]{8}$``, group/wave, or ``all``, + * the safety gates: scope cross-check against the playbook file, production + override, release/wave tokens, destructive second confirmation, the + fleet-wide wave-by-wave loop that stops at the first failure. + +Design rules: + * no UI and no execution: every function here is pure or reads a file, and + every command is *produced* as an argv list for :mod:`bfos.runner`, + * refusals are raised as :class:`Refused` carrying a Turkish operator + message; nothing is auto-confirmed or silently downgraded, + * standard library only, Python 3.8+. """ from __future__ import annotations +import json import os +import platform import re -import shlex -import subprocess -import sys -from dataclasses import dataclass, field -from typing import Callable, List, Optional, Sequence +import socket +import stat +from dataclasses import dataclass, field, replace +from typing import Dict, Iterable, List, Optional, Sequence, Tuple from . import config # --------------------------------------------------------------------------- -# Secret redaction -- the single choke point before anything is logged +# Refusals # --------------------------------------------------------------------------- -_SECRET_KEY = r"(?:password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)" -_SECRET_ASSIGN_RE = re.compile( - r"((?:" + _SECRET_KEY + r")[A-Za-z0-9_.-]*\s*[=:])\s*(\S+)", - re.IGNORECASE, -) -_PEM_LINE_RE = re.compile(r"BEGIN [A-Z0-9 ]*PRIVATE KEY") -_PEM_BLOCK_RE = re.compile( - r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", - re.DOTALL, -) - -REDACTED = "" -def redact(text: str) -> str: - """Return TEXT with secret-looking values and PEM blocks removed. +class Refused(Exception): + """A refusal with an operator-facing Turkish explanation. - Deliberately blunt: log records are written for every command, so the - sanitizer must be safe for text that was never meant to be a secret. A PEM - header without its matching footer (truncated line, interrupted paste) is - treated as the start of a secret: everything from the header on is dropped, - because a half-copied key is still a key. + ``detail`` holds optional extra lines (hints, remediation) so the UI can + print the message and the hint separately. """ - if not text: - return text - cleaned = _PEM_BLOCK_RE.sub(REDACTED, text) - header = _PEM_LINE_RE.search(cleaned) - if header: - cleaned = cleaned[:header.start()] + REDACTED - cleaned = _SECRET_ASSIGN_RE.sub(lambda match: match.group(1) + REDACTED, cleaned) - return cleaned + + def __init__(self, message: str, detail: str = "") -> None: + super().__init__(message) + self.message = message + self.detail = detail # --------------------------------------------------------------------------- -# Audit log +# Identity helpers (mirrors admin/lib/targets.sh) # --------------------------------------------------------------------------- -def fallback_log_path(state_home: Optional[str] = None, home: Optional[str] = None) -> str: - base = state_home or os.environ.get("XDG_STATE_HOME") or os.path.join( - home or os.path.expanduser("~"), ".local", "state" - ) - return os.path.join(base, *config.LOG_FALLBACK_SUBDIR) +DEALER_RE = re.compile(config.DEALER_PATTERN) +HOST_RE = re.compile(config.HOST_PATTERN) -def _prepare_log_file(path: str) -> bool: - """Create or tighten PATH to mode 600; True when it is usable.""" - directory = os.path.dirname(path) or "." - try: - if os.path.exists(path): - if not os.path.isfile(path) or not os.access(path, os.W_OK): - return False - os.chmod(path, config.LOG_FILE_MODE) - else: - if not os.path.isdir(directory): - os.makedirs(directory, mode=config.LOG_DIR_MODE, exist_ok=True) - if not os.access(directory, os.W_OK): - return False - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, config.LOG_FILE_MODE) - os.close(fd) - os.chmod(path, config.LOG_FILE_MODE) - except OSError: - return False - return _is_private(path) +def dealer_is_valid(value: str) -> bool: + return bool(DEALER_RE.match(value or "")) -def _is_private(path: str) -> bool: - """True when only the owner can read the log (never log into a world-readable file).""" - try: - mode = os.stat(path).st_mode - except OSError: - return False - return (mode & 0o077) == 0 +def dealer_to_host(dealer: str) -> str: + return "bf-{0}".format(dealer) -@dataclass -class AuditLog: - """Append-only audit trail. Never receives raw command output.""" - - requested: str = config.DEFAULT_LOG_FILE - state_home: Optional[str] = None - home: Optional[str] = None - path: Optional[str] = None - ready: bool = False - warning: str = "" - - def initialise(self) -> None: - for candidate in (self.requested, fallback_log_path(self.state_home, self.home)): - if not candidate: - continue - if _prepare_log_file(candidate): - self.path = candidate - self.ready = True - if candidate != self.requested: - self.warning = config.MSG_LOG_FALLBACK.format( - requested=self.requested, fallback=candidate - ) - return - self.ready = False - self.warning = config.MSG_LOG_NONE - - def write(self, level: str, message: str, timestamp: Optional[str] = None) -> None: - if not self.ready or not self.path: - return - stamp = timestamp or _utc_now() - record = "{0} [{1}] {2}\n".format(stamp, level, redact(message)) - try: - with open(self.path, "a", encoding="utf-8") as handle: - handle.write(record) - except OSError: - # A failing log must never break an operator's session. - self.ready = False +def dealer_to_device_id(dealer: str) -> str: + return "BF-{0}".format(dealer) -def _utc_now() -> str: - import datetime +def host_to_dealer(host: str) -> Optional[str]: + match = HOST_RE.match(host or "") + return match.group(0)[3:] if match else None - return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + +def is_wave(name: str) -> bool: + return name in config.WAVES + + +def waves_csv() -> str: + return ",".join(config.WAVES) + + +def waves_readable() -> str: + return ", ".join(config.WAVES) # --------------------------------------------------------------------------- -# Execution +# Operation catalogue -- 21 playbooks, one row each # --------------------------------------------------------------------------- -RUN_MODE_RUN = "run" -RUN_MODE_CHECK = "check" -RUN_MODE_CANCEL = "cancel" +PROMPT_NONE = "none" +PROMPT_PACKAGES = "packages" +PROMPT_SERVICE = "service" +PROMPT_SCRIPT = "script" +PROMPT_GRACE = "grace" +PROMPT_DISK_PCT = "disk_pct" +PROMPT_LOG_LINES = "log_lines" +PROMPT_VERSION = "expected_version" + +CATEGORY_REPORT = "report" +CATEGORY_MAINTENANCE = "maintenance" -@dataclass -class RunResult: - argv: List[str] = field(default_factory=list) - executed: bool = False - dry_run: bool = False - check: bool = False - cancelled: bool = False - rc: Optional[int] = None - signal_name: str = "" +@dataclass(frozen=True) +class Operation: + """One fleet operation, bound to exactly one existing playbook.""" + + ident: str + label: str + playbook: str + declared_scope: str + destructive: bool + prompt: str + description: str + category: str @property - def ok(self) -> bool: - return self.executed and self.rc == 0 + def target_mode(self) -> str: + """Target mode the operator is offered for this operation.""" + if self.declared_scope == config.SCOPE_RELEASE: + return config.MODE_RELEASE + if self.declared_scope == config.SCOPE_WAVE: + return config.MODE_WAVE + return config.MODE_ANY -def command_string(argv: Sequence[str]) -> str: - """A copy/pasteable shell line (like ``printf %q`` in the bash console).""" - return " ".join(shlex.quote(part) for part in argv) +# 2.1 Read-only fleet reports -- no playbook in this list changes a device. +REPORT_OPERATIONS: Tuple[Operation, ...] = ( + Operation("ping", "Erişilebilirlik testi (ping)", "bf-ping.yml", "simple", False, PROMPT_NONE, + "SSH erişimi kontrol edilir (salt okunur)", CATEGORY_REPORT), + Operation("uptime", "Çalışma süresi", "bf-uptime.yml", "simple", False, PROMPT_NONE, + "Açık kalma süresi ve sistem yükü", CATEGORY_REPORT), + Operation("disk", "Disk kullanımı", "bf-disk-check.yml", "simple", False, PROMPT_DISK_PCT, + "Disk doluluğu; eşik üstünde uyarı verir", CATEGORY_REPORT), + Operation("docker", "Docker durumu", "bf-docker-status.yml", "simple", False, PROMPT_NONE, + "Docker servisi ve konteynerler (salt okunur)", CATEGORY_REPORT), + Operation("packages", "Paket sürümleri", "bf-package-check.yml", "simple", False, PROMPT_NONE, + "Sabitlenmiş paketlerin kurulu sürümleri", CATEGORY_REPORT), + Operation("security-updates", "Bekleyen güvenlik güncellemeleri", + "bf-security-updates-check.yml", "simple", False, PROMPT_NONE, + "Yalnız rapor; hiçbir paket kurmaz", CATEGORY_REPORT), + Operation("provisioning-status", "Kurulum fazı raporu", "bf-provisioning-status.yml", "wave", + False, PROMPT_NONE, "Cihaz başına state.json fazı", CATEGORY_REPORT), + Operation("enrollment-status", "Kayıt ve merkez kanıtı", "bf-enrollment-status.yml", "wave", + False, PROMPT_NONE, "ENROLLED/READY iddiası ↔ merkez kanıtı", CATEGORY_REPORT), + Operation("fieldos-version", "Field OS sürüm raporu", "bf-fieldos-version.yml", "wave", + False, PROMPT_VERSION, "Cihaz başına sürüm; isteğe bağlı sapma denetimi", + CATEGORY_REPORT), + Operation("remote-channels", "Uzak erişim kanalları", "bf-remote-channels-check.yml", "wave", + False, PROMPT_NONE, "SSH/xRDP/RustDesk/WireGuard hazırlığı", CATEGORY_REPORT), + Operation("offline-ready", "Çevrimdışı hazırlık raporu", "bf-offline-ready-check.yml", "wave", + False, PROMPT_NONE, "İnternetsiz kurulum için yerel ön koşullar", CATEGORY_REPORT), +) + +# 2.2 Fleet maintenance -- destructive rows ask a second, explicit question. +MAINTENANCE_OPERATIONS: Tuple[Operation, ...] = ( + Operation("rdp-restart", "RDP servisini yeniden başlat", "bf-rdp-restart.yml", "simple", True, + PROMPT_NONE, "xrdp servisini yeniden başlatır", CATEGORY_MAINTENANCE), + Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "bf-rustdesk-restart.yml", + "simple", True, PROMPT_NONE, "RustDesk servisini yeniden başlatır", + CATEGORY_MAINTENANCE), + Operation("wireguard-restart", "WireGuard servisini yeniden başlat", + "bf-wireguard-restart.yml", "simple", True, PROMPT_NONE, + "wg-quick@wg0 servisini yeniden başlatır", CATEGORY_MAINTENANCE), + Operation("service-restart", "İzinli bir servisi yeniden başlat", "bf-service-restart.yml", + "simple", True, PROMPT_SERVICE, "Yalnız izinli servis adları kabul edilir", + CATEGORY_MAINTENANCE), + Operation("gui-off", "Grafik arayüzü kapat (headless)", "bf-gui-off.yml", "wave", True, + PROMPT_NONE, "Dalgayı multi-user.target'e alır", CATEGORY_MAINTENANCE), + Operation("gui-on", "Grafik arayüzü aç (bakım)", "bf-gui-on.yml", "wave", True, PROMPT_NONE, + "Grafik oturumu yeniden başlatır", CATEGORY_MAINTENANCE), + Operation("collect-logs", "Log topla / destek paketi", "bf-collect-logs.yml", "wave", False, + PROMPT_LOG_LINES, "Zaman damgalı journal kaydı ve destek paketi", + CATEGORY_MAINTENANCE), + Operation("run-script", "Onaylı betik çalıştır", "bf-run-script.yml", "wave", True, + PROMPT_SCRIPT, "Yalnız /opt/blueforce/bin ve /usr/local/sbin", + CATEGORY_MAINTENANCE), + Operation("reboot", "Kontrollü yeniden başlatma", "bf-reboot.yml", "release", True, + PROMPT_GRACE, "Sağlık kapılı, sıralı yeniden başlatma", CATEGORY_MAINTENANCE), + Operation("deploy-update", "Onaylı paket güncellemesi kur", "bf-deploy-update.yml", "release", + True, PROMPT_PACKAGES, "Sabit sürümlü paketler, sağlık kapılı", CATEGORY_MAINTENANCE), +) + +ALL_OPERATIONS: Tuple[Operation, ...] = REPORT_OPERATIONS + MAINTENANCE_OPERATIONS + +#: 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 +#: actually declares it (a permanent parity rule with the Python catalogue). +VAR_OPERATION_WAVE = "operation_wave" +VAR_RELEASE_WAVE = "release_wave" +VAR_OPERATION_CONFIRMED = "operation_confirmed" +VAR_WAVE_GATE_CONFIRMED = "wave_gate_confirmed" +VAR_REBOOT_CONFIRMED = "reboot_confirmed" +VAR_PRODUCTION_OVERRIDE = "production_override" +VAR_ALLOW_PRODUCTION_REBOOT = "allow_production_reboot" + +GATE_VARIABLES = ( + VAR_OPERATION_WAVE, + VAR_RELEASE_WAVE, + VAR_WAVE_GATE_CONFIRMED, + VAR_REBOOT_CONFIRMED, + VAR_PRODUCTION_OVERRIDE, + VAR_ALLOW_PRODUCTION_REBOOT, +) -def default_spawn(argv: Sequence[str]) -> int: - """Run ARGV with inherited stdio and return its exit code.""" - try: - completed = subprocess.run(list(argv), check=False) - return completed.returncode - except FileNotFoundError: - return 127 - except PermissionError: - return 126 - except KeyboardInterrupt: - return 130 - - -class Runner: - """Show, confirm and execute commands; record every decision.""" - - def __init__( - self, - console, - audit: Optional[AuditLog] = None, - dry_run: bool = False, - check_mode: bool = False, - spawn: Optional[Callable[[Sequence[str]], int]] = None, - ) -> None: - self.console = console - self.audit = audit or AuditLog() - self.dry_run = dry_run - self.check_mode = check_mode - self._spawn = spawn or default_spawn - - # -- helpers ----------------------------------------------------------- - def show_command(self, argv: Sequence[str], title: str = config.MSG_COMMAND_TITLE) -> str: - printable = command_string(argv) - self.console.section(title) - self.console.write(" " + printable) - self.console.write("") - return printable - - def _check_flag(self, argv: List[str]) -> List[str]: - if "--check" not in argv: - return argv + ["--check"] - return argv - - def _spawn_exit_code(self, argv: Sequence[str]) -> RunResult: - try: - rc = self._spawn(argv) - except KeyboardInterrupt: - return RunResult(argv=list(argv), executed=True, rc=130, signal_name="SIGINT") - return RunResult(argv=list(argv), executed=True, rc=rc) - - # -- public API -------------------------------------------------------- - def run_readonly(self, argv: Sequence[str], title: str) -> RunResult: - """Run a read-only tool: printed, not confirmed (mirrors bf_show_local_cmd).""" - self.show_command(argv, title=title) - if self.dry_run: - self.console.warn(config.MSG_DRY_RUN_WARN) - self.audit.write(config.AUDIT_DRYRUN, command_string(argv)) - return RunResult(argv=list(argv), dry_run=True) - self.audit.write(config.AUDIT_RUN, command_string(argv)) - result = self._spawn_exit_code(argv) - if result.rc == 0: - self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(argv)) - else: - self.console.warn(config.MSG_RUN_FAIL.format(rc=result.rc)) - self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(argv))) - return result - - def confirm_and_run( - self, - argv: Sequence[str], - label: str, - destructive: bool = False, - check_mode: Optional[bool] = None, - ) -> RunResult: - """Print the command, ask, then run it (or cancel / dry-run).""" - self.show_command(argv) - if self.dry_run: - self.console.warn(config.MSG_DRY_RUN_WARN) - self.audit.write(config.AUDIT_DRYRUN, command_string(argv)) - return RunResult(argv=list(argv), dry_run=True) - - if destructive: - self.console.warn(config.MSG_DESTRUCTIVE_WARN.format(label=label)) - self.console.warn(config.MSG_DESTRUCTIVE_SUB) - - mode = self.console.ask_run(config.MSG_RUN_ASK) - if mode == RUN_MODE_RUN: - final = list(argv) - elif mode == RUN_MODE_CHECK: - final = self._check_flag(list(argv)) - self.console.info(config.MSG_CHECK_MODE) - else: - self.console.info(config.MSG_CANCELLED_RUN) - self.audit.write(config.AUDIT_CANCEL, "operatör iptal etti :: " + command_string(argv)) - return RunResult(argv=list(argv), cancelled=True) - - # NOTE: this method does not enforce root. The terminal console gates a - # destructive operation earlier (App.require_root, config.MSG_ROOT_REQUIRED) - # and the GUI gates it at its execution choke point (ui_gui.run_plan). - # Enforcing here as well double-guarded the same rule and fired before - # the console's own message, so the operator saw the wrong explanation. - self.audit.write(config.AUDIT_RUN, command_string(final)) - result = self._spawn_exit_code(final) - result.check = "--check" in final - if result.rc == 0: - self.console.ok(config.MSG_RUN_OK) - self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(final)) - else: - self.console.error(config.MSG_RUN_FAIL.format(rc=result.rc)) - self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(final))) - return result - - # -- checks ------------------------------------------------------------ - @staticmethod - def have_ansible() -> bool: - return _which("ansible-playbook") is not None - - @staticmethod - def have_python() -> bool: - return sys.version_info >= config.MIN_PYTHON - - @staticmethod - def is_root() -> bool: - return os.geteuid() == 0 - - -def _which(name: str) -> Optional[str]: - for directory in os.environ.get("PATH", "").split(os.pathsep): - candidate = os.path.join(directory, name) - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): - return candidate +def find_operation(ident: str) -> Optional[Operation]: + for operation in ALL_OPERATIONS: + if operation.ident == ident: + return operation return None - - -"""bfos/ui_gui.py -- Blueforce Field OS Turkce masaustu arayuzu (tkinter). -Ne yapar --------- -`admin/bf-menu` terminal konsolunun yaptigi isi ayni is mantigi uzerinden bir -gercek pencereye tasir: +def operations_of(category: str) -> Tuple[Operation, ...]: + return tuple(op for op in ALL_OPERATIONS if op.category == category) - * basit mod : uc buyuk buton -- Kur / Durum / Cikis (+ "Detayli mod"), - * detayli mod: kategori sekmeleri -- Kurulum, Cihaz islemleri, Merkez - hazirligi, Araclar, Cihaz bilgisi, - * hedef secimi: bayi no alani (^[0-9]{8}$ dogrulamali) + dalga grubu listesi, - * her komut CALISTIRILMADAN ONCE gosterilir ve onay ister; yikici islemlerde - AYRICA ikinci onay istenir, production dalgasi icin "production" yazilir, - * komut ciktisi canli olarak kaydirilabilir bir metin kutusuna akar, - * ekran yoksa (SSH/headless) veya tkinter kurulu degilse net TURKCE hata - verir ve terminal menusunu onerir. -====================================================================== -SOZLESME -- backend arareyuzu (bfos/operations.py ve bfos/runner.py icin) -====================================================================== -Bu dosya is mantigini TAKLIT ETMEZ; onu bir "backend adapter" uzerinden -kullanir. Gercek moduller (bfos.operations, bfos.runner) yazildiginda asagidaki -sozlesmeyi saglamalari yeterlidir; ui_gui.py onlari otomatik olarak adapte eder. +def catalogue_rows() -> Tuple[Operation, ...]: + """All operations ordered exactly like the bash catalogue.""" + return tuple(sorted(ALL_OPERATIONS, key=lambda op: (op.category != CATEGORY_REPORT, op.ident))) -Sozlesme (bu dosyada tanimli veri tipleriyle ayni alan adlari): - -1) Operasyon listesi - list_operations() -> Sequence[Operation] - ya da OPERATIONS / OPS / OPERATION_CATALOGUE isimli bir dizi. - Operation alanlari: id (str, tekil), label (str, TURKCE ad), - category (str, su bes kategoriden biri: "Kurulum", "Cihaz islemleri", - "Merkez hazirligi", "Araclar", "Cihaz bilgisi"), destructive (bool), - scope ("local"|"simple"|"wave"|"release"), description (str), - playbook (str, sadece filo islemleri icin "bf-*.yml" adi). - -2) Hedef secimi - Target(kind="dealer"|"group"|"all", value="12010193"|"pilot_1"|"all") - - dealer -> Ansible --limit bf-<8 hane>, insan kimligi BF-<8 hane> - - group -> dalga/grup adi; wave/release kapsamli islemler SADECE onayli - bir dalga grubu kabul eder (lab, pilot_1, pilot_2, wave_1, wave_2, - production); release kapsamli islemler tek cihaz veya "all" KABUL ETMEZ. - -3) Komut uretimi (calistirmadan) - build_command(operation, target, options, grants=None, check_mode=False) - -> CommandPlan - CommandPlan: - * commands : her biri bir argv olan demet (satir listesi - `CommandPlan.lines` uzerinden shlex ile uretilir), - * report_lines: SALT-OKUNUR rapor satirlari; commands bos ise hicbir sey - calistirilmaz, satirlar dogrudan gosterilir, - * destructive / requires_root / operation_id / target_label / notes, - * dry_run / check_mode. - options : {secenek_adi: str} (bkz. Operation.options) - grants : {"wave_gate_confirmed"|"production_override"| - "allow_production_reboot"|"fleet_wide": bool} -- operatorun - ACIK onaylari. Bir kapi bayragi yalniz onay verildiginde ve - playbook o degiskeni kullaniyorsa komuta eklenir. - check_mode: True ise komut --check ile uretilir (degisiklik uygulanmaz). - -4) Kapi onaylari (acik onay adimlari) - gate_steps(operation, target) -> Sequence[GateStepInfo] - GateStepInfo(kind="token"|"approval", key, prompt, message, token, unlocks) - - "token" : operator kutulara `token` sozcugunu yazmak zorundadir - (dalga adi, "production", "GATE", "all"), - - "approval" : evet/hayir onayi (yikici ikinci onay dahil), - - unlocks : onay verilince build_command'a gecirilecek grant adlari. - Arayuz onaylarin TAMAMINI sorar; hicbir kapi sessizce gecilmez. - -5) Komut calistirma (cikti + cikis kodu) - run(plan, on_line=None, cancel=None) -> RunResult(exit_code, ok, output) - on_line(str) her cikti satirinda cagrilir (canli akis icin); - cancel threading.Event ile calisan komut sonlandirilir. - -Uyumluluk icin adapter su adlari da dener (sirayla): - katalog : catalogue_rows | ALL_OPERATIONS | list_operations | operations | - get_operations | OPERATIONS | OPS - komut : build_command | build_plan | command_for | build_ansible_cmd - kosum : run | run_plan | run_command | execute -ve donus degerlerini (dict / dataclass / object) normalize eder. - -bfos.operations ve bfos.runner MEVCUTSA (bu repodaki gercek moduller) arayuz -dogrudan onlari kullanir; hicbir kural kopyalanmaz: - katalog : catalogue_rows() / ALL_OPERATIONS - envanter/hedef : Repository + Inventory.load_repository + resolve_target - komut uretimi : build_ansible_command / build_install_command / - build_tool_command / build_extra_vars - kapilar : confirmation_plan + GatesGranted - denetim kaydi : runner.AuditLog (+ runner.redact) - salt-okunur ekranlar: targets_lines / central_*_lines / identity_lines / - state_lines / environment_lines -Gercek moduller yoksa dosya kendi YERLESIK KATALOG'unu kullanir: bu katalog -admin/bf-menu'nun tablosuyla (id, playbook, kapsam, yikici, ek degisken -kurallari) birebir ayni kurallara ve ayni komut uretimine dayanir. -====================================================================== - -Kullanim: - python3 -m bfos.ui_gui # basit mod - python3 -m bfos.ui_gui --full # dogrudan detayli mod - python3 -m bfos.ui_gui --list # pencere acmadan katalogu yazdir - python3 -m bfos.ui_gui --dry-run # komutlari goster, calistirma - -Notlar: - * tkinter Ubuntu'da hazir gelir (paket: python3-tk); indirme gerekmez. - * Bu dosya tkinter olmadan da ice aktarilabilir: yardimci fonksiyonlar - (dogrulama, komut onizleme, yikici siniflama, headless tespiti) tkinter'siz - calisir, bu yuzden statik testler pencere ACMADAN kosar. -""" - -from __future__ import annotations - -import argparse -import json -import os -import queue -import re -import shlex -import shutil -import subprocess -import sys -import threading -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Callable, Mapping, Sequence # --------------------------------------------------------------------------- -# Sabitler -- kullanicinin gordugu TUM metinler Turkce +# Playbook file: the source of truth for a playbook's own gates # --------------------------------------------------------------------------- -WINDOW_TITLE = "Blueforce Field OS — Yönetim" -APP_SUBTITLE = "Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)" +def non_comment_text(text: str) -> str: + """The playbook text without full-line comments. -# Basit modda gorunen uc buyuk buton (sira onemli). -SIMPLE_MODE_BUTTONS: tuple[str, ...] = ("Kur", "Durum", "Çıkış") -SIMPLE_MODE_LABEL = "Detaylı mod" -SIMPLE_MODE_BACK_LABEL = "Basit moda dön" - -#: Birincil eylemin metni (docs/33 §9.2 ile ayni kalir). -RUN_BUTTON_LABEL = "Komutu göster ve çalıştır" - -#: Islem listesindeki tur ve durum sutunlari: renk + SOZCUK (renksiz de okunur). -TYPE_WRITE_LABEL = "YIKICI" -TYPE_READ_LABEL = "salt-okunur" -AVAILABLE_LABEL = "açık" -BLOCKED_LABEL = "kapalı — sudo bf" - -#: Komut cikti panelini gizle/goster (kanit erisilebilir kalir, ekrani kaplamaz). -OUTPUT_HIDE_LABEL = "Çıktı panelini gizle" -OUTPUT_SHOW_LABEL = "Çıktı panelini göster" - -# Detayli moddaki kategori sekmeleri (docs/33 §9.2 ile ayni bes kategori). -CATEGORIES: tuple[str, ...] = ( - "Kurulum", - "Cihaz işlemleri", - "Merkez hazırlığı", - "Araçlar", - "Cihaz bilgisi", -) - -# Onayli dalga sirasi (docs/10; admin/lib/targets.sh BF_WAVES ile ayni). -WAVES: tuple[str, ...] = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production") -PRODUCTION_WAVE = "production" -GATE_TOKEN_RELEASE = "GATE" - -DEALER_PATTERN = re.compile(r"^[0-9]{8}$") -SCOPE_LABELS: Mapping[str, str] = { - "local": "yerel — bu cihaz", - "simple": "filo — tek cihaz/grup (salt-okunur)", - "wave": "filo — dalga kapılı", - "release": "filo — sürüm kapılı (onaylı dalga)", -} - -# admin/lib/targets.sh + admin/bf-menu ile ayni kurallar. -SERVICE_ALLOWLIST: tuple[str, ...] = ( - "blueforce-agent", - "docker", - "ssh", - "sshd", - "systemd-journald", - "cron", -) -SCRIPT_DIRS: tuple[str, ...] = ("/opt/blueforce/bin", "/usr/local/sbin") -MODULE_PATTERN = re.compile(r"^[0-9]{2}-[a-z0-9][a-z0-9._-]*$") -PACKAGE_PIN_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$") -HOST_PATTERN = re.compile(r"^bf-[0-9]{8}$") -STATE_JSON_PATH = "/var/lib/blueforce/state.json" -STATE_ALLOWED_KEYS: tuple[str, ...] = ( - "phase", - "enrollment_status", - "fleet_status", - "device_id", - "provisioning_id", -) -INSTALL_STATE_PATH = "/var/lib/blueforce/install-state" -INSTALL_LOG_PATH = "/var/log/blueforce-install.log" - -#: --check eklenebilecek yerel komutlar (kurulum betigi ve kapi araclari). -CHECK_CAPABLE_LOCAL_OPS: tuple[str, ...] = ( - "install-preflight", - "install-local", - "install-resume", - "install-module", - "tools-gate-local", - "tools-gate-enrollment", - "tools-gate-ready", -) -DEFAULT_CONSOLE_LOG = "/var/log/blueforce-console.log" -INSTALLER_REL = "scripts/install/blueforce-install.sh" -PLAYBOOK_DIR_REL = "ansible/playbooks" -MENU_REL = "admin/bf-menu" - -TK_PACKAGE_HINT = "sudo apt install -y python3-tk" -# 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 -# konsolu ana yoldur, o yüzden önce o önerilir. -TERMINAL_MENU_HINT = "sudo bf --tui" - -# --------------------------------------------------------------------------- -# tkinter -- opsiyonel ice aktarma -# --------------------------------------------------------------------------- -class _MissingTk: - """tkinter yoksa: kullanildigi anda Turkce hata veren yer tutucu. - - Amac iki yonlu: (1) modul tkinter'siz de ice aktarilabilir kalsin, - (2) GUI kodundaki `tk.X`/`ttk.X` tip denetimi gecerli bir nesne gordugu - icin None uzerinden gelen yanlis uyarilar uretilmesin. + Deliberately small and deterministic parser: a + trailing comment is kept, so the console and the bash console can never + disagree about what a playbook declares. """ + keep = [line for line in text.splitlines() if not line.lstrip().startswith("#")] + return "\n".join(keep) - def __getattr__(self, name: str): - raise RuntimeError( - "tkinter kurulu değil (" + TK_IMPORT_ERROR + "). " - f"Kurmak için: {TK_PACKAGE_HINT}" + +def playbook_uses_var(text: str, var: str) -> bool: + """True when VAR appears as a whole word outside the playbook's comments.""" + pattern = r"\b{0}\b".format(re.escape(var)) + return re.search(pattern, non_comment_text(text)) is not None + + +def playbook_scope(text: str) -> str: + """Derive wave / release / simple from the playbook's own variables.""" + if playbook_uses_var(text, VAR_RELEASE_WAVE): + return config.SCOPE_RELEASE + if playbook_uses_var(text, VAR_OPERATION_WAVE): + return config.SCOPE_WAVE + return config.SCOPE_SIMPLE + + +class Repository: + """Read-only view of the Blueforce checkout the console runs from.""" + + def __init__(self, root: str) -> None: + self.root = os.path.abspath(root) + + # -- discovery --------------------------------------------------------- + def path(self, rel: str) -> str: + return os.path.join(self.root, rel) + + def looks_valid(self) -> bool: + return os.path.isdir(self.path("ansible")) and os.path.isdir(self.path("scripts")) + + def playbook_path(self, playbook: str) -> str: + return self.path(os.path.join(config.PLAYBOOK_DIR_REL, playbook)) + + def playbook_text(self, playbook: str) -> str: + path = self.playbook_path(playbook) + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + except OSError as exc: + raise Refused( + config.MSG_PLAYBOOK_MISSING.format(path=path), str(exc) + ) from exc + + def verify_scope(self, operation: Operation) -> str: + """Cross-check the catalogue scope against the playbook file. + + The playbook is the source of truth for its own gates: a table that + drifted must refuse to run instead of silently dropping a gate. + """ + text = self.playbook_text(operation.playbook) + actual = playbook_scope(text) + if actual != operation.declared_scope: + raise Refused( + config.MSG_GATE_SCOPE_MISMATCH.format( + playbook=operation.playbook, + declared=operation.declared_scope, + actual=actual, + ) + ) + return actual + + def playbook_uses(self, playbook: str, var: str) -> bool: + return playbook_uses_var(self.playbook_text(playbook), var) + + # -- local assets ------------------------------------------------------ + def installer_path(self) -> str: + path = self.path(config.INSTALLER_REL) + if not os.path.isfile(path): + raise Refused(config.MSG_INSTALLER_MISSING.format(path=path)) + return path + + def tool_path(self, tool: str) -> str: + """Preferred path of a diagnostics/maintenance tool (bf_tool_path).""" + candidates = ( + os.path.join(config.TOOL_BIN_DIR, tool), + self.path(os.path.join(config.DIAGNOSTICS_DIR_REL, tool)), + self.path(os.path.join("scripts/maintenance", tool)), + ) + for candidate in candidates: + if os.access(candidate, os.X_OK) and os.path.isfile(candidate): + return candidate + raise Refused(config.MSG_TOOL_MISSING.format(tool=tool)) + + def group_vars_path(self) -> Optional[str]: + path = self.path(config.GROUP_VARS_REL) + return path if os.path.isfile(path) else None + + # -- inventory --------------------------------------------------------- + def inventory_candidates(self) -> Tuple[str, ...]: + base = self.path(config.INVENTORY_DIR_REL) + return tuple(os.path.join(base, name) for name in config.INVENTORY_CANDIDATES) + + def find_inventory(self, explicit: str = "") -> str: + """Resolve the Ansible inventory, or refuse with remediation. + + ``hosts.example.yml`` is never selected automatically: it carries + example device IDs and must only be used after a deliberate copy. + """ + if explicit: + if os.path.isfile(explicit) and os.access(explicit, os.R_OK): + return os.path.abspath(explicit) + raise Refused(config.MSG_INVENTORY_UNREADABLE.format(path=explicit)) + for candidate in self.inventory_candidates(): + if os.path.isfile(candidate) and os.access(candidate, os.R_OK): + return candidate + example = self.path(config.INVENTORY_EXAMPLE_REL) + detail = "" + if os.path.isfile(example): + detail = config.MSG_INVENTORY_EXAMPLE_ONLY.format(path=example) + raise Refused( + config.MSG_INVENTORY_MISSING.format(dir=self.path(config.INVENTORY_DIR_REL)), + detail, ) -try: # pragma: no cover - ortama bagli - import tkinter as tk - from tkinter import messagebox, ttk +# --------------------------------------------------------------------------- +# Inventory parsing (a deliberately small YAML reader, mirrors targets.sh) +# --------------------------------------------------------------------------- +@dataclass +class Inventory: + """group -> ordered hosts, parsed from the Ansible inventory.""" - HAVE_TKINTER = True - TK_IMPORT_ERROR = "" -except Exception as _tk_exc: # pragma: no cover - ortama bagli - HAVE_TKINTER = False - TK_IMPORT_ERROR = str(_tk_exc) - tk = ttk = messagebox = _MissingTk() # type: ignore[assignment] + path: Optional[str] = None + groups: Dict[str, List[str]] = field(default_factory=dict) + + # -- construction ------------------------------------------------------ + @classmethod + def parse(cls, text: str, path: Optional[str] = None) -> "Inventory": + groups: Dict[str, List[str]] = {} + # Stack of (key, indent) for keys whose value is an empty mapping. + stack: List[Tuple[str, int]] = [] + host_block = False + host_indent = 0 + host_step: Optional[int] = None + host_group = "" + + for raw in text.splitlines(): + line = raw.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-"): + continue + if ":" not in line: + continue + indent = len(line) - len(line.lstrip(" ")) + key_part, _, value_part = line.partition(":") + key = key_part.strip().strip("'\"").strip() + value = value_part.strip() + if not key: + continue + + # Close blocks that are at or above this key's indentation. + while stack and stack[-1][1] >= indent: + stack.pop() + if host_block and indent <= host_indent: + host_block = False + + if key == "hosts" and not value: + host_block = True + host_indent = indent + host_step = None + host_group = stack[-1][0] if stack else "" + stack.append((key, indent)) + continue + + if host_block and indent > host_indent: + if host_step is None: + host_step = indent + if indent == host_step: + groups.setdefault(host_group, []) + if key not in groups[host_group]: + groups[host_group].append(key) + stack.append((key, indent)) + continue + + if not value: + stack.append((key, indent)) + + return cls(path=path, groups=groups) + + @classmethod + def load(cls, path: str) -> "Inventory": + with open(path, "r", encoding="utf-8") as handle: + return cls.parse(handle.read(), path=path) + + @classmethod + def load_repository(cls, repo: Repository, explicit: str = "") -> "Inventory": + """Load the inventory, or return an empty one marked as unresolved. + + Callers that must refuse on a missing inventory ask the repository + directly (:meth:`Repository.find_inventory`); the UI keeps working with + an unresolved inventory so read-only screens can still explain what is + missing instead of crashing. + """ + try: + resolved = repo.find_inventory(explicit) + except Refused: + return cls(path=None, groups={}) + try: + return cls.load(resolved) + except (OSError, ValueError): + return cls(path=None, groups={}) + + # -- queries ----------------------------------------------------------- + @property + def resolved(self) -> bool: + """True when an inventory was actually found and parsed. + + A path is not required: an inventory parsed from text (tests, callers + that hold the YAML themselves) is just as usable as a file on disk. + """ + return self.path is not None or bool(self.groups) + + def labels(self) -> str: + return self.path or "çözümlenmedi" + + def group_names(self) -> Tuple[str, ...]: + return tuple(self.groups) + + def hosts_of(self, group: str) -> Tuple[str, ...]: + return tuple(self.groups.get(group, ())) + + def group_exists(self, group: str) -> bool: + return group in self.groups + + def host_exists(self, host: str) -> bool: + return any(host in hosts for hosts in self.groups.values()) + + def all_hosts(self) -> Tuple[str, ...]: + hosts: List[str] = [] + for group_hosts in self.groups.values(): + for host in group_hosts: + if host not in hosts: + hosts.append(host) + return tuple(hosts) + + def count_all(self) -> int: + return len(self.all_hosts()) + + def waves_present(self) -> Tuple[str, ...]: + """Canonical waves that exist in the inventory, in rollout order.""" + return tuple(wave for wave in config.WAVES if self.group_exists(wave)) + + def host_wave(self, host: str) -> Optional[str]: + """The approved wave containing HOST, or None.""" + for wave in config.WAVES: + if host in self.groups.get(wave, ()): + return wave + return None # --------------------------------------------------------------------------- -# Tasarim belirtecleri -- tek kaynak: bfos/ui_theme.py, o da -# web/static/tokens.css'i birebir yansitir. Bu dosya renk/secim/bosluk -# degeri URETMEZ; hepsi temadan gelir (docs/DESIGN.md "Rules"). +# Target selection (mirrors bf_target_*) # --------------------------------------------------------------------------- -try: - from bfos import ui_theme as theme -except ImportError: # pragma: no cover - dosya paket disinda yuklendiginde - _THEME_DIR = str(Path(__file__).resolve().parent) - if _THEME_DIR not in sys.path: - sys.path.insert(0, _THEME_DIR) - import ui_theme as theme # type: ignore[no-redef] +KIND_HOST = "host" +KIND_GROUP = "group" +KIND_ALL = "all" -# --------------------------------------------------------------------------- -# Durum sozlugu (docs/DESIGN.md "Status vocabulary") -# --------------------------------------------------------------------------- -#: Bir cihaz durumunun TUM sozcukleri. Renk tek basina anlam tasimaz: her -#: rozet sozcugunu da yazar, liste renksiz de okunur. -DEVICE_STATES: tuple[str, ...] = ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK") - -#: Sozcuk -> token anahtari (tema ile ayni esleme). -STATE_KEYS_BY_WORD: Mapping[str, str] = { - word: key for key, word in theme.STATE_WORDS.items() -} - -#: state.json'da bir denetimin dustugunu gosteren isaretler. -FAILURE_MARKERS: tuple[str, ...] = ("FAIL", "HATA", "ERROR", "BOZUK") - -#: Yetki seviyeleri: tam yetki ve salt-okunur GORUNUR birer durumdur. -PRIVILEGE_FULL = "full" -PRIVILEGE_READ_ONLY = "read_only" -PRIVILEGE_WORDS: Mapping[str, str] = { - PRIVILEGE_FULL: "TAM YETKİ", - PRIVILEGE_READ_ONLY: "SALT-OKUNUR", -} +@dataclass(frozen=True) +class Target: + kind: str + limit: str + wave: str + count: int + label: str -def read_state_values(path: str = STATE_JSON_PATH) -> dict[str, str]: - """state.json'u BEYAZ LISTE ile okur (jeton/anahtar alanlari okunmaz). +def _target_host(dealer: str, mode: str, inventory: Inventory) -> Target: + if not dealer_is_valid(dealer): + raise Refused(config.MSG_TARGET_DEALER_INVALID.format(value=dealer)) + if mode == config.MODE_RELEASE: + raise Refused(config.MSG_TARGET_RELEASE_DEVICE) + if not inventory.resolved: + raise Refused(config.MSG_TARGET_DEVICE_UNKNOWN.format( + host=dealer_to_host(dealer), inventory=inventory.labels())) + host = dealer_to_host(dealer) + if not inventory.host_exists(host): + raise Refused(config.MSG_TARGET_DEVICE_UNKNOWN.format(host=host, inventory=inventory.labels())) + wave = inventory.host_wave(host) or "" + if not wave and mode != config.MODE_ANY: + raise Refused(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=host)) + label = "cihaz {0} ({1})".format(dealer_to_device_id(dealer), host) + if wave: + label += " — dalga '{0}'".format(wave) + return Target(kind=KIND_HOST, limit=host, wave=wave, count=1, label=label) - Okunamayan/yok dosya bos sozluk doner: cihaz kurulmamis olabilir ve bu bir - hata degil, ``YOK`` durumudur. + +def _target_group(group: str, mode: str, inventory: Inventory) -> Target: + if not inventory.resolved or not inventory.group_exists(group): + raise Refused( + config.MSG_TARGET_GROUP_UNKNOWN.format( + group=group, groups=" ".join(inventory.group_names()) or "-" + ) + ) + if mode in (config.MODE_WAVE, config.MODE_RELEASE) and not is_wave(group): + raise Refused(config.MSG_TARGET_GROUP_NOT_WAVE.format(group=group, waves=waves_readable())) + count = len(inventory.hosts_of(group)) + if count == 0: + raise Refused(config.MSG_TARGET_GROUP_EMPTY.format(group=group)) + label = "dalga '{0}' ({1} cihaz)".format(group, count) if is_wave(group) else \ + "grup '{0}' ({1} cihaz)".format(group, count) + wave = group if is_wave(group) else "" + return Target(kind=KIND_GROUP, limit=group, wave=wave, count=count, label=label) + + +def _target_all(mode: str, inventory: Inventory) -> Target: + if mode == config.MODE_RELEASE: + raise Refused(config.MSG_TARGET_RELEASE_ALL) + if not inventory.resolved or inventory.count_all() == 0: + raise Refused(config.MSG_TARGET_ALL_EMPTY) + count = inventory.count_all() + return Target(kind=KIND_ALL, limit="all", wave="", count=count, + label="filo geneli ({0} cihaz)".format(count)) + + +def resolve_target(word: str, mode: str, inventory: Inventory) -> Target: + """Turn one operator word into a validated target. + + Accepts an 8-digit dealer number, a ``bf-`` hostname (convenience), + a group/wave name, or ``all``. Anything else is refused with a Turkish + explanation; nothing is guessed. """ + word = (word or "").strip() + if not word: + raise Refused(config.MSG_TARGET_EMPTY) + if word == "all": + return _target_all(mode, inventory) + if dealer_is_valid(word): + return _target_host(word, mode, inventory) + if inventory.resolved and inventory.group_exists(word): + return _target_group(word, mode, inventory) + if HOST_RE.match(word) and dealer_is_valid(word[3:]): + return _target_host(word[3:], mode, inventory) + raise Refused( + config.MSG_TARGET_UNKNOWN.format(value=word), + config.MSG_TARGET_KNOWN_GROUPS.format(groups=" ".join(inventory.group_names()) or "-"), + ) + + +def wave_sequence(inventory: Inventory) -> Tuple[Tuple[str, int], ...]: + """Waves present in the inventory with their device counts, in order.""" + return tuple((wave, len(inventory.hosts_of(wave))) for wave in inventory.waves_present()) + + +# --------------------------------------------------------------------------- +# Gates: the confirmation plan (what the operator must state explicitly) +# --------------------------------------------------------------------------- +STEP_TOKEN = "token" +STEP_APPROVAL = "approval" + + +@dataclass(frozen=True) +class GateStep: + """One explicit confirmation the operator has to give before a run.""" + + kind: str + key: str + prompt: str + message: str = "" + token: str = "" + unlocks: Tuple[str, ...] = () + + +@dataclass +class GatesGranted: + """Flags granted by the operator's confirmations. + + ``build_ansible_command`` adds a gate variable only when the matching flag + is granted AND the playbook declares that variable, so a command can never + become less gated than the playbook expects. + """ + + wave_gate_confirmed: bool = False + production_override: bool = False + allow_production_reboot: bool = False + fleet_wide: bool = False + + def grant(self, step: GateStep) -> "GatesGranted": + changes = {name: True for name in step.unlocks if hasattr(self, name)} + return replace(self, **changes) if changes else self + + +def confirmation_plan( + operation: Operation, + scope: str, + target: Target, + wave: str = "", + wave_gate_supported: bool = False, +) -> List[GateStep]: + """The ordered list of confirmations required for one operation run. + + Rules (all preserved from the bash console): + * the production wave requires the word ``production`` **and** a separate + production approval that unlocks ``production_override=true``, + * a release-gated operation (reboot, deploy-update) requires its wave name + to be written out before anything is built; a wave-scoped read-only + report does not (the bash console asks no token for it either), + * a destructive operation requires a second, explicit approval, + * a fleet-wide simple run requires the word ``all``. + """ + steps: List[GateStep] = [] + effective_wave = wave or target.wave + + if scope in (config.SCOPE_WAVE, config.SCOPE_RELEASE): + if effective_wave == config.PRODUCTION_WAVE: + message = config.MSG_GATE_PRODUCTION + if wave_gate_supported: + message = message + "\n" + config.MSG_GATE_GATE_FLAG + steps.append( + GateStep( + kind=STEP_TOKEN, + key="production_token", + prompt=config.MSG_GATE_PRODUCTION_ASK, + message=message, + token=config.PRODUCTION_WAVE, + unlocks=(VAR_WAVE_GATE_CONFIRMED,) if wave_gate_supported else (), + ) + ) + steps.append( + GateStep( + kind=STEP_APPROVAL, + key="production_approval", + prompt=config.MSG_GATE_PRODUCTION_APPROVAL, + message=config.MSG_GATE_PRODUCTION, + unlocks=(VAR_PRODUCTION_OVERRIDE, VAR_ALLOW_PRODUCTION_REBOOT), + ) + ) + elif effective_wave and scope == config.SCOPE_RELEASE: + # Release-gated operations never run from a typed word alone: the + # operator has to name the exact wave that is being released. + message = config.MSG_GATE_WAVE_TOKEN.format(wave=effective_wave) + if wave_gate_supported: + message = message + "\n" + config.MSG_GATE_GATE_FLAG + steps.append( + GateStep( + kind=STEP_TOKEN, + key="wave_token", + prompt=config.MSG_GATE_WAVE_TOKEN_ASK.format(wave=effective_wave), + message=message, + token=effective_wave, + unlocks=(VAR_WAVE_GATE_CONFIRMED,) if wave_gate_supported else (), + ) + ) + + if target.kind == KIND_ALL and scope == config.SCOPE_SIMPLE: + steps.append( + GateStep( + kind=STEP_TOKEN, + key="fleet_token", + prompt=config.MSG_GATE_FLEET_TOKEN_ASK, + message=config.MSG_GATE_FLEET_TOKEN.format(count=target.count), + token="all", + unlocks=("fleet_wide",), + ) + ) + + if operation.destructive and not any(step.key == "production_approval" for step in steps): + # The second, explicit approval for a state-changing operation. It is a + # gate (it happens before the command is even built); the run prompt + # after the command has been shown is a separate, final confirmation. + steps.append( + GateStep( + kind=STEP_APPROVAL, + key="destructive_approval", + prompt=config.MSG_DESTRUCTIVE_SUB, + message=config.MSG_DESTRUCTIVE_GATE.format(label=operation.label), + ) + ) + return steps + + +# --------------------------------------------------------------------------- +# Extra -e variables, validated with the rules the playbooks enforce +# --------------------------------------------------------------------------- +PIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$") + + +def parse_packages(text: str) -> List[str]: + pins: List[str] = [] + for chunk in (text or "").split(","): + pin = chunk.strip() + if not pin: + continue + if not PIN_RE.match(pin): + raise Refused(config.MSG_PKG_INVALID.format(value=pin)) + version = pin.partition("=")[2].strip("=").strip() + if not version or version.lower().startswith("latest"): + raise Refused(config.MSG_PKG_LATEST.format(value=pin)) + pins.append(pin) + if not pins: + raise Refused(config.MSG_PKG_REQUIRED) + return pins + + +def packages_expression(pins: Sequence[str]) -> str: + """The ``update_packages`` value: ``["pkg=ver",...]`` like bf_prompt_packages. + + Only the value is returned; the caller pairs it with the variable name, so + exactly one ``update_packages=`` prefix ever reaches the command line. + """ + return "[{0}]".format(",".join('"{0}"'.format(pin) for pin in pins)) + + +def parse_service(text: str) -> str: + name = (text or "").strip() + if name not in config.SERVICE_ALLOWLIST: + raise Refused( + config.MSG_SERVICE_INVALID.format(value=name), + config.MSG_SERVICE_HINT.format(allowlist=" ".join(config.SERVICE_ALLOWLIST)), + ) + return name + + +def parse_script(path: str, args: str = "") -> List[Tuple[str, str]]: + script = (path or "").strip() + if not script.startswith("/"): + raise Refused(config.MSG_SCRIPT_ABSOLUTE) + if not any(script.startswith(directory + "/") for directory in config.SCRIPT_DIRS): + raise Refused( + config.MSG_SCRIPT_OUTSIDE.format(value=script, dirs=" ".join(config.SCRIPT_DIRS)) + ) + pairs = [("script_path", script)] + if (args or "").strip(): + pairs.append(("script_args", args.strip())) + return pairs + + +def parse_grace(text: str) -> str: + value = (text or "").strip() or str(config.DEFAULT_REBOOT_GRACE) + if not value.isdigit(): + raise Refused(config.MSG_GRACE_INVALID) + return value + + +def parse_disk_pct(text: str) -> List[Tuple[str, str]]: + value = (text or "").strip() + if not value: + return [] + if not value.isdigit() or not (config.DISK_WARN_MIN <= int(value) <= config.DISK_WARN_MAX): + raise Refused(config.MSG_PCT_INVALID.format(min=config.DISK_WARN_MIN, max=config.DISK_WARN_MAX)) + return [("disk_warn_pct", value)] + + +def parse_log_lines(text: str) -> str: + value = (text or "").strip() or str(config.DEFAULT_LOG_LINES) + if not value.isdigit() or int(value) < 1: + raise Refused(config.MSG_LINES_INVALID) + return value + + +def parse_expected_version(text: str) -> List[Tuple[str, str]]: + value = (text or "").strip() + return [("fieldos_expected_version", value)] if value else [] + + +def build_extra_vars(prompt: str, answers: Dict[str, str]) -> List[Tuple[str, str]]: + """Validate the operator's answers for one operation's prompt kind.""" + if prompt == PROMPT_NONE: + return [] + if prompt == PROMPT_PACKAGES: + return [("update_packages", packages_expression(parse_packages(answers.get("packages", ""))))] + if prompt == PROMPT_SERVICE: + return [("service_name", parse_service(answers.get("service", "")))] + if prompt == PROMPT_SCRIPT: + return parse_script(answers.get("script", ""), answers.get("script_args", "")) + if prompt == PROMPT_GRACE: + return [("reboot_grace_seconds", parse_grace(answers.get("grace", "")))] + if prompt == PROMPT_DISK_PCT: + return parse_disk_pct(answers.get("disk_pct", "")) + if prompt == PROMPT_LOG_LINES: + return [("log_lines", parse_log_lines(answers.get("log_lines", "")))] + if prompt == PROMPT_VERSION: + return parse_expected_version(answers.get("expected_version", "")) + raise Refused("Konsol hatası: bilinmeyen soru türü '{0}'.".format(prompt)) + + +# --------------------------------------------------------------------------- +# Command construction +# --------------------------------------------------------------------------- +def build_ansible_command( + repo: Repository, + inventory_path: str, + operation: Operation, + scope: str, + target: Optional[Target], + wave: str = "", + extra_vars: Sequence[Tuple[str, str]] = (), + gates: Optional[GatesGranted] = None, + check_mode: bool = False, +) -> List[str]: + """Build the ansible-playbook argv for one operation and one target. + + Single choke point: no command is ever built without a target, and a gated + scope is never built without a wave. + """ + gates = gates or GatesGranted() + if target is None: + raise Refused(config.MSG_GATE_NO_TARGET) + playbook = operation.playbook + + def uses(var: str) -> bool: + return repo.playbook_uses(playbook, var) + + argv = [ + "ansible-playbook", + "-i", inventory_path, + repo.playbook_path(playbook), + "--limit", target.limit, + ] + effective_wave = wave or target.wave + + if scope == config.SCOPE_WAVE: + if not effective_wave: + raise Refused(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=target.limit)) + argv += ["-e", "{0}={1}".format(VAR_OPERATION_WAVE, effective_wave), + "-e", "{0}=true".format(VAR_OPERATION_CONFIRMED)] + if effective_wave == config.PRODUCTION_WAVE and gates.production_override \ + and uses(VAR_PRODUCTION_OVERRIDE): + argv += ["-e", "{0}=true".format(VAR_PRODUCTION_OVERRIDE)] + elif scope == config.SCOPE_RELEASE: + if not effective_wave or not is_wave(effective_wave): + raise Refused(config.MSG_TARGET_RELEASE_DEVICE) + argv += ["-e", "{0}={1}".format(VAR_RELEASE_WAVE, effective_wave)] + if gates.wave_gate_confirmed and uses(VAR_WAVE_GATE_CONFIRMED): + argv += ["-e", "{0}=true".format(VAR_WAVE_GATE_CONFIRMED)] + if uses(VAR_REBOOT_CONFIRMED): + argv += ["-e", "{0}=true".format(VAR_REBOOT_CONFIRMED)] + if effective_wave == config.PRODUCTION_WAVE: + if gates.production_override and uses(VAR_PRODUCTION_OVERRIDE): + argv += ["-e", "{0}=true".format(VAR_PRODUCTION_OVERRIDE)] + if gates.allow_production_reboot and uses(VAR_ALLOW_PRODUCTION_REBOOT): + argv += ["-e", "{0}=true".format(VAR_ALLOW_PRODUCTION_REBOOT)] + + for name, value in extra_vars: + argv += ["-e", "{0}={1}".format(name, value)] + if check_mode: + argv.append("--check") + return argv + + +def build_install_command( + installer: str, + dealer: str, + offline: bool = False, + resume: bool = False, + only: str = "", + check_mode: bool = False, +) -> List[str]: + """The local installer argv (bash --dealer-id ...).""" + if not dealer_is_valid(dealer): + raise Refused(config.MSG_TARGET_DEALER_INVALID.format(value=dealer)) + argv = ["bash", installer, "--dealer-id", dealer] + if offline: + argv.append("--offline") + if resume: + argv.append("--resume") + if only: + argv += ["--only", only] + if check_mode: + argv.append("--check") + return argv + + +def build_tool_command(tool: str, args: Sequence[str] = (), check_mode: bool = False) -> List[str]: + argv = [tool] + list(args) + if check_mode: + argv.append("--check") + return argv + + +def module_names(repo: Repository) -> Tuple[str, ...]: + """Installer modules (``NN-name.sh``) offered by the single-module action.""" + modules_dir = repo.path(os.path.join("scripts/install/installer/modules")) + try: + names = sorted( + entry for entry in os.listdir(modules_dir) + if re.match(r"^[0-9]{2}-.*\.sh$", entry) + ) + except OSError: + return () + return tuple(name[:-3] for name in names) + + +# --------------------------------------------------------------------------- +# Read-only information reports (returned as text lines for the UI) +# --------------------------------------------------------------------------- +def targets_lines(inventory: Inventory) -> List[str]: + lines: List[str] = [] + lines.append("DALGALAR (yayılma sırası)") + for wave in config.WAVES: + if inventory.group_exists(wave): + lines.append(" {0:<14} {1} cihaz".format(wave, len(inventory.hosts_of(wave)))) + else: + lines.append(" {0:<14} envanterde yok".format(wave)) + others = [group for group in inventory.group_names() if not is_wave(group)] + if others: + lines.append("") + lines.append("DİĞER GRUPLAR") + for group in others: + lines.append(" {0:<14} {1} cihaz".format(group, len(inventory.hosts_of(group)))) + lines.append("") + lines.append("CİHAZLAR") + for host in inventory.all_hosts(): + lines.append(" {0}".format(host)) + lines.append("") + lines.append("Envanter: {0}".format(inventory.labels())) + lines.append("Toplam cihaz: {0}".format(inventory.count_all())) + return lines + + +def wave_gate_state_lines(inventory: Inventory) -> List[str]: + lines = ["DALGA GEÇİŞ KAPISI DURUMU"] + for wave in config.WAVES: + if inventory.group_exists(wave): + lines.append(" {0:<14} {1} cihaz".format(wave, len(inventory.hosts_of(wave)))) + else: + lines.append(" {0:<14} envanterde yok".format(wave)) + lines.append("") + lines.append("Kural: bir dalga, ancak önceki dalga sağlık kapısını ve merkez onayını geçtikten sonra başlar.") + lines.append("Üretim (production) ayrıca yazılı 'production' onayı ve production_override=true gerektirir.") + lines.append("Kaynak: docs/10 — güncelleme ve geri alma politikası.") + return lines + + +def central_checklist_lines() -> List[str]: + return [ + "MERKEZ HAZIRLIK LİSTESİ (bilgilendirme)", + " 1. WireGuard hub: uç nokta, anahtarlar ve her bf- için eş tahsisi (docs/08).", + " 2. Kayıt (enrollment) jeton servisi: tek kullanımlık, TTL sınırlı (docs/29).", + " 3. İzleme: Prometheus/VictoriaMetrics + Grafana, device_id etiketi BF- (docs/14).", + " 4. Uzak erişim: RustDesk hbbs/hbbr veya MeshCentral, WireGuard üzerinden (docs/07).", + " 5. Paket deposu + çevrimdışı anlık görüntü (docs/10, docs/27).", + " 6. Gerçek ana makine adlarıyla Ansible envanteri ve yönetici SSH anahtarı (docs/02, 06, 09).", + "Bu konsol 6'yı doğrular, 1-5'i hazırlar; merkez bileşeni kurmaz.", + ] + + +def _group_vars_value(path: Optional[str], key: str) -> str: + if not path or not os.path.isfile(path): + return "" + pattern = re.compile(r"^[ \t]*{0}[ \t]*:(.*)$".format(re.escape(key))) + try: + with open(path, "r", encoding="utf-8") as handle: + for line in handle: + match = pattern.match(line.rstrip("\n")) + if match: + value = match.group(1) + value = value.split("#", 1)[0].strip() + return value.strip("'\"") + except OSError: + return "" + return "" + + +def central_endpoint_lines(repo: Repository) -> List[str]: + path = repo.group_vars_path() + lines = ["MERKEZ UÇ NOKTALARI (group_vars/all.yml)"] + if path is None: + lines.append(" group_vars/all.yml bulunamadı: {0}".format(repo.path(config.GROUP_VARS_REL))) + return lines + for key in config.CENTRAL_ENDPOINT_KEYS: + value = _group_vars_value(path, key) + if value: + lines.append(" {0:<22} {1}".format(key, value)) + lines.append("") + lines.append("Değerler group_vars/all.yml dosyasından okunur; burada hiçbir sır saklanmaz veya yazılmaz.") + return lines + + +def central_readiness_lines(repo: Repository, inventory: Inventory, inventory_error: str = "") -> List[str]: + lines = ["MERKEZ HAZIRLIK DURUMU"] + if inventory.resolved: + lines.append(" [OK] Envanter: {0} ({1} cihaz)".format(inventory.path, inventory.count_all())) + else: + lines.append(" [UYARI] Envanter çözümlenemedi.") + if inventory_error: + lines.extend(" " + part for part in inventory_error.splitlines()) + path = repo.group_vars_path() + if path: + try: + with open(path, "r", encoding="utf-8") as handle: + placeholders = [line.strip() for line in handle if "PLACEHOLDER" in line] + except OSError: + placeholders = [] + if placeholders: + lines.append(" [UYARI] group_vars/all.yml içinde {0} PLACEHOLDER değeri var:".format(len(placeholders))) + lines.extend(" " + line for line in placeholders) + else: + lines.append(" [OK] group_vars/all.yml içinde PLACEHOLDER kalmamış.") + else: + lines.append(" [UYARI] ansible/inventory/group_vars/all.yml bulunamadı.") + key_path = os.path.expanduser(config.ADMIN_KEY_FILE) + if os.path.isfile(key_path): + lines.append(" [OK] Yönetici SSH anahtarı var: {0} (içeriği okunmaz/yazılmaz)".format(config.ADMIN_KEY_FILE)) + else: + lines.append(" [UYARI] Yönetici SSH anahtarı yok: {0} (docs/06)".format(config.ADMIN_KEY_FILE)) + if inventory.path and "example" in os.path.basename(inventory.path): + lines.append(" [UYARI] Bu örnek bir envanter; gerçek cihazlara yöneltmeyin.") + return lines + + +def split_endpoint(endpoint: str) -> Tuple[str, Optional[int]]: + """Split a configured endpoint into (host, port) — no network access.""" + value = (endpoint or "").strip() + if not value: + return "", None + if "//" in value: + value = value.split("//", 1)[1] + value = value.split("/", 1)[0] + if value.startswith("["): # IPv6 literal + host, _, rest = value.partition("]") + host = host.lstrip("[") + port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else None + return host, port + if ":" in value: + host, _, port_text = value.rpartition(":") + if port_text.isdigit(): + return host, int(port_text) + return value, None + if endpoint.startswith("http://"): + return value, 80 + if endpoint.startswith("https://"): + return value, 443 + return value, None + + +def probe_endpoint(host: str, port: Optional[int], timeout: float = 3.0) -> Tuple[str, bool]: + """Resolve HOST and, when PORT is known, try one TCP connection.""" + if not host: + return "", False + try: + infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) + except OSError: + return "", False + if not infos: + return "", False + resolved = str(infos[0][4][0]) + if port is None: + return resolved, True + try: + with socket.create_connection((resolved, port), timeout=timeout): + return resolved, True + except OSError: + return resolved, False + + +def central_probe_lines(repo: Repository, timeout: float = 3.0) -> List[str]: + path = repo.group_vars_path() + lines = ["MERKEZ UÇ NOKTASI DENEMESİ (salt okunur)"] + if path is None: + lines.append(" group_vars/all.yml bulunamadı.") + return lines + for key in ("wg_hub_endpoint", "monitoring_endpoint", "update_repo_url"): + endpoint = _group_vars_value(path, key) + if not endpoint: + continue + host, port = split_endpoint(endpoint) + if not host: + continue + resolved, reachable = probe_endpoint(host, port, timeout=timeout) + if not resolved: + lines.append(" [UYARI] {0}: çözümlenemedi (yer tutucu olabilir)".format(host)) + elif port is None: + lines.append(" [OK] {0} -> {1} (port türetilemedi)".format(host, resolved)) + elif reachable: + lines.append(" [OK] {0}:{1} erişilebilir ({2})".format(host, port, resolved)) + else: + lines.append(" [UYARI] {0}:{1} erişilemiyor ({2})".format(host, port, resolved)) + return lines + + +def state_values(path: str = config.STATE_FILE) -> Dict[str, str]: + """Read state.json through a whitelist of non-secret keys. + + A future token field can never leak into the UI or the audit log. + """ + values: Dict[str, str] = {} try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) except (OSError, ValueError): - return {} + return values if not isinstance(data, dict): - return {} - values: dict[str, str] = {} - for key in STATE_ALLOWED_KEYS: + return values + for key in config.STATE_ALLOWED_KEYS: value = data.get(key) if value is None or isinstance(value, bool): continue @@ -68115,3402 +56995,1405 @@ def read_state_values(path: str = STATE_JSON_PATH) -> dict[str, str]: return values -def state_word_from_values(values: Mapping[str, str]) -> str: - """state.json alanlarini sozlukteki TEK sozcuge indirger. - - Sira onemli: once HATA (bir denetim dustu), sonra HAZIR (her kanal - dogrulandi), sonra KAYITLI (kaydedildi, kanallar eksik), sonra KURULDU - (kuruldu, henuz kaydedilmedi). Alan yoksa YOK: hic gorulmemis cihaz. - Acik olumsuzlama ("NOT_ENROLLED") kayit SAYILMAZ: o cihaz kaydedilmemistir. - """ - if not values: - return "YOK" - phase = str(values.get("phase", "")).upper() - fleet = str(values.get("fleet_status", "")).upper() - enrollment = str(values.get("enrollment_status", "")).upper() - if any(marker in field for marker in FAILURE_MARKERS - for field in (phase, fleet, enrollment)): - return "HATA" - if any("READY" in field for field in (phase, fleet, enrollment)): - return "HAZIR" - if any(field.endswith("ENROLLED") and not field.startswith(("NOT_", "UN")) - for field in (phase, fleet, enrollment)): - return "KAYITLI" - if phase or fleet or enrollment: - return "KURULDU" - return "YOK" - - -def device_state_word(path: str = STATE_JSON_PATH) -> str: - """Bu cihazin yerel kurulum/kayit durumu (sozluk sozcugu).""" - return state_word_from_values(read_state_values(path)) - - -def state_badge_style(word: str) -> str: - """Sozcukten rozet stilini cozer; bilinmeyen sozcuk notr rozete duser.""" - key = STATE_KEYS_BY_WORD.get(word) - if key is None: - return theme.STATE_BADGE_STYLE["absent"] - return theme.STATE_BADGE_STYLE[key] - - -def privilege_level(root: bool | None = None) -> str: - """'full' ya da 'read_only'; yetki bir durumdur, satir degil.""" - granted = is_root() if root is None else bool(root) - return PRIVILEGE_FULL if granted else PRIVILEGE_READ_ONLY - - -def privilege_word(level: str) -> str: - """Yetki seviyesinin ekranda gorunen sozcugu.""" - return PRIVILEGE_WORDS.get(level, PRIVILEGE_WORDS[PRIVILEGE_READ_ONLY]) - - -def write_blocked_reason(operation) -> str | None: - """Yazma islemi kapaliysa NEDENINI doner; islem calisabilirse None. - - Yazma islemleri yetkisiz oturumda GIZLENMEZ: listede kalir, dugu kapali - cizilir ve nedenin metni dugmenin kendi uzerinde yazar - (docs/DESIGN.md "Privilege"). - """ - if not operation.needs_root: - return None - return None if write_operations_enabled() else NON_ROOT_DISABLED_HINT - - -def operation_is_write(operation) -> bool: - """True when the operation changes the device (catalogue rule: root).""" - return bool(operation.needs_root) - - -# --------------------------------------------------------------------------- -# Veri tipleri (backend sozlesmesi) -# --------------------------------------------------------------------------- -@dataclass(frozen=True) -class OptionSpec: - """Bir islemin ek parametresi (bf-menu'deki -e degiskenleri).""" - - name: str - label: str - kind: str = "text" # text | int | bool | choice - default: str = "" - choices: tuple[str, ...] = () - hint: str = "" - required: bool = False - validator: Callable[[str], "str | None"] | None = None - - -@dataclass(frozen=True) -class Operation: - """Operasyon katalogu kaydi (sozlesme: bkz. dosya basligi).""" - - id: str - label: str - category: str - destructive: bool = False - scope: str = "local" # local | simple | wave | release - description: str = "" - playbook: str = "" - options: tuple[OptionSpec, ...] = () - # None means "not stated": the value is then derived from `destructive` by - # the property below, so a write operation cannot be marked read-only by - # omission. An operation that genuinely differs states the value. - requires_root: bool | None = None - needs_dealer: bool = False - - @property - def needs_root(self) -> bool: - """Whether this operation must run as root to be allowed.""" - if self.requires_root is None: - return bool(self.destructive) - return bool(self.requires_root) - - @property - def is_fleet(self) -> bool: - return self.scope in ("simple", "wave", "release") - - -@dataclass(frozen=True) -class Target: - """Hedef secimi: bayı no, dalga/grup adi ya da tum filo.""" - - kind: str # dealer | group | all - value: str - - @property - def limit(self) -> str: - if self.kind == "dealer": - return dealer_to_host(self.value) - return self.value - - @property - def label(self) -> str: - if self.kind == "dealer": - return f"cihaz {dealer_to_device_id(self.value)} ({dealer_to_host(self.value)})" - if self.kind == "all": - return "tüm filo (envanterdeki her cihaz)" - if is_wave(self.value): - return f"dalga '{self.value}'" - return f"grup '{self.value}'" - - -@dataclass(frozen=True) -class GateStepInfo: - """Calistirmadan once gereken ACIK operator onayi (sozlesme eki). - - kind : "token" (yazilarak) | "approval" (evet/hayir) - token: kind == "token" ise yazilmasi gereken sozcuk - unlocks: onay verilince backend'e gecirilecek bayrak adlari - (or. "wave_gate_confirmed", "production_override") - """ - - kind: str - key: str - prompt: str - message: str = "" - token: str = "" - unlocks: tuple[str, ...] = () - - -@dataclass(frozen=True) -class CommandPlan: - """Calistirilacak komutlar + ekranda gosterilecek satir listesi. - - `report_lines` dolu ve `commands` bos oldugunda plan HICBIR SEY calistirmaz: - rapor satirlari (bfos.operations icindeki salt-okunur ekranlar) dogrudan - gosterilir. - """ - - commands: tuple[tuple[str, ...], ...] = () - destructive: bool = False - requires_root: bool | None = None - operation_id: str = "" - target_label: str = "" - notes: tuple[str, ...] = () - report_lines: tuple[str, ...] = () - dry_run: bool = False - check_mode: bool = False - - @property - def needs_root(self) -> bool: - """Whether running this plan requires root (derived when not stated).""" - if self.requires_root is None: - return bool(self.destructive) - return bool(self.requires_root) - - @property - def lines(self) -> tuple[str, ...]: - """Kopyala/yapistir edilebilir komut satirlari (bf-menu %q ile ayni fikir).""" - return tuple(shlex.join(argv) for argv in self.commands if argv) - - @property - def is_empty(self) -> bool: - return not any(self.commands) and not self.report_lines - - @property - def is_report_only(self) -> bool: - return not any(self.commands) and bool(self.report_lines) - - -@dataclass -class RunResult: - """Komut calistirma sonucu: cikti + cikis kodu (sozlesme maddesi 4).""" - - exit_code: int - ok: bool - output: str = "" - - -class BackendUnavailable(RuntimeError): - """Gercek backend modulu var ama sozlesmeye uymuyor.""" - - -class CommandError(RuntimeError): - """Komut uretilemedi / secenek gecersiz (TURKCE mesaj tasir).""" - - -def _as_int(value: Any, default: int = 1) -> int: - """Farkli backend'lerin cikis kodu tiplerini (int/str/None) guvenle cevirir.""" - try: - return int(value) - except (TypeError, ValueError): - return default - - -# --------------------------------------------------------------------------- -# Saf yardimcilar: birim testleri bunlari pencere ACMADAN kullanir -# --------------------------------------------------------------------------- -def validate_dealer(value: str) -> bool: - """Bayi no kapisi: ^[0-9]{8}$ (docs/02, K-12).""" - return bool(DEALER_PATTERN.match(value or "")) - - -def dealer_error(value: str) -> str | None: - """Gecerli ise None, degilse Turkce hata metni.""" - if validate_dealer(value): - return None - if not value: - return "Bayi numarası boş olamaz. Tam 8 hane, yalnız rakam girin (örn. 12010193)." - if not value.isdigit(): - return "Bayi numarası yalnız rakam içermelidir (harf, boşluk ve işaret kullanılamaz)." - return ( - f"Geçersiz bayi numarası '{value}': tam 8 hane olmalıdır " - f"(verilen uzunluk: {len(value)}). Örnek: 12010193" - ) - - -def dealer_to_host(dealer: str) -> str: - return f"bf-{dealer}" - - -def dealer_to_device_id(dealer: str) -> str: - return f"BF-{dealer}" - - -def host_to_dealer(host: str) -> str | None: - return host[3:] if HOST_PATTERN.match(host or "") else None - - -def is_wave(name: str) -> bool: - return name in WAVES - - -def validate_wave(value: str) -> str | None: - """Dalga adini dogrular; gecerliyse None doner.""" - if value in WAVES: - return None - return ( - f"Geçersiz dalga '{value}'. Onaylı dalgalar (sırayla): {', '.join(WAVES)}." - ) - - -def validate_disk_pct(value: str) -> str | None: - if value == "": - return None - if not value.isdigit() or not (1 <= int(value) <= 100): - return "Eşik 1 ile 100 arasında bir tam sayı olmalıdır." - return None - - -def validate_grace(value: str) -> str | None: - if value == "": - return None - if not value.isdigit(): - return "Bekleme süresi saniye cinsinden bir tam sayı olmalıdır (örn. 300)." - return None - - -def validate_log_lines(value: str) -> str | None: - if value == "": - return None - if not value.isdigit() or int(value) < 1: - return "Satır sayısı pozitif bir tam sayı olmalıdır (örn. 500)." - return None - - -def validate_service_name(value: str) -> str | None: - if value in SERVICE_ALLOWLIST: - return None - return ( - f"'{value}' izinli servis listesinde değil. İzinli olanlar: " - f"{', '.join(SERVICE_ALLOWLIST)}." - ) - - -def validate_script_path(value: str) -> str | None: - if not value: - return "Script yolu boş olamaz." - if not value.startswith("/"): - return "Script yolu mutlak olmalıdır (örn. /opt/blueforce/bin/kontrol.sh)." - if not any(value == d or value.startswith(d + "/") for d in SCRIPT_DIRS): - return ( - "Script yolu izinli dizinlerin dışında. İzinli dizinler: " - f"{', '.join(SCRIPT_DIRS)}." - ) - return None - - -def validate_package_pins(value: str) -> str | None: - if not value.strip(): - return "En az bir paket sürümü gerekir (biçim: paket=sürüm)." - for pin in value.split(","): - pin = pin.strip() - if not pin: - continue - if not PACKAGE_PIN_PATTERN.match(pin): - return ( - f"Geçersiz pin '{pin}': 'paket=sürüm' biçiminde olmalıdır " - "(örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)." - ) - return None - - -def validate_module_name(value: str) -> str | None: - if not value: - return "Modül numarası boş olamaz (örn. 01-precheck)." - if not MODULE_PATTERN.match(value): - return ( - f"Geçersiz modül '{value}': '01-precheck' biçiminde olmalıdır " - "(iki hane, tire, küçük harfli ad)." - ) - return None - - -def validate_expected_version(value: str) -> str | None: - if value == "": - return None - if any(ch.isspace() for ch in value): - return "Sürüm boşluk içermemelidir (örn. 1.0.0)." - return None - - -# --------------------------------------------------------------------------- -# Headless / tkinter hatalari -- net TURKCE yonlendirme -# --------------------------------------------------------------------------- -def headless_error(env: Mapping[str, str] | None = None) -> str | None: - """Ekran yoksa Turkce aciklama doner; ekran varsa None. - - Sadece DISPLAY / WAYLAND_DISPLAY ortam degiskenlerine bakar, boylece - pencere acmadan test edilebilir. - """ - data = os.environ if env is None else env - if data.get("DISPLAY") or data.get("WAYLAND_DISPLAY"): - return None - return ( - "Grafik ekran bulunamadı (DISPLAY ve WAYLAND_DISPLAY tanımlı değil).\n" - "\n" - "SSH veya başsız (headless) bir oturumda masaüstü penceresi açılamaz.\n" - "Bu durumda terminal menüsünü kullanın:\n" - f"\n {TERMINAL_MENU_HINT}\n" - "\n" - "Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin:\n" - " python3 -m bfos.ui_gui\n" - ) - - -def tkinter_missing_message(error: str = "") -> str: - """tkinter kurulu degilse Turkce hata + kurulum ipucu.""" - detail = f" (teknik ayrıntı: {error})" if error else "" - return ( - "tkinter bulunamadı; masaüstü arayüzü başlatılamıyor" - f"{detail}.\n" - "\n" - "Bu, minimal kurulumlarda sık görülür: tkinter ayrı bir sistem paketidir.\n" - "Ubuntu/Debian üzerinde kurmak için:\n" - f"\n {TK_PACKAGE_HINT}\n" - " sudo apt update && sudo apt install -y python3-tk\n" - "\n" - f"tkinter kurmak istemiyorsanız terminal menüsünü kullanın: {TERMINAL_MENU_HINT}\n" - ) - - -def window_start_error(exc: BaseException) -> str: - """Tk() acilamazsa (DISPLAY var ama X/Wayland erisilemiyor) Turkce mesaj.""" - return ( - "Pencere açılamadı; grafik oturumuna erişilemiyor.\n" - f"Teknik ayrıntı: {exc}\n" - "\n" - "Muhtemel nedenler:\n" - " * SSH oturumundasınız ve X yönlendirmesi (ssh -X) yok,\n" - " * ekran kilitli veya grafik oturum kapalı,\n" - " * başka bir kullanıcının X sunucusuna bağlanmaya çalışıyorsunuz.\n" - f"\nTerminal menüsünü kullanın: {TERMINAL_MENU_HINT}\n" - ) - - -# --------------------------------------------------------------------------- -# Onizleme metni (onay pencereleri ve kayit icin tek kaynak) -# --------------------------------------------------------------------------- -def preview_text( - plan: CommandPlan, - *, - operation: Operation | None = None, - target: Target | None = None, -) -> str: - """Calistirilacak komutu gosteren Turkce onay metni.""" - parts: list[str] = [] - if plan.is_report_only: - parts.append("Bu ekran komut çalıştırmaz; salt-okunur rapor gösterir:") - parts.append("") - for line in plan.report_lines: - parts.append(f" {line}") - parts.append("") +def identity_lines(hostname: str = "") -> List[str]: + host = hostname or platform.node() or "bilinmiyor" + dealer = host_to_dealer(host) + lines = ["KİMLİK", " Ana makine adı : {0}".format(host)] + if dealer: + lines.append(" Bayi numarası : {0}".format(dealer)) + lines.append(" Cihaz kimliği (insan) : {0}".format(dealer_to_device_id(dealer))) else: - parts.append("Çalıştırılacak komut(lar):") - parts.append("") - if plan.is_empty: - parts.append(" (komut üretilemedi)") - for line in plan.lines: - parts.append(f" {line}") - parts.append("") - if operation is not None: - parts.append(f"İşlem : {operation.label} [{operation.id}]") - parts.append(f"Kapsam : {SCOPE_LABELS.get(operation.scope, operation.scope)}") - if operation.description: - parts.append(f"Açıklama: {operation.description}") - if target is not None: - parts.append(f"Hedef : {target.label}") - elif plan.target_label: - parts.append(f"Hedef : {plan.target_label}") - if plan.needs_root: - parts.append("Yetki : root gerekir — konsolu sudo ile açın.") - if plan.check_mode: - parts.append("Kip : --check (deneme) — hiçbir değişiklik uygulanmaz.") - if plan.dry_run: - parts.append("Kip : DENEME (--dry-run) — komut çalıştırılmaz, yalnız gösterilir.") - if plan.destructive: - parts.append("") - parts.append("UYARI: Bu YIKICI bir işlemdir; seçili hedefte değişiklik yapar.") - for note in plan.notes: - parts.append(f"Not : {note}") - return "\n".join(parts) + lines.append(" [UYARI] Ana makine adı bf-<8 hane> biçiminde değil (docs/02).") + lines.append(" Çekirdek : {0}".format(platform.platform())) + values = state_values() + for key in ("device_id", "phase"): + if key in values: + lines.append(" state.json {0:<10}: {1}".format(key, values[key])) + return lines -def destructive_operations( - operations: Sequence[Operation] | None = None, -) -> tuple[str, ...]: - """Yikici isaretli operasyonlarin id'leri.""" - ops = OPERATIONS if operations is None else operations - return tuple(op.id for op in ops if op.destructive) +def state_lines(path: str = config.STATE_FILE) -> List[str]: + lines = ["KURULUM / KAYIT DURUMU"] + values = state_values(path) + if not values: + lines.append(" [UYARI] {0} yok veya okunamadı: cihaz kurulmamış olabilir.".format(path)) + return lines + for key in config.STATE_ALLOWED_KEYS: + if key in values: + lines.append(" {0:<18} {1}".format(key, values[key])) + lines.append("") + lines.append("Yalnız yukarıdaki kimlik/yaşam döngüsü alanları okunur; jeton ve anahtarlar asla yazılmaz.") + lines.append("İleri yönlü durum makinesi: PROVISIONED_OFFLINE -> ENROLLED -> READY (docs/29).") + return lines -def operation_is_destructive(operation_id: str) -> bool: - op = operation_by_id(operation_id) - return bool(op and op.destructive) +def environment_lines( + repo: Repository, + log_file: str, + inventory: Inventory, + dry_run: bool, + check_mode: bool, +) -> List[str]: + lines = [ + "ORTAM", + " Konsol : {0} {1}".format(config.PROGRAM_NAME, config.VERSION), + " Depo : {0}".format(repo.root), + " Envanter : {0}".format(inventory.labels()), + " Denetim kaydı : {0}".format(log_file), + " dry-run : {0}".format("evet" if dry_run else "hayır"), + " check modu : {0}".format("evet" if check_mode else "hayır"), + " Python : {0}".format(platform.python_version()), + ] + for tool in (config.TOOL_ENROLL_STATUS, config.TOOL_LIVE_HW_CHECK): + try: + lines.append(" {0:<21}: {1}".format(tool, repo.tool_path(tool))) + except Refused: + lines.append(" {0:<21}: yok".format(tool)) + return lines -# --------------------------------------------------------------------------- -# Yerlesik katalog -- admin/bf-menu tablosuyla ayni id/kapsam/yikici degerleri -# (kaynak: admin/bf-menu BF_OPS_REPORT / BF_OPS_MAINTENANCE) -# --------------------------------------------------------------------------- -_FLEET_OPTIONS: Mapping[str, tuple[OptionSpec, ...]] = { - "disk": ( - OptionSpec( - "disk_warn_pct", - "Uyarı eşiği (%)", - kind="int", - default="", - hint="Boş = playbook varsayılanı (80)", - validator=validate_disk_pct, - ), - ), - "fieldos-version": ( - OptionSpec( - "fieldos_expected_version", - "Beklenen Field OS sürümü", - default="", - hint="Boş = drift denetimi atlanır", - validator=validate_expected_version, - ), - ), - "service-restart": ( - OptionSpec( - "service_name", - "Servis adı", - kind="choice", - choices=SERVICE_ALLOWLIST, - required=True, - hint="Yalnız izinli servis adları", - validator=validate_service_name, - ), - ), - "run-script": ( - OptionSpec( - "script_path", - "Script yolu", - required=True, - hint=f"İzinli dizinler: {', '.join(SCRIPT_DIRS)}", - validator=validate_script_path, - ), - OptionSpec("script_args", "Script argümanları", default="", hint="İsteğe bağlı"), - ), - "collect-logs": ( - OptionSpec( - "log_lines", - "Cihaz başına journal satırı", - kind="int", - default="500", - validator=validate_log_lines, - ), - ), - "reboot": ( - OptionSpec( - "reboot_grace_seconds", - "Yeniden başlatma bekleme süresi (saniye)", - kind="int", - default="300", - validator=validate_grace, - ), - ), - "deploy-update": ( - OptionSpec( - "update_packages", - "Paket pinleri (paket=sürüm, virgülle)", - required=True, - hint="Örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble", - validator=validate_package_pins, - ), - ), -} - -OPERATIONS: tuple[Operation, ...] = ( - # --- Kurulum (bu cihaz; filo cihazina DOKUNMAZ) ------------------------ - Operation( - "install-preflight", - "Kurulum ön kontrolü (--check)", - "Kurulum", - destructive=False, - scope="local", - description="Hiçbir şey yazmadan kurulum ön koşullarını raporlar.", - needs_dealer=True, - ), - Operation( - "install-local", - "Bu cihazı kur", - "Kurulum", - destructive=True, - scope="local", - description="Cihazı kurar, hostname'i bf- yapar.", - requires_root=True, - needs_dealer=True, - options=( - OptionSpec("offline", "Yerel çevrimdışı depodan kur (--offline)", kind="bool"), - OptionSpec("resume", "Zaten OK olan modülleri atla (--resume)", kind="bool"), - ), - ), - Operation( - "install-resume", - "Başarısız kuruluma devam et (--resume)", - "Kurulum", - destructive=True, - scope="local", - description="Yarıda kalan kurulumu idempotent biçimde sürdürür.", - requires_root=True, - needs_dealer=True, - ), - Operation( - "install-module", - "Tek modül çalıştır (--only)", - "Kurulum", - destructive=True, - scope="local", - description="Kurulumun tek bir modülünü çalıştırır.", - requires_root=True, - needs_dealer=True, - options=( - OptionSpec( - "only", - "Modül", - required=True, - hint="Örn. 01-precheck", - validator=validate_module_name, - ), - ), - ), - Operation( - "install-state", - "Kurulum modül durumu", - "Kurulum", - destructive=False, - scope="local", - description="Kurulumun hangi modülde kaldığını gösterir (salt-okunur).", - ), - Operation( - "install-log", - "Kurulum günlüğü (son 40 satır)", - "Kurulum", - destructive=False, - scope="local", - description="Kurulum günlüğünün sonunu gösterir (salt-okunur).", - ), - # --- Cihaz islemleri: salt-okunur raporlar ----------------------------- - Operation("ping", "Ping / erişilebilirlik", "Cihaz işlemleri", False, "simple", - "SSH erişilebilirliği (salt-okunur).", "bf-ping.yml"), - Operation("uptime", "Çalışma süresi ve yük", "Cihaz işlemleri", False, "simple", - "Uptime ve yük ortalaması (salt-okunur).", "bf-uptime.yml"), - Operation("disk", "Disk kullanımı", "Cihaz işlemleri", False, "simple", - "Disk kullanımı; eşik üstünde uyarır.", "bf-disk-check.yml", - options=_FLEET_OPTIONS["disk"]), - Operation("docker", "Docker durumu", "Cihaz işlemleri", False, "simple", - "Docker servisi ve konteynerler (salt-okunur).", "bf-docker-status.yml"), - Operation("packages", "Kurulu paket sürümleri", "Cihaz işlemleri", False, "simple", - "Pinlenmiş paketlerin kurulu sürümleri.", "bf-package-check.yml"), - Operation("security-updates", "Bekleyen güvenlik güncellemeleri", "Cihaz işlemleri", - False, "simple", "Yalnız rapor; hiçbir güncelleme kurmaz.", - "bf-security-updates-check.yml"), - Operation("provisioning-status", "Kurulum fazı raporu", "Cihaz işlemleri", False, - "wave", "Cihaz başına state.json fazı.", "bf-provisioning-status.yml"), - Operation("enrollment-status", "Kayıt (enrollment) ve merkez kanıtı", - "Cihaz işlemleri", False, "wave", - "ENROLLED/READY iddialarını merkezi kanıta karşı denetler.", - "bf-enrollment-status.yml"), - Operation("fieldos-version", "Field OS sürüm raporu", "Cihaz işlemleri", False, "wave", - "Sürüm bilgisi; istenirse drift denetimi.", "bf-fieldos-version.yml", - options=_FLEET_OPTIONS["fieldos-version"]), - Operation("remote-channels", "Uzak erişim kanalları", "Cihaz işlemleri", False, "wave", - "SSH/xRDP/RustDesk/WireGuard hazırlığı.", "bf-remote-channels-check.yml"), - Operation("offline-ready", "Çevrimdışı hazırlık raporu", "Cihaz işlemleri", False, "wave", - "Offline provisioning yerel ön koşulları.", "bf-offline-ready-check.yml"), - # --- Cihaz islemleri: bakim (yikici olanlar isaretli) ------------------- - Operation("rdp-restart", "xRDP servisini yeniden başlat", "Cihaz işlemleri", True, - "simple", "xRDP servisini yeniden başlatır (RDP açık kalır).", - "bf-rdp-restart.yml"), - Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "Cihaz işlemleri", - True, "simple", "RustDesk servisini yeniden başlatır.", "bf-rustdesk-restart.yml"), - Operation("wireguard-restart", "WireGuard (wg0) yeniden başlat", "Cihaz işlemleri", True, - "simple", "wg-quick@wg0 servisini yeniden başlatır.", "bf-wireguard-restart.yml"), - Operation("service-restart", "İzinli servisi yeniden başlat", "Cihaz işlemleri", True, - "simple", "Yalnız izinli servis adlarını yeniden başlatır.", - "bf-service-restart.yml", options=_FLEET_OPTIONS["service-restart"]), - Operation("gui-off", "Yerel grafik oturumu kapat (headless)", "Cihaz işlemleri", True, - "wave", "Dalgayı multi-user.target'e alır; xRDP'yi durdurmaz.", "bf-gui-off.yml"), - Operation("gui-on", "Yerel grafik oturumu aç (bakım)", "Cihaz işlemleri", True, "wave", - "Grafik oturumu yeniden açar (bakım için).", "bf-gui-on.yml"), - Operation("collect-logs", "Günlük topla / destek paketi", "Cihaz işlemleri", False, - "wave", "Zaman damgalı journal + destek paketi üretir.", "bf-collect-logs.yml", - options=_FLEET_OPTIONS["collect-logs"]), - Operation("run-script", "Onaylı script çalıştır", "Cihaz işlemleri", True, "wave", - "Yalnız onaylı dizinlerdeki scriptleri çalıştırır.", "bf-run-script.yml", - options=_FLEET_OPTIONS["run-script"]), - Operation("reboot", "Kontrollü yeniden başlatma", "Cihaz işlemleri", True, "release", - "Seri yeniden başlatma + sağlık kapısı.", "bf-reboot.yml", - options=_FLEET_OPTIONS["reboot"]), - Operation("deploy-update", "Onaylı paket güncellemesi", "Cihaz işlemleri", True, - "release", "package=sürüm pinleri + dalga ve sağlık kapıları.", - "bf-deploy-update.yml", options=_FLEET_OPTIONS["deploy-update"]), - # --- Merkez hazirligi (salt-okunur denetim; merkez SUNUCUSU kurulmaz) --- - Operation("central-targets", "Envanter hedefleri (dalga/grup/cihaz sayıları)", - "Merkez hazırlığı", False, "local", - "Envanteri ve dalga gruplarını listeler; hiçbir cihaza dokunmaz."), - Operation("central-placeholders", "Doldurulmamış PLACEHOLDER denetimi", - "Merkez hazırlığı", False, "local", - "group_vars/all.yml içinde kalan PLACEHOLDER değerlerini gösterir."), - Operation("central-endpoints", "Yapılandırılmış merkez uç noktaları", - "Merkez hazırlığı", False, "local", - "WireGuard hub, izleme ve depo uç noktalarını (secret hariç) gösterir."), - Operation("central-checklist", "Merkez hazırlık kontrol listesi", - "Merkez hazırlığı", False, "local", - "Kayıttan önce merkezde var olması gerekenleri hatırlatır."), - # --- Araclar ----------------------------------------------------------- - Operation("tools-catalogue", "İşlem kataloğunu göster (--list)", "Araçlar", False, - "local", "Konsolun işlem kataloğunu listeler."), - Operation("tools-git-pull", "Bu kopyayı güncelle (git pull --ff-only)", "Araçlar", True, - "local", "Depo kopyasını ileri sarar; yerel değişikliği silmez."), - Operation("tools-gate-local", "Yerel çevrimdışı kapısı (bf-check-local)", "Araçlar", - False, "local", "Cihazın çevrimdışı kurulum ön koşullarını denetler."), - Operation("tools-gate-enrollment", "Kayıt kapısı (bf-check-enrollment)", "Araçlar", - False, "local", "Kayıt (enrollment) ön koşullarını denetler."), - Operation("tools-gate-ready", "Teslim kapısı (bf-check-ready)", "Araçlar", False, - "local", "Cihazın READY olabilmesi için gerekenleri denetler."), - Operation("tools-diagnostics", "Derin teşhis (bf-diagnostics)", "Araçlar", False, - "local", "Kapsamlı teşhis raporu üretir (salt-okunur)."), - Operation("tools-release", "Field OS sürüm kimliği (bf-release)", "Araçlar", False, - "local", "Cihazdaki Field OS sürümünü gösterir."), - Operation("tools-support-bundle", "Destek paketi oluştur", "Araçlar", False, "local", - "Parola/anahtar içermeyen destek paketi üretir."), - Operation("tools-console-log", "Konsol denetim günlüğü (son 40 satır)", "Araçlar", - False, "local", "Operatör konsolunun denetim günlüğünü gösterir."), - # --- Cihaz bilgisi (salt-okunur) --------------------------------------- - Operation("info-summary", "Durum özeti (sağlık + kurulum durumu)", "Cihaz bilgisi", - False, "local", - "bf-status, whitelist'li state.json alanları ve envanter hedefleri."), - Operation("info-identity", "Kimlik (hostname, cihaz no)", "Cihaz bilgisi", False, - "local", "Makine adı ve cihaz kimliği."), - Operation("info-network", "Ağ (adres, rota, DNS, WireGuard)", "Cihaz bilgisi", False, - "local", "Ağ arayüzleri, varsayılan rota, DNS ve WireGuard durumu."), - Operation("info-resources", "Disk, bellek, yük", "Cihaz bilgisi", False, "local", - "Dosya sistemleri, bellek ve yük ortalaması."), - Operation("info-packages", "Kurulu pinlenmiş paketler", "Cihaz bilgisi", False, "local", - "docker-ce, wireguard, rustdesk, xrdp, openssh-server sürümleri."), - Operation("info-services", "Servis durumu", "Cihaz bilgisi", False, "local", - "ssh, xrdp, rustdesk, docker, wg-quick@wg0, cron durumu."), - Operation("info-journal", "Son journal hataları", "Cihaz bilgisi", False, "local", - "journalctl -p err son 20 satır."), - Operation("info-state", "Kurulum/kayıt durumu (state.json beyaz liste)", "Cihaz bilgisi", - False, "local", - "Yalnız kimlik/durum alanları okunur; token ve anahtar asla gösterilmez."), -) - -_OPERATION_INDEX: Mapping[str, Operation] = {op.id: op for op in OPERATIONS} - - -def operation_by_id(operation_id: str) -> Operation | None: - return _OPERATION_INDEX.get(operation_id) - - -def operations_by_category(category: str) -> tuple[Operation, ...]: - return tuple(op for op in OPERATIONS if op.category == category) - - -# --------------------------------------------------------------------------- -# Yardimci: yol cozumleme (admin/bf-menu ile ayni kurallar) -# --------------------------------------------------------------------------- -def repo_root_from_file() -> Path: - """bfos/ui_gui.py -> depo koku (bir ust dizin).""" - return Path(__file__).resolve().parents[1] - - -def resolve_inventory(repo_root: Path, explicit: str | None = None) -> Path | None: - if explicit: - candidate = Path(explicit).expanduser() - return candidate if candidate.is_file() else None - base = repo_root / "ansible" / "inventory" - for name in ("hosts.yml", "hosts.yaml", "hosts", "hosts.ini"): - candidate = base / name - if candidate.is_file(): - return candidate - return 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.""" - system = Path("/usr/local/bin") / name - if system.is_file() and os.access(system, os.X_OK): - return str(system) - root = repo_root or repo_root_from_file() - for sub in ("scripts/diagnostics", "scripts/maintenance"): - candidate = root / sub / name - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) - return None - - -def playbook_path(repo_root: Path, playbook: str) -> Path: - return repo_root / PLAYBOOK_DIR_REL / playbook - - -def playbook_declares_var(path: Path, var: str) -> bool: - """Playbook dosyasi VAR'i (yorum satirlari haric) kullaniyor mu? - - admin/bf-menu -> bf_playbook_var_present ile ayni fikir: playbook kendi - kapisinin kaynagi kalir, konsol onu varsaymaz. - """ +def file_mode(path: str) -> str: + """Octal permission bits of PATH, or "?" when it cannot be read.""" try: - text = path.read_text(encoding="utf-8", errors="replace") + return oct(stat.S_IMODE(os.stat(path).st_mode))[2:].zfill(3) except OSError: - return False - pattern = re.compile(rf"\b{re.escape(var)}\b") - for line in text.splitlines(): - stripped = line.lstrip() - if stripped.startswith("#"): - continue - if pattern.search(line): - return True - return False + return "?" + + + +"""Compatibility adapter for the canonical system identity file.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from bfos.operations import dealer_is_valid +from bfos.identity import DEALER_ID_FILE, identity_for, read_dealer_id + +PREFERENCES_FILENAME = "blueforce-gui.json" -def playbook_scope(path: Path) -> str: - """Playbook'un kendi beyan ettigi kapsam: release | wave | simple.""" - if playbook_declares_var(path, "release_wave"): - return "release" - if playbook_declares_var(path, "operation_wave"): - return "wave" - return "simple" +def preferences_path() -> Path: + return Path(os.environ.get("BF_DEALER_ID_FILE", str(DEALER_ID_FILE))) -def parse_inventory(path: Path) -> dict[str, list[str]]: - """Salt-okunur envanter okuma: {grup: [host, ...]}. - - admin/lib/targets.sh -> bf_inventory_pairs awk okuyucusunun Python - karsiligi: `hosts:` bloklarini iceren en yakin grup anahtari kullanilir. - Amac yalnizca hedef listesi/dalga listesi doldurmak; hicbir sey yazilmaz. - """ - groups: dict[str, list[str]] = {} - stack: list[tuple[int, str]] = [] - hosts_indent: int | None = None - hosts_step: int | None = None - hosts_group = "" +def load_preferences(path: Path | None = None) -> dict[str, str]: + target = path or preferences_path() + if path is None: + try: + return identity_for(read_dealer_id(target)) + except (OSError, ValueError): + return {} try: - lines = path.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: - return groups - - for raw in lines: - if raw.lstrip().startswith("#") or not raw.strip() or raw.lstrip().startswith("-"): - continue - if ":" not in raw: - continue - indent = len(raw) - len(raw.lstrip(" ")) - key, _, value = raw.partition(":") - key = key.strip().strip("\"'") - value = value.strip().strip("\"'") - if not key: - continue - while stack and stack[-1][0] >= indent: - stack.pop() - - if key == "hosts" and value == "": - hosts_indent = indent - hosts_step = None - hosts_group = stack[-1][1] if stack else "" - stack.append((indent, key)) - continue - if hosts_indent is not None and indent <= hosts_indent: - hosts_indent = None - hosts_step = None - - if hosts_indent is not None and indent > hosts_indent: - if hosts_step is None: - hosts_step = indent - if indent == hosts_step and hosts_group: - groups.setdefault(hosts_group, []).append(key) - stack.append((indent, key)) - continue - if value == "": - stack.append((indent, key)) - return groups + data: Any = json.loads(target.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + dealer = str(data.get("dealer_id", "")).strip() + if not dealer_is_valid(dealer): + return {} + return identity_for(dealer) -def waves_in_inventory(groups: Mapping[str, Sequence[str]]) -> tuple[str, ...]: - """Envanterde gercekten bulunan onayli dalgalar (yayilim sirasiyla).""" - return tuple(wave for wave in WAVES if groups.get(wave)) +def save_dealer(dealer_id: str, path: Path | None = None) -> dict[str, str]: + if not dealer_is_valid(dealer_id): + raise ValueError("Bayi numarası tam 8 rakam olmalıdır.") + 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) + data = {"dealer_id": dealer_id, "active_user": f"bf-{dealer_id}"} + temporary = target.with_suffix(target.suffix + ".tmp") + temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(temporary, target) + return identity -def device_wave(host: str, groups: Mapping[str, Sequence[str]]) -> str | None: - for wave in WAVES: - if host in (groups.get(wave) or ()): - return wave - return None - - -# --------------------------------------------------------------------------- -# Komut calistirma (sozlesme maddesi 4) -# --------------------------------------------------------------------------- -def run_command( - argv: Sequence[str], - on_line: Callable[[str], None] | None = None, - cancel: threading.Event | None = None, - cwd: str | None = None, -) -> RunResult: - """Tek komutu calistirir; ciktisi satir satir `on_line`'a akar.""" - argv = [str(a) for a in argv] - if not argv: - return RunResult(2, False, "Boş komut çalıştırılamaz.") - emit = on_line or (lambda _line: None) +def clear_dealer(path: Path | None = None) -> None: + target = path or preferences_path() try: - proc = subprocess.Popen( # noqa: S603 - argv listesi, shell yok - argv, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - cwd=cwd, - ) + target.unlink() except FileNotFoundError: - message = ( - f"Komut bulunamadı: '{argv[0]}' bu cihazda kurulu değil. " - "Araçları bağlamak için: sudo admin/bf-bootstrap.sh" - ) - emit(message) - return RunResult(127, False, message + "\n") - except OSError as exc: - message = f"Komut başlatılamadı: {' '.join(argv)} ({exc})" - emit(message) - return RunResult(126, False, message + "\n") + pass + - collected: list[str] = [] - assert proc.stdout is not None - for line in proc.stdout: - collected.append(line) - emit(line.rstrip("\n")) - if cancel is not None and cancel.is_set(): - proc.terminate() - note = "Kullanıcı iptal etti; komut sonlandırıldı." - collected.append(note + "\n") - emit(note) - break - exit_code = proc.wait() - return RunResult(exit_code, exit_code == 0, "".join(collected)) + +"""Blueforce GUI surfaces. +The installer and the remote portal deliberately have separate view models. +They share only the backend contract; no selection, navigation, or execution +state crosses the boundary. +""" -def run_plan( - plan: CommandPlan, - on_line: Callable[[str], None] | None = None, - cancel: threading.Event | None = None, - cwd: str | None = None, -) -> RunResult: - """Plan icindeki komutlari sirayla calistirir; ilk hatada DURUR. +from __future__ import annotations - (Wave akisindaki "bir dalga basarisizsa sonraki dalgaya gecme" kuralinin - basit karsiligi: hata sonrasi komut calistirilmaz.) - """ - emit = on_line or (lambda _line: None) - if plan.is_empty: - return RunResult(2, False, "Çalıştırılacak komut yok.\n") - if plan.is_report_only: - for line in plan.report_lines: - emit(line) - return RunResult(0, True, "\n".join(plan.report_lines) + "\n") - if plan.dry_run: - for line in plan.lines: - emit(f"[deneme] {line}") - return RunResult(0, True, "[deneme] komut çalıştırılmadı.\n") - if plan.needs_root and not write_operations_enabled(): - # Last line of defence: every execution path funnels through this - # function, so the root rule holds even if a future caller builds the - # plan itself instead of going through the GUI's run button. - emit("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT) - return RunResult(2, False, NON_ROOT_DISABLED_HINT + "\n") +import os +import queue +import subprocess +import threading +import tkinter as tk +from tkinter import messagebox, ttk +from typing import Any - chunks: list[str] = [] - for index, argv in enumerate(plan.commands, start=1): - if not argv: - continue - if len(plan.commands) > 1: - emit(f"--- [{index}/{len(plan.commands)}] {shlex.join(argv)}") - result = run_command(argv, on_line=emit, cancel=cancel, cwd=cwd) - chunks.append(result.output) - if not result.ok: - emit(f"[HATA] Komut {result.exit_code} koduyla bitti: {shlex.join(argv)}") - return RunResult(result.exit_code, False, "".join(chunks)) - return RunResult(0, True, "".join(chunks)) - - -# --------------------------------------------------------------------------- -# Backend: yerlesik katalog (admin/bf-menu kurallari) + gercek modul adapteri -# --------------------------------------------------------------------------- -@dataclass -class GuiContext: - """Arayuzun calisma baglami (yol ve kip bilgisi).""" - - repo_root: Path = field(default_factory=repo_root_from_file) - inventory: Path | None = None - console_log: str = DEFAULT_CONSOLE_LOG - dry_run: bool = False - - @classmethod - def detect(cls, repo: str | None = None, inventory: str | None = None, - dry_run: bool = False) -> "GuiContext": - root = Path(repo).expanduser().resolve() if repo else repo_root_from_file() - return cls( - repo_root=root, - inventory=resolve_inventory(root, inventory), - console_log=os.environ.get("BF_CONSOLE_LOG", DEFAULT_CONSOLE_LOG), - dry_run=dry_run, - ) - - -class CatalogueBackend: - """Yerlesik katalog: admin/bf-menu'nun tablosu + komut uretimi. - - Gercek moduller (bfos.operations / bfos.runner) yoksa arayuz bunu kullanir; - boylece GUI, backend yazilmadan once de dogru komutu gosterip calistirabilir. - """ - - name = "yerleşik katalog (admin/bf-menu kuralları)" - - def __init__(self, ctx: GuiContext): - self.ctx = ctx - self._groups: dict[str, list[str]] | None = None - - # -- envanter (salt-okunur) ------------------------------------------- - @property - def groups(self) -> dict[str, list[str]]: - if self._groups is None: - self._groups = ( - parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} - ) - return self._groups - - def list_operations(self) -> tuple[Operation, ...]: - return OPERATIONS - - def list_waves(self) -> tuple[str, ...]: - present = waves_in_inventory(self.groups) - return present or WAVES - - def inventory_note(self) -> str: - if self.ctx.inventory is None: - note = ( - "Ansible envanteri bulunamadı (ansible/inventory/hosts.yml). " - "Filo işlemleri için envanteri oluşturun: " - "cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml" - ) - else: - note = f"Envanter: {self.ctx.inventory}" - if shutil.which("ansible-playbook") is None: - note += ( - " | UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz " - "(sudo apt install ansible)." - ) - return note - - # -- hedef ------------------------------------------------------------- - def validate_target(self, operation: Operation, target: Target) -> str | None: - if target.kind == "dealer": - problem = dealer_error(target.value) - if problem: - return problem - if operation.scope in ("wave", "release"): - if target.kind == "dealer": - if self.ctx.inventory is None: - return ( - "Bu işlem dalga kapılıdır ve cihazın dalgasını çözmek için " - "envanter gerekir; envanter bulunamadı." - ) - wave = device_wave(dealer_to_host(target.value), self.groups) - if not wave: - return ( - f"{dealer_to_host(target.value)} onaylı bir dalga grubunda değil " - f"({', '.join(WAVES)}). Önce cihazı dalgasına ekleyin." - ) - elif target.kind == "all": - if operation.scope == "release": - return ( - "Sürüm kapılı işlemler tüm filo ile çalıştırılamaz: " - "onaylı bir dalga grubu seçin (dalga dalga yayılım)." - ) - elif target.kind == "group" and not is_wave(target.value): - return ( - f"'{target.value}' onaylı bir dalga değil. Dalga kapılı işlemler " - f"şu dalgalardan birini ister: {', '.join(WAVES)}." - ) - return None - - def target_wave(self, operation: Operation, target: Target) -> str | None: - if target.kind == "group" and is_wave(target.value): - return target.value - if target.kind == "dealer": - return device_wave(dealer_to_host(target.value), self.groups) - return None - - # -- kapilar (acik onay adimlari) --------------------------------------- - def gate_steps( - self, operation: Operation, target: Target | None - ) -> tuple[GateStepInfo, ...]: - """Calistirmadan once sorulacak ACIK onaylar (bf-menu ile ayni kurallar). - - Hicbir kapi sessizce gecilmez: dalga adi yazdirilir, production icin - ayrica `production` yazilir, surum kapisi icin GATE yazilir, yikici - islemler icin ikinci onay istenir. - """ - steps: list[GateStepInfo] = [] - wave = self.target_wave(operation, target) if target else None - book = ( - playbook_path(self.ctx.repo_root, operation.playbook) - if operation.playbook - else None - ) - gate_supported = bool(book and playbook_declares_var(book, "wave_gate_confirmed")) - - if operation.scope in ("wave", "release") and wave: - if wave == PRODUCTION_WAVE: - steps.append( - GateStepInfo( - kind="token", - key="production_token", - prompt="PRODUCTION onayı — canlı filo", - message=( - "Seçilen dalga canlı filodur (production).\n" - "Onaylamak için kutuya 'production' yazın; başka bir şey iptal eder." - + ("\nEk olarak dalga kapısı bayrağı onaylanacak." if gate_supported else "") - ), - token=PRODUCTION_WAVE, - unlocks=("wave_gate_confirmed",) if gate_supported else (), - ) - ) - steps.append( - GateStepInfo( - kind="approval", - key="production_approval", - prompt="Production ek onayı", - message=( - "production_override / allow_production_reboot bayrakları " - "yalnız bu onayla eklenir (playbook ilgili değişkeni " - "kullanıyorsa).\nDevam edilsin mi?" - ), - unlocks=("production_override", "allow_production_reboot"), - ) - ) - elif operation.scope == "release" and gate_supported: - steps.append( - GateStepInfo( - kind="token", - key="wave_gate_token", - prompt=f"Sürüm kapısı onayı (dalga: {wave})", - message=( - f"'{wave}' dalgası için sürüm kapısını onaylıyorsunuz.\n" - f"Önceki dalga sağlık kapısını geçtiyse '{GATE_TOKEN_RELEASE}' yazın." - ), - token=GATE_TOKEN_RELEASE, - unlocks=("wave_gate_confirmed",), - ) - ) - if target is not None and target.kind == "all" and operation.scope == "simple": - steps.append( - GateStepInfo( - kind="token", - key="fleet_token", - prompt="FİLO GENELİ onayı", - message=( - "Komut envanterdeki HER cihaza uygulanacak.\n" - "Onaylamak için kutuya 'all' yazın." - ), - token="all", - unlocks=("fleet_wide",), - ) - ) - if operation.destructive: - steps.append( - GateStepInfo( - kind="approval", - key="destructive_approval", - prompt="İKİNCİ ONAY — yıkıcı işlem", - message=( - f"'{operation.label}' seçili hedefte DEĞİŞİKLİK YAPAR.\n" - "Bu onay yalnız bu işlem için geçerlidir. Devam edilsin mi?" - ), - ) - ) - return tuple(steps) - - # -- komut uretimi ----------------------------------------------------- - def build_command( - self, - operation: Operation, - target: Target | None = None, - options: Mapping[str, str] | None = None, - grants: Mapping[str, bool] | None = None, - check_mode: bool = False, - ) -> CommandPlan: - opts = dict(options or {}) - self._check_options(operation, opts) - if operation.scope in ("wave", "release") and target is not None: - problems = self.validate_target(operation, target) - if problems: - raise CommandError(problems) - if operation.is_fleet: - return self._build_fleet_plan(operation, target, opts, dict(grants or {}), check_mode) - return self._build_local_plan(operation, target, opts, check_mode) - - def _check_options(self, operation: Operation, opts: Mapping[str, str]) -> None: - for spec in operation.options: - value = (opts.get(spec.name) or "").strip() - if spec.required and not value: - raise CommandError(f"'{spec.label}' zorunludur ({operation.label}).") - if value and spec.validator: - problem = spec.validator(value) - if problem: - raise CommandError(problem) - - def _build_fleet_plan( - self, - operation: Operation, - target: Target | None, - opts: Mapping[str, str], - grants: Mapping[str, bool], - check_mode: bool = False, - ) -> CommandPlan: - if target is None or not target.value: - raise CommandError( - "Hedef seçilmedi; komut üretilmedi. Bayi numarası veya dalga grubu seçin." - ) - problem = self.validate_target(operation, target) - if problem: - raise CommandError(problem) - if self.ctx.inventory is None: - raise CommandError( - "Ansible envanteri bulunamadı; filo işlemleri komutu üretilemiyor. " - "Beklenen yol: ansible/inventory/hosts.yml (veya --inventory PATH)." - ) - book = playbook_path(self.ctx.repo_root, operation.playbook) - if not book.is_file(): - raise CommandError( - f"Playbook bulunamadı: {PLAYBOOK_DIR_REL}/{operation.playbook}\n" - "Doğru depo kopyasında çalıştırın (--repo PATH) veya checkout'u güncelleyin." - ) - declared = playbook_scope(book) - if declared != operation.scope: - raise CommandError( - f"{operation.playbook} çalıştırılmıyor: konsol kapsamı '{operation.scope}' " - f"beklerken playbook '{declared}' beyan ediyor.\n" - "Playbook kapıların kaynağıdır; tablo ile playbook'u eşitleyin " - "(admin/bf-menu -> bf_playbook_check ile aynı kural)." - ) - - wave = self.target_wave(operation, target) - if operation.scope in ("wave", "release") and not wave: - raise CommandError( - "Bu işlem dalga kapsamlıdır ama hedefte onaylı bir dalga yok. " - "Bir dalga grubu seçin (" + ", ".join(WAVES) + ")." - ) - - argv: list[str] = [ - "ansible-playbook", - "-i", - str(self.ctx.inventory), - str(book), - "--limit", - target.limit, - ] - notes: list[str] = [] - if operation.scope == "wave": - argv += ["-e", f"operation_wave={wave}", "-e", "operation_confirmed=true"] - if ( - wave == PRODUCTION_WAVE - and grants.get("production_override") - and playbook_declares_var(book, "production_override") - ): - argv += ["-e", "production_override=true"] - notes.append( - "Dalga kapısı: yalnız önceki dalga sağlık kapısını ve merkez onayını " - "geçtiyse çalıştırın (docs/10)." - ) - elif operation.scope == "release": - argv += ["-e", f"release_wave={wave}"] - if grants.get("wave_gate_confirmed") and playbook_declares_var( - book, "wave_gate_confirmed" - ): - argv += ["-e", "wave_gate_confirmed=true"] - if playbook_declares_var(book, "reboot_confirmed"): - argv += ["-e", "reboot_confirmed=true"] - if wave == PRODUCTION_WAVE: - if grants.get("production_override") and playbook_declares_var( - book, "production_override" - ): - argv += ["-e", "production_override=true"] - if grants.get("allow_production_reboot") and playbook_declares_var( - book, "allow_production_reboot" - ): - argv += ["-e", "allow_production_reboot=true"] - notes.append( - "Sürüm kapısı: dalga onayı ve sağlık kapısı olmadan çalıştırmayın (docs/10)." - ) - if target.kind == "all" and operation.scope == "wave": - notes.append( - "Tüm filo: dalga kapılı playbook'lar dalga dalga ilerler; her dalgayı " - "ayrı ayrı onaylayın." - ) - - argv += self._extra_vars(operation.id, opts) - if check_mode: - argv.append("--check") - notes.append("--check: playbook hiçbir değişiklik uygulamaz (deneme koşusu).") - return CommandPlan( - commands=(tuple(argv),), - destructive=operation.destructive, - requires_root=operation.needs_root, - operation_id=operation.id, - target_label=target.label + (f" (dalga {wave})" if wave else ""), - notes=tuple(notes), - dry_run=self.ctx.dry_run, - check_mode=check_mode, - ) - - def _extra_vars(self, operation_id: str, opts: Mapping[str, str]) -> list[str]: - value = lambda name: (opts.get(name) or "").strip() # noqa: E731 - extra: list[str] = [] - if operation_id == "disk" and value("disk_warn_pct"): - extra += ["-e", f"disk_warn_pct={value('disk_warn_pct')}"] - elif operation_id == "fieldos-version" and value("fieldos_expected_version"): - extra += ["-e", f"fieldos_expected_version={value('fieldos_expected_version')}"] - elif operation_id == "collect-logs" and value("log_lines"): - extra += ["-e", f"log_lines={value('log_lines')}"] - elif operation_id == "reboot" and value("reboot_grace_seconds"): - extra += ["-e", f"reboot_grace_seconds={value('reboot_grace_seconds')}"] - elif operation_id == "service-restart" and value("service_name"): - extra += ["-e", f"service_name={value('service_name')}"] - elif operation_id == "run-script" and value("script_path"): - extra += ["-e", f"script_path={value('script_path')}"] - if value("script_args"): - extra += ["-e", f"script_args={value('script_args')}"] - elif operation_id == "deploy-update" and value("update_packages"): - pins = [ - f'"{pin.strip()}"' - for pin in value("update_packages").split(",") - if pin.strip() - ] - extra += ["-e", f"update_packages=[{','.join(pins)}]"] - return extra - - def _installer_arg(self) -> str: - installer = self.ctx.repo_root / INSTALLER_REL - if not installer.is_file(): - raise CommandError( - f"Kurulum betiği bulunamadı: {INSTALLER_REL}\n" - "Depo kopyasını doğru verin (--repo PATH) veya checkout'u güncelleyin." - ) - 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: - path = resolve_tool(tool, self.ctx.repo_root) - if path is None: - if optional: - return None - raise CommandError( - f"Araç bulunamadı: {tool}\n" - "Bu araç depoda veya /usr/local/bin altında yok. Bağlamak için: " - "sudo admin/bf-bootstrap.sh" - ) - return (path, *args) - - def _build_local_plan( - self, - operation: Operation, - target: Target | None, - opts: Mapping[str, str], - check_mode: bool = False, - ) -> CommandPlan: - dealer = "" - if operation.needs_dealer: - if target is None or target.kind != "dealer" or not target.value: - raise CommandError( - f"'{operation.label}' için bayi numarası gerekir " - "(hedef alanına 8 haneli bayi no yazın)." - ) - problem = dealer_error(target.value) - if problem: - raise CommandError(problem) - dealer = target.value - commands: list[tuple[str, ...]] = [] - notes: list[str] = [] - op_id = operation.id - - if op_id == "install-preflight": - commands.append( - ("bash", self._installer_arg(), "--dealer-id", dealer, "--check") - ) - notes.append("Ön kontrol hiçbir şey yazmaz (log/state/dizin oluşturmaz).") - elif op_id in ("install-local", "install-resume"): - argv = ["bash", self._installer_arg(), "--dealer-id", dealer] - if op_id == "install-resume" or opts.get("resume") == "1": - argv.append("--resume") - if opts.get("offline") == "1": - argv.append("--offline") - commands.append(tuple(argv)) - notes.append(f"Hedef cihaz kimliği: {dealer_to_device_id(dealer)} ({dealer_to_host(dealer)}).") - elif op_id == "install-module": - commands.append( - ( - "bash", - self._installer_arg(), - "--dealer-id", - dealer, - "--only", - (opts.get("only") or "").strip(), - ) - ) - elif op_id == "install-state": - commands.append(("cat", INSTALL_STATE_PATH)) - elif op_id == "install-log": - commands.append(("tail", "-n", "40", INSTALL_LOG_PATH)) - elif op_id == "central-targets": - commands.append(("bash", self._menu_arg(), "--list-targets")) - elif op_id == "central-placeholders": - commands.append( - ( - "grep", - "-n", - "PLACEHOLDER", - str(self.ctx.repo_root / "ansible" / "inventory" / "group_vars" / "all.yml"), - ) - ) - notes.append("Eşleşme yoksa bu iyi haberdir (PLACEHOLDER kalmamış demektir).") - elif op_id == "central-endpoints": - commands.append( - ( - "grep", - "-nE", - r"^[[:space:]]*(wg_subnet|wg_interface|wg_hub_endpoint|monitoring_endpoint|" - r"monitoring_job|update_repo_url|meg_image_tag|support_bundle_dir)[[:space:]]*:", - str(self.ctx.repo_root / "ansible" / "inventory" / "group_vars" / "all.yml"), - ) - ) - notes.append("Yalnız yapılandırma anahtarları okunur; secret hiçbir zaman gösterilmez.") - elif op_id == "central-checklist": - for line in CENTRAL_CHECKLIST: - notes.append(line) - elif op_id == "tools-catalogue": - commands.append(("bash", self._menu_arg(), "--list")) - elif op_id == "tools-git-pull": - commands.append(("git", "-C", str(self.ctx.repo_root), "pull", "--ff-only")) - notes.append("Çekme sonrası konsol değişmiş olabilir; arayüzü yeniden başlatın.") - elif op_id == "tools-gate-local": - commands.append(self._tool_argv("bf-check-local", "--check") or ()) - elif op_id == "tools-gate-enrollment": - commands.append(self._tool_argv("bf-check-enrollment", "--check") or ()) - elif op_id == "tools-gate-ready": - commands.append(self._tool_argv("bf-check-ready", "--check") or ()) - elif op_id == "tools-diagnostics": - commands.append(self._tool_argv("bf-diagnostics") or ()) - elif op_id == "tools-release": - commands.append(self._tool_argv("bf-release") or ()) - elif op_id == "tools-support-bundle": - commands.append( - self._tool_argv("bf-support-bundle", "--out-dir", "/var/tmp") or () - ) - notes.append("Destek paketi parola, private key veya token içermez.") - elif op_id == "tools-console-log": - commands.append(("tail", "-n", "40", self.ctx.console_log)) - elif op_id == "info-summary": - commands.extend(self._status_summary_commands()) - elif op_id == "info-identity": - commands.append(("hostname",)) - commands.append( - ( - "grep", - "-nE", - r"^[[:space:]]*\"(device_id|phase)\"", - STATE_JSON_PATH, - ) - ) - elif op_id == "info-network": - commands.append(("ip", "-brief", "address")) - commands.append(("ip", "route", "show", "default")) - commands.append(("sh", "-c", "awk '/^nameserver/{print}' /etc/resolv.conf")) - commands.append(("wg", "show", "wg0", "latest-handshakes")) - notes.append("WireGuard private/preshared anahtarları istenmez ve gösterilmez.") - elif op_id == "info-resources": - commands.append(("df", "-h")) - commands.append(("free", "-h")) - commands.append(("uptime",)) - elif op_id == "info-packages": - commands.append( - ( - "dpkg-query", - "-W", - "-f=${Package} ${Version} ${Status}\n", - "docker-ce", - "wireguard", - "rustdesk", - "xrdp", - "openssh-server", - ) - ) - elif op_id == "info-services": - commands.append( - ( - "systemctl", - "is-active", - "ssh", - "sshd", - "xrdp", - "rustdesk", - "docker", - "wg-quick@wg0", - "cron", - ) - ) - elif op_id == "info-journal": - commands.append(("journalctl", "-p", "err", "-n", "20", "--no-pager")) - elif op_id == "info-state": - commands.append( - ( - "grep", - "-nE", - r"^[[:space:]]*\"(phase|enrollment_status|fleet_status|device_id|provisioning_id)\"", - STATE_JSON_PATH, - ) - ) - notes.append( - "Yalnız kimlik/durum alanları okunur; token ve anahtarlar gösterilmez." - ) - else: - raise CommandError(f"Bilinmeyen operasyon: {operation.id}") - - commands = [argv for argv in commands if argv] - if not commands: - raise CommandError( - f"'{operation.label}' için çalıştırılabilir komut üretilemedi. " - "Bu cihazda gerekli araç kurulu olmayabilir (sudo admin/bf-bootstrap.sh)." - ) - # --check yalnizca destekleyen komutlara eklenir (kurulum betigi ve - # bf-check-* kapilari); tail/cat/grep/dpkg gibi komutlara eklenmez. - if check_mode and op_id in CHECK_CAPABLE_LOCAL_OPS: - commands = [tuple(argv) + ("--check",) for argv in commands] - notes.append("--check: yalnız denetim yapılır, değişiklik uygulanmaz.") - label = target.label if target is not None and target.value else "bu cihaz (yerel)" - return CommandPlan( - commands=tuple(commands), - destructive=operation.destructive, - requires_root=operation.needs_root, - operation_id=operation.id, - target_label=label, - notes=tuple(notes), - dry_run=self.ctx.dry_run, - check_mode=check_mode and op_id in CHECK_CAPABLE_LOCAL_OPS, - ) - - def _status_summary_commands(self) -> list[tuple[str, ...]]: - """Durum ozeti: bakim sirasinda kaybolan cikti yerine guvenli, opsiyonel set.""" - commands: list[tuple[str, ...]] = [] - for tool, args in (("bf-status", ()), ("bf-enrollment-status", ("--check",))): - argv = self._tool_argv(tool, *args, optional=True) - if argv: - commands.append(argv) - commands.append( - ( - "grep", - "-nE", - r"^[[:space:]]*\"(phase|enrollment_status|fleet_status|device_id|provisioning_id)\"", - STATE_JSON_PATH, - ) - ) - commands.append(("bash", self._menu_arg(), "--list-targets")) - return commands - - # -- calistirma -------------------------------------------------------- - def run( - self, - plan: CommandPlan, - on_line: Callable[[str], None] | None = None, - cancel: threading.Event | None = None, - ) -> RunResult: - return run_plan(plan, on_line=on_line, cancel=cancel, cwd=str(self.ctx.repo_root)) - - -CENTRAL_CHECKLIST: tuple[str, ...] = ( - "1. WireGuard hub: uç nokta, anahtarlar ve her bf- için eş ataması (docs/08).", - "2. Kayıt (enrollment) token servisi: tek kullanımlık, TTL sınırlı token (docs/29).", - "3. İzleme: Prometheus/VictoriaMetrics + Grafana, device_id etiketi = BF- (docs/14).", - "4. Uzak erişim: RustDesk hbbs/hbbr veya MeshCentral, WireGuard üzerinden erişilebilir (docs/07).", - "5. Paket aynası + saha kurulumları için çevrimdışı anlık görüntü (docs/10, docs/27).", - "6. Gerçek ana makine adlarını içeren Ansible envanteri ve yönetici SSH anahtarı (docs/02, 06, 09).", - "Bu arayüz 6'yı denetler; 1-5'i hazırlamaya yardımcı olur, merkez sunucusu KURMAZ.", +from bfos import ui_theme as theme +from bfos.ui_gui import ( + CommandError, + GuiContext, + Operation, + Target, + dealer_error, + operation_by_id, + parse_inventory, ) +from bfos.preferences import load_preferences, save_dealer -class ModuleBackend: - """Gercek bfos.operations / bfos.runner modullerini kullanan backend. +class SurfaceFrame(ttk.Frame): + """Common chrome only; each product surface owns its own state below.""" - Paylasilan is mantigi TEK kaynaktan gelir (kopyalanmaz): - * katalog : bfos.operations (catalogue_rows / ALL_OPERATIONS) - * envanter/hedef: Inventory.load_repository + resolve_target - * komut uretimi : build_ansible_command / build_install_command / - build_tool_command / build_extra_vars - * kapilar : confirmation_plan + GatesGranted (dalga, production, - surum kapisi, yikici ikinci onay) - * denetim kaydi : bfos.runner.AuditLog (varsa) -- TUI ile ayni kayit. - - Yerel (kurulum / arac / merkez / bilgi) islemler icin modulde karsiligi olan - kurucular kullanilir; karsiligi olmayan salt-okunur ekran ve komutlar - CatalogueBackend'e devredilir (bkz. _local). - """ - - name = "bfos.operations + bfos.runner (gerçek backend)" - - #: modulde komut kurucusu olan yerel arac islemleri: id -> (arac, sabit arg.) - _TOOL_OPS: Mapping[str, tuple[str, tuple[str, ...]]] = { - "tools-gate-local": ("bf-check-local", ()), - "tools-gate-enrollment": ("bf-check-enrollment", ()), - "tools-gate-ready": ("bf-check-ready", ()), - "tools-diagnostics": ("bf-diagnostics", ()), - "tools-release": ("bf-release", ()), - "tools-support-bundle": ("bf-support-bundle", ("--out-dir", "/var/tmp")), - } - - #: modulun salt-okunur rapor ureticileri: id -> fonksiyon adi - _REPORT_OPS: Mapping[str, str] = { - "central-targets": "targets_lines", - "central-readiness": "central_readiness_lines", - "central-endpoints": "central_endpoint_lines", - "central-probe": "central_probe_lines", - "central-wave-gates": "wave_gate_state_lines", - "info-identity": "identity_lines", - "info-state": "state_lines", - } - - def __init__(self, ops_module, runner_module, ctx: GuiContext): - self._ops = ops_module - self._runner = runner_module - self.ctx = ctx - self._local = CatalogueBackend(ctx) - self._repo = ops_module.Repository(str(ctx.repo_root)) - self._inventory = ops_module.Inventory.load_repository( - self._repo, str(ctx.inventory) if ctx.inventory else "" - ) - self._raw_ops: dict[str, Any] = {} - self._catalogue = self._load_catalogue() - self._by_id = {op.id: op for op in self._catalogue} - self._fleet_ids = frozenset(op.id for op in self._catalogue if op.is_fleet) - - # -- katalog ----------------------------------------------------------- - def _load_catalogue(self) -> tuple[Operation, ...]: - """Modulun kataloğu (filo işlemleri) + yerel işlemler (kurulum/araç/...).""" - raw: Any = None - for name in ("catalogue_rows", "list_operations", "operations", - "ALL_OPERATIONS", "OPERATIONS", "OPS", "get_operations"): - candidate = getattr(self._ops, name, None) - if candidate is None: - continue - try: - value = candidate() if callable(candidate) else candidate - except Exception: - continue - if value: - raw = value - break - if not raw: - raise BackendUnavailable( - "bfos.operations içinde operasyon listesi bulunamadı.\n" - "Beklenen: catalogue_rows() / ALL_OPERATIONS " - "(bkz. bfos/ui_gui.py başlığındaki SÖZLEŞME)." - ) - fleet: list[Operation] = [] - for item in raw: - operation = self._normalize_operation(item) - fleet.append(operation) - self._raw_ops[operation.id] = item - if not fleet: - raise BackendUnavailable("bfos.operations boş bir operasyon listesi döndürdü.") - local = tuple(op for op in OPERATIONS if not op.is_fleet) - return tuple(fleet) + local - - @staticmethod - def _normalize_operation(item) -> Operation: - """Modul operasyonunu arayuzun Operation tipine cevirir (SÖZLEŞME).""" - if isinstance(item, Operation): - return item - get = (lambda key, default=None: item.get(key, default)) if isinstance(item, dict) \ - else (lambda key, default=None: getattr(item, key, default)) - op_id = get("id") or get("ident") or get("name") or get("operation_id") - if not op_id: - raise BackendUnavailable("Bir operasyonun id alanı yok (SÖZLEŞME: id zorunlu).") - label = get("label") or get("title") or get("description") or op_id - category = get("category") or "Cihaz işlemleri" - if category not in CATEGORIES: - category = "Cihaz işlemleri" - scope = str(get("scope") or get("declared_scope") or "simple") - prompt = get("prompt") or "none" - destructive = bool(get("destructive") or get("is_destructive") or False) - # requires_root is left None unless the source actually states it, so the - # Operation property derives it from `destructive`. Reading the field - # with a False default marked every real operation read-only and the - # root gate never fired; the catalogue's `destructive` flag is the - # honest signal. An operation that states the value keeps that answer. - stated_root = None - if isinstance(item, dict): - if "requires_root" in item: - stated_root = bool(item["requires_root"]) - elif getattr(item, "requires_root", None) is not None: - stated_root = bool(item.requires_root) - return Operation( - id=str(op_id), - label=str(label), - category=str(category), - destructive=destructive, - scope=scope, - description=str(get("description") or ""), - playbook=str(get("playbook") or ""), - options=_prompt_options(str(prompt)), - requires_root=stated_root, - needs_dealer=bool(get("needs_dealer") or False), - ) - - # -- envanter ---------------------------------------------------------- - def list_operations(self) -> tuple[Operation, ...]: - return self._catalogue - - def list_waves(self) -> tuple[str, ...]: - try: - present = self._inventory.waves_present() - except Exception: - present = () - return tuple(present) or WAVES - - def inventory_note(self) -> str: - note = self._local.inventory_note() - if self._inventory.path: - note = f"Envanter: {self._inventory.path} ({self._inventory.count_all()} cihaz)" - if not self._have_ansible(): - note += ( - " | UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz " - "(sudo apt install ansible)." - ) - return note - - def _have_ansible(self) -> bool: - checker = getattr(getattr(self._runner, "Runner", None), "have_ansible", None) - try: - if callable(checker): - return bool(checker()) - except Exception: - pass - return shutil.which("ansible-playbook") is not None - - def target_wave(self, operation: Operation, target: Target | None) -> str | None: - if target is None or not target.value: - return None - try: - resolved = self._resolve_target(target, operation) - except CommandError: - return None - return resolved.wave or None - - def validate_target(self, operation: Operation, target: Target) -> str | None: - try: - self._resolve_target(target, operation) - except CommandError as exc: - return str(exc) - return None - - def _resolve_target(self, target: Target, operation: Operation): - """Modulun resolve_target'i ile hedefi cozer (Refused -> CommandError).""" - raw_op = self._raw_ops.get(operation.id) - mode = "any" - if raw_op is not None: - mode = getattr(raw_op, "target_mode", "any") - try: - return self._ops.resolve_target(target.value, mode, self._inventory) - except Exception as exc: - raise self._refusal(exc) from exc - - @staticmethod - def _refusal(exc: Exception) -> CommandError: - """Modulun Refused hatasini (mesaj + ayrinti) Turkce CommandError'a cevirir.""" - message = getattr(exc, "message", None) or str(exc) - detail = getattr(exc, "detail", "") or "" - text = f"{message}\n{detail}".strip() if detail else str(message) - if isinstance(exc, CommandError): - return exc - return CommandError(text) - # -- kapilar ----------------------------------------------------------- - def gate_steps( - self, operation: Operation, target: Target | None - ) -> tuple[GateStepInfo, ...]: - """Modulun confirmation_plan'i ile ayni kapi kurallari. - - confirmation_plan yoksa/çağrılamazsa yerel ayna (CatalogueBackend) - kullanilir: kapi sormamak gibi sessiz bir davranis ASLA secilmez. - """ - if operation.id not in self._fleet_ids or target is None or not target.value: - return self._local.gate_steps(operation, target) - planner = getattr(self._ops, "confirmation_plan", None) - raw_op = self._raw_ops.get(operation.id) - if not callable(planner) or raw_op is None: - return self._local.gate_steps(operation, target) - try: - resolved = self._resolve_target(target, operation) - gate_supported = False - plays = getattr(self._repo, "playbook_uses", None) - if callable(plays): - gate_supported = bool(plays(raw_op.playbook, "wave_gate_confirmed")) - steps = planner( - raw_op, raw_op.declared_scope, resolved, - wave_gate_supported=gate_supported, - ) - except Exception: - return self._local.gate_steps(operation, target) - infos: list[GateStepInfo] = [] - for step in steps: - infos.append( - GateStepInfo( - kind=str(getattr(step, "kind", "approval")), - key=str(getattr(step, "key", "")), - prompt=str(getattr(step, "prompt", "")), - message=str(getattr(step, "message", "")), - token=str(getattr(step, "token", "")), - unlocks=tuple(getattr(step, "unlocks", ()) or ()), - ) - ) - return tuple(infos) - - def _gates(self, grants: Mapping[str, bool]): - """Onaylanan bayraklari modulun GatesGranted tipine cevirir.""" - gates_cls = getattr(self._ops, "GatesGranted", None) - if gates_cls is None: - return None - fields = getattr(gates_cls, "__dataclass_fields__", {}) - kwargs = {name: bool(value) for name, value in grants.items() if name in fields} - try: - return gates_cls(**kwargs) - except Exception: - return None - - # -- komut uretimi ----------------------------------------------------- - def build_command( - self, - operation: Operation, - target: Target | None = None, - options: Mapping[str, str] | None = None, - grants: Mapping[str, bool] | None = None, - check_mode: bool = False, - ) -> CommandPlan: - opts = dict(options or {}) - granted = dict(grants or {}) - if operation.id in self._fleet_ids: - return self._build_fleet_plan(operation, target, opts, granted, check_mode) - for builder in (self._build_install_plan, self._build_tool_plan, - self._build_report_plan): - plan = builder(operation, target, opts, check_mode) - if plan is not None: - return plan - # Modulde karsiligi olmayan yerel komut/salt-okunur ekranlar. - return self._local.build_command(operation, target, opts, granted, check_mode) - - def _build_fleet_plan( - self, - operation: Operation, - target: Target | None, - opts: Mapping[str, str], - grants: Mapping[str, bool], - check_mode: bool, - ) -> CommandPlan: - raw_op = self._raw_ops.get(operation.id) - if raw_op is None: - raise CommandError( - f"'{operation.id}' operasyonunun tanımı backend'de bulunamadı." - ) - if target is None or not target.value: - raise CommandError( - "Hedef seçilmedi; komut üretilmedi. Bayi numarası veya dalga grubu seçin." - ) - if not self._inventory.path: - raise CommandError( - "Ansible envanteri bulunamadı; filo işlemleri komutu üretilemiyor. " - "Beklenen yol: ansible/inventory/hosts.yml (veya --inventory PATH)." - ) - verify = getattr(self._repo, "verify_scope", None) - if callable(verify): - # Playbook kendi kapisinin kaynagidir; tablo ile uyusmazsa REDDET. - try: - verify(raw_op) - except Exception as exc: - raise self._refusal(exc) from exc - try: - resolved = self._resolve_target(target, operation) - extra = self._ops.build_extra_vars(raw_op.prompt, opts) - argv = self._ops.build_ansible_command( - self._repo, self._inventory.path, raw_op, raw_op.declared_scope, - resolved, extra_vars=extra, gates=self._gates(grants), - check_mode=check_mode, - ) - except Exception as exc: - raise self._refusal(exc) from exc - - notes: list[str] = [] - if raw_op.declared_scope in ("wave", "release"): - notes.append( - "Dalga/sürüm kapısı: yalnız önceki dalga sağlık kapısını ve merkez " - "onayını geçtiyse çalıştırın (docs/10)." - ) - if not grants.get("wave_gate_confirmed") and _declares( - self._repo, raw_op.playbook, "wave_gate_confirmed" - ): - notes.append( - "Not: wave_gate_confirmed bayrağı yalnız sürüm kapısı onayı verilirse " - "komuta eklenir." - ) - if check_mode: - notes.append("--check: playbook hiçbir değişiklik uygulamaz (deneme koşusu).") - return CommandPlan( - commands=(tuple(argv),), - destructive=bool(raw_op.destructive), - requires_root=False, - operation_id=operation.id, - target_label=str(getattr(resolved, "label", target.label)), - notes=tuple(notes), - dry_run=self.ctx.dry_run, - check_mode=check_mode, - ) - - def _build_install_plan( - self, operation: Operation, target: Target | None, - opts: Mapping[str, str], check_mode: bool, - ) -> CommandPlan | None: - if not operation.id.startswith("install-"): - return None - builder = getattr(self._ops, "build_install_command", None) - if not callable(builder): - return None - if operation.id in ("install-state", "install-log"): - return None - dealer = "" - if operation.needs_dealer: - if target is None or target.kind != "dealer" or not target.value: - raise CommandError( - f"'{operation.label}' için bayi numarası gerekir " - "(hedef alanına 8 haneli bayi no yazın)." - ) - problem = dealer_error(target.value) - if problem: - raise CommandError(problem) - dealer = target.value - try: - installer = self._repo.installer_path() - except Exception as exc: - raise self._refusal(exc) from exc - check = operation.id == "install-preflight" or check_mode - try: - argv = builder( - installer, - dealer, - offline=opts.get("offline") == "1", - resume=operation.id == "install-resume" or opts.get("resume") == "1", - only=(opts.get("only") or "").strip(), - check_mode=check, - ) - except Exception as exc: - raise self._refusal(exc) from exc - notes = [f"Hedef cihaz kimliği: {dealer_to_device_id(dealer)} ({dealer_to_host(dealer)})."] - if operation.id == "install-preflight": - notes.append("Ön kontrol hiçbir şey yazmaz (log/state/dizin oluşturmaz).") - if check: - notes.append("--check: hiçbir değişiklik uygulanmaz.") - return CommandPlan( - commands=(tuple(argv),), - destructive=operation.destructive, - requires_root=operation.needs_root, - operation_id=operation.id, - target_label=f"cihaz {dealer_to_device_id(dealer)} ({dealer_to_host(dealer)})", - notes=tuple(notes), - dry_run=self.ctx.dry_run, - check_mode=check, - ) - - def _build_tool_plan( - self, operation: Operation, target: Target | None, - opts: Mapping[str, str], check_mode: bool, - ) -> CommandPlan | None: - entry = self._TOOL_OPS.get(operation.id) - if entry is None: - return None - builder = getattr(self._ops, "build_tool_command", None) - tool, args = entry - try: - path = self._repo.tool_path(tool) - except Exception as exc: - raise self._refusal(exc) from exc - if not callable(builder): - return None - check = tool.startswith("bf-check-") or check_mode - argv = builder(path, list(args), check_mode=check) - notes: list[str] = [] - if operation.id == "tools-support-bundle": - notes.append("Destek paketi parola, private key veya token içermez.") - return CommandPlan( - commands=(tuple(argv),), - destructive=operation.destructive, - requires_root=operation.needs_root, - operation_id=operation.id, - target_label="bu cihaz (yerel)", - notes=tuple(notes), - dry_run=self.ctx.dry_run, - check_mode=check, - ) - - def _build_report_plan( - self, operation: Operation, target: Target | None, - opts: Mapping[str, str], check_mode: bool, - ) -> CommandPlan | None: - """Modulun salt-okunur ekran ureticileri (komut CALISTIRILMAZ).""" - lines = self._module_report_lines(operation) - if lines is None: - return None - return CommandPlan( - report_lines=tuple(lines), - destructive=False, - operation_id=operation.id, - target_label="bu cihaz (yerel)", - notes=("Salt-okunur ekran: hiçbir komut çalıştırılmadı.",), - dry_run=self.ctx.dry_run, - ) - - def _module_report_lines(self, operation: Operation) -> tuple[str, ...] | None: - ops = self._ops - try: - if operation.id == "central-targets" and hasattr(ops, "targets_lines"): - return tuple(ops.targets_lines(self._inventory)) - if operation.id == "central-placeholders" and hasattr( - ops, "central_readiness_lines" - ): - return tuple(ops.central_readiness_lines(self._repo, self._inventory)) - if operation.id == "central-endpoints" and hasattr(ops, "central_endpoint_lines"): - return tuple(ops.central_endpoint_lines(self._repo)) - if operation.id == "central-checklist" and hasattr(ops, "central_checklist_lines"): - return tuple(ops.central_checklist_lines()) - if operation.id == "info-identity" and hasattr(ops, "identity_lines"): - return tuple(ops.identity_lines()) - if operation.id == "info-state" and hasattr(ops, "state_lines"): - return tuple(ops.state_lines()) - if operation.id == "info-summary" and hasattr(ops, "environment_lines"): - lines = list( - ops.environment_lines( - self._repo, str(self.ctx.console_log), self._inventory, - self.ctx.dry_run, False, - ) - ) - if hasattr(ops, "state_lines"): - lines += [""] - lines += list(ops.state_lines()) - return tuple(lines) - except Exception: - # Rapor uretilemedi: yerel (katalog) yoluna dusulur, hata gizlenmez. - return None - return None - # -- calistirma -------------------------------------------------------- - def run( - self, - plan: CommandPlan, - on_line: Callable[[str], None] | None = None, - cancel: threading.Event | None = None, - ) -> RunResult: - """Komutlari canli ciktiyla calistirir; denetim kaydi modul uzerinden. - - Not: bfos.runner.Runner komutu kendi stdio'suyla calistirir (TUI icin - dogru olan davranis budur) ve ciktiyi dondurmez; GUI metin kutusuna - satir satir akis icin buranin streaming calistiricisi kullanilir. - Denetim kaydi, modulun AuditLog'u varsa onun uzerinden yazilir, boylece - GUI ile TUI ayni bicimde kayit tutar. - """ - audit = self._audit_log() - redact = getattr(self._runner, "redact", None) - command_text = " && ".join(shlex.join(argv) for argv in plan.commands) - if callable(redact): - try: - command_text = str(redact(command_text)) - except Exception: - pass - if audit is not None: - self._audit_write(audit, self._audit_level("AUDIT_RUN", "RUN"), command_text) - result = run_plan(plan, on_line=on_line, cancel=cancel, cwd=str(self.ctx.repo_root)) - if audit is not None: - level = self._audit_level("AUDIT_OK", "OK") if result.ok \ - else self._audit_level("AUDIT_FAIL", "FAIL") - self._audit_write(audit, level, f"exit={result.exit_code} :: {command_text}") - return result - - def _audit_log(self): - """bfos.runner.AuditLog (varsa) -- kurulamazsa None (kayit sessizce atlanir).""" - factory = getattr(self._runner, "AuditLog", None) - if factory is None: - return None - try: - return factory() - except Exception: - return None - - @staticmethod - def _audit_write(audit, level: str, message: str) -> None: - try: - audit.write(level, message) - except Exception: - pass - - def _audit_level(self, name: str, fallback: str) -> str: - config = getattr(self._ops, "config", None) - return str(getattr(config, name, fallback) or fallback) - - -def _declares(repo, playbook: str, var: str) -> bool: - """Playbook VAR'i kullaniyor mu? (modulun okuma yolu varsa onu kullanir.)""" - checker = getattr(repo, "playbook_uses", None) - if not callable(checker): - return False - try: - return bool(checker(playbook, var)) - except Exception: - return False - - -#: Modulun prompt turleri (bfos.operations PROMPT_*) icin arayuz secenekleri. -#: Anahtarlar modulun build_extra_vars() cevap anahtarlariyla AYNI olmalidir. -_PROMPT_OPTION_SPECS: Mapping[str, tuple[OptionSpec, ...]] = { - "none": (), - "packages": ( - OptionSpec( - "packages", - "Paket pinleri (paket=sürüm, virgülle)", - required=True, - hint="Yalnız tam pin; 'latest' ve aralık kabul edilmez", - validator=validate_package_pins, - ), - ), - "service": ( - OptionSpec( - "service", - "Servis adı", - kind="choice", - choices=SERVICE_ALLOWLIST, - required=True, - hint="Yalnız izinli servis adları", - validator=validate_service_name, - ), - ), - "script": ( - OptionSpec( - "script", - "Script yolu", - required=True, - hint=f"İzinli dizinler: {', '.join(SCRIPT_DIRS)}", - validator=validate_script_path, - ), - OptionSpec("script_args", "Script argümanları", default="", hint="İsteğe bağlı"), - ), - "grace": ( - OptionSpec( - "grace", - "Yeniden başlatma bekleme süresi (saniye)", - kind="int", - default="300", - validator=validate_grace, - ), - ), - "disk_pct": ( - OptionSpec( - "disk_pct", - "Uyarı eşiği (%)", - kind="int", - default="", - hint="Boş = playbook varsayılanı (80)", - validator=validate_disk_pct, - ), - ), - "log_lines": ( - OptionSpec( - "log_lines", - "Cihaz başına journal satırı", - kind="int", - default="500", - validator=validate_log_lines, - ), - ), - "expected_version": ( - OptionSpec( - "expected_version", - "Beklenen Field OS sürümü", - default="", - hint="Boş = sapma (drift) denetimi atlanır", - validator=validate_expected_version, - ), - ), -} - - -def _prompt_options(prompt: str) -> tuple[OptionSpec, ...]: - return _PROMPT_OPTION_SPECS.get(prompt, ()) - - -def load_backend(ctx: GuiContext): - """Gercek modulleri dener; yoksa yerlesik katalog'a duser. - - Donus: (backend, not) -- `not` Turkce bilgi satiridir (durum cubugunda - gosterilir), hata DEGILDIR. - """ - add_repo_to_path(ctx.repo_root) - try: - from bfos import operations as operations_module # type: ignore - except Exception as exc: # ImportError ve beklenmeyen hatalar - return ( - CatalogueBackend(ctx), - f"bfos.operations bulunamadı ({type(exc).__name__}); yerleşik katalog " - "kullanılıyor (admin/bf-menu kuralları).", - ) - runner_module = None - try: - from bfos import runner as runner_module # type: ignore # noqa: F811 - except Exception: - runner_module = None - if runner_module is None: - return ( - CatalogueBackend(ctx), - "bfos.runner bulunamadı; komutlar yerleşik çalıştırıcıyla koşuluyor.", - ) - try: - backend = ModuleBackend(operations_module, runner_module, ctx) - except BackendUnavailable as exc: - return CatalogueBackend(ctx), f"UYARI: {exc}" - except Exception as exc: # beklenmeyen modul hatasi arayuzu kilitlemesin - return ( - CatalogueBackend(ctx), - f"UYARI: gerçek backend başlatılamadı ({type(exc).__name__}: {exc}); " - "yerleşik katalog kullanılıyor.", - ) - catalogue = len(backend.list_operations()) - fleet = len(backend._fleet_ids) # noqa: SLF001 - bilgi amacli sayim - return ( - backend, - f"Gerçek backend: bfos.operations + bfos.runner " - f"({fleet} filo işlemi, {catalogue - fleet} yerel işlem).", - ) - - -def add_repo_to_path(repo_root: Path) -> None: - root = str(repo_root) - if root not in sys.path: - sys.path.insert(0, root) - - -# --------------------------------------------------------------------------- -# Cikti paneli + canli akis -# --------------------------------------------------------------------------- -class OutputPane: - """Komut ciktisi kuyusu: salt-okunur GORUNEN, icerlek bir metin alani. - - Olculen sorun: alan duzenlenebilir gibi duruyordu ve ic bosluğu yoktu. Bu - yuzden alan bir kuyuya (``--bg-sunken``) oturur, 12px ic bosluk tasir, - ``cursor="arrow"`` ve ``takefocus=0`` ile "buraya yazilmaz" der; yazi tipi - veri yuzudur (monospace) ki cikis kodlari ve kimlikler hizalansin. - - Ciktinin ham hali KANITTIR, arayuz degil (docs/DESIGN.md kural 6): sonuc - once durum rozetinde gorunur, bu alan kaniti tutar ve gizlenebilir. - """ - - def __init__(self, parent, styles: Mapping[str, str], height: int = 14, - log_path: str = DEFAULT_CONSOLE_LOG): - self.styles = styles - self.frame = ttk.Frame(parent, style=styles["panel"]) - self.frame.pack(fill="both", expand=True) - - head = ttk.Frame(self.frame, style=styles["panel"]) - head.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, theme.SP_2)) - ttk.Label(head, text="Komut çıktısı", style=styles["panel_title"]).pack(side="left") - ttk.Label( - head, - text=f"salt-okunur · denetim kaydı: {log_path}", - style=styles["panel_faint"], - ).pack(side="right") - - self.well = ttk.Frame(self.frame, style=styles["sunken"]) - self.well.pack(fill="both", expand=True, - padx=theme.SP_3, pady=(0, theme.SP_3)) - self.text = tk.Text( - self.well, - height=height, - wrap="word", - font=theme.data_font(theme.SIZE_SM), - background=theme.BG_SUNKEN, - foreground=theme.TEXT, - relief="flat", - borderwidth=0, - highlightthickness=0, - padx=theme.SP_3, - pady=theme.SP_2, - cursor="arrow", - takefocus=0, - selectbackground=theme.ACCENT_SOFT, - selectforeground=theme.TEXT, - inactiveselectbackground=theme.ACCENT_SOFT, - ) - self.text.pack(side="left", fill="both", expand=True) - bar = ttk.Scrollbar(self.well, orient="vertical", command=self.text.yview) - self.text.configure(yscrollcommand=bar.set) - bar.pack(side="right", fill="y") - self.text.configure(state="disabled") - - self._queue: "queue.Queue[str]" = queue.Queue() - self._parent = parent - - def append(self, line: str) -> None: - """Herhangi bir is parcacigindan guvenle cagrilabilir (kuyruga yazar).""" - self._queue.put(line) - - def _insert(self, line: str) -> None: - self.text.configure(state="normal") - self.text.insert("end", line + "\n") - self.text.configure(state="disabled") - - def drain(self) -> None: - """Ana is parcaciginda kuyrugu bosaltir (after ile periyodik cagrilir).""" - wrote = False - while True: - try: - line = self._queue.get_nowait() - except queue.Empty: - break - self._insert(line) - wrote = True - if wrote: - self.text.see("end") - self._parent.after(120, self.drain) - - def clear(self) -> None: - self.text.configure(state="normal") - self.text.delete("1.0", "end") - self.text.configure(state="disabled") - - def write_now(self, line: str) -> None: - self.append(line) - self.drain_once() - - def drain_once(self) -> None: - while True: - try: - line = self._queue.get_nowait() - except queue.Empty: - break - self._insert(line) - self.text.see("end") - - # -- gorunurluk (kanit erisilebilir kalir, ekrani kaplamaz) ------------- - def set_visible(self, visible: bool) -> None: - """Cikti kuyusunu gosterir/gizler; panel basligi yerinde kalir.""" - if visible: - self.well.pack(fill="both", expand=True, - padx=theme.SP_3, pady=(0, theme.SP_3)) - else: - self.well.pack_forget() - - def is_visible(self) -> bool: - return bool(self.well.winfo_manager()) - - -# --------------------------------------------------------------------------- -# Arayuz -# --------------------------------------------------------------------------- -class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) - """Blueforce Field OS Turkce masaustu arayuzu. - - Yerlesim docs/DESIGN.md "Direction contract" yonergesine gore kurulur: - koyu bir baslik bandi (urun adi, hedef kimligi, cihaz durumu, yetki - seviyesi), altinda beyaz paneller halinde hedef cubugu, islem listesi ve - secenek paneli, bir kuyuda komut ciktisi ve tek bir eylem cubugu. Renk - yalnizca DURUM tasir; tum degerler bfos/ui_theme.py'den gelir (o da - web/static/tokens.css'i yansitir), bu dosyada sabit renk yoktur. - """ - - #: Etiket sutunu genisligi: hedef satiri ve secenek satirlari AYNI hizada - #: baslar (olculen sorun: tutarsiz etiket genislikleri ve sikisik satir). - FIELD_LABEL_WIDTH = 22 - - def __init__(self, root, backend, ctx: GuiContext, *, full_mode: bool = False): + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: self.root = root self.backend = backend self.ctx = ctx - self.cancel_event = threading.Event() - self.running = False - self.option_widgets: dict[str, tuple[str, Any]] = {} - self._panes: dict[str, tuple[Any, Any]] = {} - # Islem tanimlari BACKEND'den gelir (yerlesik katalog ya da gercek - # bfos.operations): secenek adlari backend ile ayni olmak zorundadir. - self._ops_by_id: dict[str, Operation] = { - op.id: op for op in backend.list_operations() - } - self._selected_operation: Operation | None = None - self._current_plan: CommandPlan | None = None - self._rows: dict[str, tuple[Operation, ...]] = {} - self._filters: dict[str, Any] = {} - self._output_visible = True - - # Tema her widget'tan ONCE uygulanir: ttk stilleri dogduklari anda - # gecerli olmali, sonradan boyamak gri bir pencere ve titreme uretir. self.styles = theme.apply_theme(root) - root.title(WINDOW_TITLE) - root.minsize(1040, 760) - root.columnconfigure(0, weight=1) - # Bantlar: baslik -> uyarilar -> hedef -> govde -> cikti -> eylem. - root.rowconfigure(3, weight=3) - root.rowconfigure(4, weight=2) + super().__init__(root, style=self.styles["frame"]) + self.pack(fill="both", expand=True) - self._build_header() - self._build_notices() - self._build_target_bar() - - self.body = ttk.Frame(root, style=self.styles["frame"]) - self.body.grid(row=3, column=0, sticky="nsew", - padx=theme.SP_4, pady=(theme.SP_3, 0)) - self.simple_frame = ttk.Frame(self.body, style=self.styles["frame"]) - self.detail_frame = ttk.Frame(self.body, style=self.styles["frame"]) - self._build_simple_mode(self.simple_frame) - self._build_detail_mode(self.detail_frame) - - output_area = ttk.Frame(root, style=self.styles["frame"]) - output_area.grid(row=4, column=0, sticky="nsew", - padx=theme.SP_4, pady=(theme.SP_3, 0)) - self.output = OutputPane(output_area, self.styles, height=14, - log_path=ctx.console_log) - self.output.drain() - self._build_footer() - self._apply_privilege_controls() - - self.show_mode(full=full_mode) - self._load_inventory() - - # -- kucuk yardimcilar ------------------------------------------------- - def _badge(self, parent, word: str, style: str): - """Sozcuklu rozet: renk TEK BASINA anlam tasimaz (kural 2).""" - return ttk.Label(parent, text=word, style=style) - - def _set_status(self, word: str, severity: str = "idle") -> None: - """Durum gostergesi: sozcuk + renk (duz gri bir etiket degil).""" - self.status.configure(text=word, style=theme.severity_style(severity)) - - # -- ust bilgi: koyu bant --------------------------------------------- - def _build_header(self) -> None: - """Baslik bandi: urun adi, hedef kimligi, cihaz durumu, yetki. - - Yetki seviyesi burada GORUNUR bir ogedir (rozet), gomulu bir cumle - degil: "bu pencereden ne yapabilirim?" sorusunun cevabi bir adim - geriden okunur (docs/DESIGN.md "Privilege"). - """ - band = ttk.Frame(self.root, style=self.styles["header"]) - band.grid(row=0, column=0, sticky="ew") - band.columnconfigure(2, weight=1) - - identity = ttk.Frame(band, style=self.styles["header"]) - identity.grid(row=0, column=0, sticky="w", - padx=theme.SP_4, pady=(theme.SP_3, theme.SP_1)) - ttk.Label(identity, text=WINDOW_TITLE, - style=self.styles["header_title"]).pack(anchor="w") - ttk.Label(identity, text=APP_SUBTITLE, - style=self.styles["header_meta"]).pack(anchor="w") - - target = ttk.Frame(band, style=self.styles["header"]) - target.grid(row=0, column=1, sticky="w", padx=(theme.SP_5, 0), - pady=(theme.SP_3, theme.SP_1)) - ttk.Label(target, text="HEDEF", style=self.styles["header_meta"]).pack(anchor="w") - self.header_target = ttk.Label(target, text="— seçilmedi", - style=self.styles["header_target"]) - self.header_target.pack(anchor="w") - - badges = ttk.Frame(band, style=self.styles["header"]) - badges.grid(row=0, column=3, sticky="e", - padx=theme.SP_4, pady=(theme.SP_3, theme.SP_1)) - - self._state_word = device_state_word() - state_row = ttk.Frame(badges, style=self.styles["header"]) - state_row.pack(anchor="e") - ttk.Label(state_row, text="Cihaz durumu", - style=self.styles["header_meta"]).pack(side="left") - self.state_badge = self._badge(state_row, self._state_word, - state_badge_style(self._state_word)) - self.state_badge.pack(side="left", padx=(theme.SP_2, 0)) - - self.privilege_level = privilege_level() - priv_row = ttk.Frame(badges, style=self.styles["header"]) - priv_row.pack(anchor="e", pady=(theme.SP_1, 0)) - ttk.Label(priv_row, text="Yetki", - style=self.styles["header_meta"]).pack(side="left") - self.privilege_badge = self._badge( - priv_row, privilege_word(self.privilege_level), - theme.PRIVILEGE_BADGE_STYLE[self.privilege_level]) - self.privilege_badge.pack(side="left", padx=(theme.SP_2, 0)) - if self.ctx.dry_run: - self._badge(priv_row, "DENEME KİPİ", - self.styles["badge_info"]).pack(side="left", padx=(theme.SP_2, 0)) - - def _build_notices(self) -> None: - """Yetki ve kip uyarilari: basligin hemen altinda renkli bir serit. - - Olculen sorun: kritik "Yonetici yetkisi yok" uyarisi monospace bir - paragrafta gomuluydu. Simdi yetki hem baslikta rozet, hem burada - sozcuk + renk; ustune duzene doken tam cumle. - """ - notices = ttk.Frame(self.root, style=self.styles["frame"]) - shown = False - if not is_root(): - # Soylenmezse operator kurulumu baslatir ve yari yolda yetki - # hatasiyla karsilasir; kapi bu yuzden acilista gorunur. - band = ttk.Frame(notices, style=self.styles["warn_band"]) - band.pack(fill="x") - inner = ttk.Frame(band, style=self.styles["warn_band"]) - inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) - ttk.Label(inner, text=privilege_word(PRIVILEGE_READ_ONLY), - style=self.styles["readonly"]).pack(side="left", - padx=(0, theme.SP_3)) - ttk.Label(inner, text=ROOT_REQUIRED_NOTICE, - style=self.styles["warn_label"]).pack(side="left") - shown = True - if self.ctx.dry_run: - band = ttk.Frame(notices, style=self.styles["info_band"]) - band.pack(fill="x", pady=(theme.SP_1, 0) if shown else (0, 0)) - inner = ttk.Frame(band, style=self.styles["info_band"]) - inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) - ttk.Label(inner, text="DENEME KİPİ", - style=self.styles["badge_info"]).pack(side="left", - padx=(0, theme.SP_3)) - ttk.Label(inner, text="Komutlar gösterilir, çalıştırılmaz.", - style=self.styles["info_label"]).pack(side="left") - shown = True - if shown: - notices.grid(row=1, column=0, sticky="ew", padx=theme.SP_4, - pady=(theme.SP_3, 0)) - - # -- hedef cubugu ------------------------------------------------------ - def _build_target_bar(self) -> None: - """Hedef paneli: grid ile hizali etiketler, tek ritim. - - Olculen sorun: satir sikisik ve etiket genislikleri tutarsizdi. Her - etiket ayni sutunda ve ayni sag kenara yaslanir; alanlar arasindaki - bosluk 4px ritmine gore verilir (SP_2 / SP_3 / SP_5). - """ - panel = ttk.Frame(self.root, style=self.styles["panel"]) - panel.grid(row=2, column=0, sticky="ew", padx=theme.SP_4, - pady=(theme.SP_3, 0)) - panel.columnconfigure(4, weight=1) - - ttk.Label(panel, text="HEDEF", style=self.styles["panel_title"]).grid( - row=0, column=0, sticky="w", padx=theme.SP_3, - pady=(theme.SP_2, 0)) - self.mode_button = ttk.Button(panel, text=SIMPLE_MODE_LABEL, - style=self.styles["button"], - command=self.toggle_mode) - self.mode_button.grid(row=0, column=4, sticky="e", padx=theme.SP_3, - pady=(theme.SP_2, 0)) - - self.dealer_var = tk.StringVar() - self.wave_var = tk.StringVar() - self.all_var = tk.IntVar(value=0) - - ttk.Label(panel, text="Bayi no (8 hane)", style=self.styles["field_label"], - width=16, anchor="e").grid( - row=1, column=0, sticky="e", padx=(theme.SP_3, theme.SP_2), - pady=theme.SP_2) - self.dealer_entry = ttk.Entry(panel, textvariable=self.dealer_var, - width=14, style=self.styles["entry"]) - self.dealer_entry.grid(row=1, column=1, sticky="w", pady=theme.SP_2) - self.dealer_entry.bind("", lambda _e: self._on_dealer_change()) - - ttk.Label(panel, text="Dalga", style=self.styles["field_label"], - width=8, anchor="e").grid( - row=1, column=2, sticky="e", padx=(theme.SP_5, theme.SP_2), - pady=theme.SP_2) - self.wave_combo = ttk.Combobox(panel, textvariable=self.wave_var, - values=list(WAVES), width=14, - state="readonly") - self.wave_combo.grid(row=1, column=3, sticky="w", pady=theme.SP_2) - self.wave_combo.bind("<>", - lambda _e: self._on_target_change()) - - ttk.Checkbutton(panel, text="Tüm filo (dikkat)", variable=self.all_var, - style=self.styles["panel_check"], - command=self._on_target_change).grid( - row=1, column=4, sticky="w", padx=theme.SP_5, pady=theme.SP_2) - - self.target_status = ttk.Label(panel, text="", - style=self.styles["panel_muted"]) - self.target_status.grid(row=2, column=0, columnspan=5, sticky="w", - padx=theme.SP_3, pady=(0, theme.SP_2)) - - # -- eylem cubugu ------------------------------------------------------ - def _build_footer(self) -> None: - """Eylem cubugu, IKI satir: eylem + durum, altinda sessiz araclar. - - Tek satira sigdirilan cubukta dugmeler birbirini sikistiriyor ve - sonuncusunun yazisi kirpiliyordu (olculdu: "Cikis" dugmesi "Ck" - gorunuyordu). Birincil eylem kendi satirini alir; yardimcilar ikinci - satirda durur, boylece hicbir kontrol digerinin yerini yemez. - """ - footer = ttk.Frame(self.root, style=self.styles["frame"]) - footer.grid(row=5, column=0, sticky="ew", padx=theme.SP_4, - pady=(theme.SP_3, theme.SP_4)) - footer.columnconfigure(0, weight=1) - - action = ttk.Frame(footer, style=self.styles["frame"]) - action.grid(row=0, column=0, sticky="ew") - self.run_button = ttk.Button(action, text=RUN_BUTTON_LABEL, - style=self.styles["accent_button"], - command=self._on_run_clicked) - self.run_button.pack(side="left") - # Kapali bir yazma isleminin NEDENI, dugmenin yanindaki ayni satirda - # durur (docs/DESIGN.md "Privilege": gizleme yok, nedeni goster). - self.run_reason = ttk.Label(action, text="", style=self.styles["app_warn"]) - self.run_reason.pack(side="left", padx=(theme.SP_3, 0)) - self.status = ttk.Label(action, text="HAZIR", style=self.styles["status"]) - self.status.pack(side="right") - - tools = ttk.Frame(footer, style=self.styles["frame"]) - tools.grid(row=1, column=0, sticky="ew", pady=(theme.SP_2, 0)) - ttk.Button(tools, text="Çıktıyı temizle", style=self.styles["button"], - command=self._clear_output).pack(side="left") - self.cancel_button = ttk.Button(tools, text="Çalışanı iptal et", - style=self.styles["button"], - command=self._cancel, state="disabled") - self.cancel_button.pack(side="left", padx=(theme.SP_2, 0)) - self.check_var = tk.IntVar(value=0) - ttk.Checkbutton(tools, text="Yalnız denetim (--check)", - variable=self.check_var, - style=self.styles["check"]).pack(side="left", - padx=(theme.SP_3, 0)) - self.output_button = ttk.Button(tools, text=OUTPUT_HIDE_LABEL, - style=self.styles["quiet_button"], - command=self._toggle_output) - self.output_button.pack(side="left", padx=(theme.SP_2, 0)) - - # Tek cikis kontrolu: burada (detayli mod) ya da basit modda ucuncu - # buyuk dugme olarak. Ikisi AYNI ANDA gorunmez (olculen sorun: "Cik" - # iki kez duruyordu). - self.exit_button = ttk.Button(tools, text="Çıkış", - style=self.styles["button"], - command=self.root.destroy) - - # Depo ve katalog kaynagi: sessiz bir satir. Baslik bandini token - # yuksekliginde (56px) tutmak icin banddan asagi alindi ve hicbir - # dugmeyle yer yarisi yapmamasi icin kendi satirina konuldu: depo yolu - # okunmasi gereken bir bilgi degil, gorunur kalmasi yeter. - ttk.Label( - footer, - text=f"Depo: {self.ctx.repo_root} · {self.backend.name}", - style=self.styles["faint"], - ).grid(row=2, column=0, sticky="w", pady=(theme.SP_2, 0)) - - # -- basit mod --------------------------------------------------------- - def _build_simple_mode(self, parent) -> None: - """Basit mod: iki is karari ve TEK cikis dugmesi. - - Cikis burada ucuncu buyuk dugmedir; alt cubuktaki kucuk "Cikis" - dugmesi bu modda gizlenir, boylece ayni eylem iki kez durmaz. - """ - box = ttk.Frame(parent, style=self.styles["panel"]) - box.pack(fill="both", expand=True) - inner = ttk.Frame(box, style=self.styles["panel"]) - inner.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_6) - ttk.Label(inner, text="Ne yapmak istiyorsunuz?", - style=self.styles["section"]).pack(anchor="w", - pady=(0, theme.SP_4)) - for label in SIMPLE_MODE_BUTTONS: - command = { - "Kur": self._simple_install, - "Durum": self._simple_status, - "Çıkış": self.root.destroy, - }[label] - # Aksan yalnizca "operatorun geldigi eylem" icin: Kur. - style_name = (self.styles["big_accent_button"] if label == "Kur" - else self.styles["big_button"]) - button = ttk.Button(inner, text=label, command=command, - style=style_name, width=18) - button.pack(fill="x", pady=theme.SP_2) - if label == "Kur": - self.install_button = button - ttk.Label( - inner, - text=( - "Kur: bu cihazı kurar (bayi no gerekir). Durum: salt-okunur özet.\n" - "Daha fazlası için üstteki '" + SIMPLE_MODE_LABEL + "' düğmesini kullanın." - ), - style=self.styles["panel_muted"], - justify="left", - ).pack(anchor="w", pady=(theme.SP_4, 0)) - self._apply_privilege_controls() - - def _simple_install(self) -> None: - choice = self._choice_dialog( - "Kurulum", - "Bu cihazda ne yapılacak?\n\n" - "Ön kontrol (--check) hiçbir şey yazmaz; kurulum cihazı değiştirir.", - (("Ön kontrol (--check)", "install-preflight"), - ("Kurulumu başlat", "install-local"), - ("Vazgeç", None)), - ) - if not choice: - return - operation = self._ops_by_id.get(choice) - if operation is not None: - self._execute(operation) - - def _simple_status(self) -> None: - operation = self._ops_by_id.get("info-summary") - if operation is None: - return - self._execute(operation) - - # -- detayli mod ------------------------------------------------------- - def _build_detail_mode(self, parent) -> None: - """Detayli mod: ozet serit + bes kategori sekmesi. - - Ozet seritte TEK buyuk rakam var (islem sayisi): DESIGN.md kural 3, - "her gorunumde tek yuksek sesli rakam". Geri kalan her sey sessiz ve - hizali. Yazma islemlerinin sayisi ayrica durur cunku "kac islem cihazi - degistirir?" sorusu bu ekranda sorulur. - """ - strip = ttk.Frame(parent, style=self.styles["frame"]) - strip.pack(fill="x", pady=(0, theme.SP_3)) - operations = tuple(self._ops_by_id.values()) - writes = sum(1 for op in operations if operation_is_write(op)) - ttk.Label(strip, text=str(len(operations)), - style=self.styles["figure"]).pack(side="left") - ttk.Label(strip, text="işlem", style=self.styles["muted"]).pack( - side="left", padx=(theme.SP_1, theme.SP_4)) - ttk.Label(strip, text=f"{writes} yıkıcı", style=self.styles["muted"]).pack( - side="left", padx=(0, theme.SP_3)) - ttk.Label(strip, text=f"{len(operations) - writes} salt-okunur", - style=self.styles["muted"]).pack(side="left") - if self.privilege_level == PRIVILEGE_READ_ONLY: - ttk.Label(strip, text=f"yazma işlemleri kapalı · {NON_ROOT_DISABLED_HINT}", - style=self.styles["app_warn"]).pack(side="right") - - for category in CATEGORIES: - # Satirlar backend'den gelir (katalog sirasi korunur); backend bir - # kategoriyi bos birakirsa yerlesik tabloya dusulur. - self._rows[category] = tuple( - op for op in self._ops_by_id.values() if op.category == category - ) or operations_by_category(category) - - self.notebook = ttk.Notebook(parent) - self.notebook.pack(fill="both", expand=True) - for category in CATEGORIES: - frame = ttk.Frame(self.notebook, style=self.styles["frame"]) - self.notebook.add(frame, text=category) - self._build_category_tab(frame, category) - - def _build_category_tab(self, frame, category: str) -> None: - """Sekme: solda islem listesi (suzgecli), sagda secenekler paneli.""" - frame.columnconfigure(0, weight=3) - frame.columnconfigure(1, weight=2) - frame.rowconfigure(0, weight=1) - - left = ttk.Frame(frame, style=self.styles["panel"]) - left.grid(row=0, column=0, sticky="nsew", padx=(0, theme.SP_3)) - left.columnconfigure(0, weight=1) - left.rowconfigure(2, weight=1) - - head = ttk.Frame(left, style=self.styles["panel"]) - head.grid(row=0, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, - pady=(theme.SP_3, theme.SP_2)) - ttk.Label(head, text="İŞLEMLER", style=self.styles["panel_title"]).pack(side="left") - ttk.Label(head, text=f"{len(self._rows[category])} işlem", - style=self.styles["panel_faint"]).pack(side="right") - - filt = ttk.Frame(left, style=self.styles["panel"]) - filt.grid(row=1, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, - pady=(0, theme.SP_2)) - ttk.Label(filt, text="Süz:", style=self.styles["field_label"], - width=5, anchor="e").pack(side="left", padx=(0, theme.SP_2)) - query = tk.StringVar() - self._filters[category] = query - entry = ttk.Entry(filt, textvariable=query, style=self.styles["entry"]) - entry.pack(side="left", fill="x", expand=True) - entry.bind("", lambda _e, c=category: self._render_rows(c)) - - columns = ("islem", "kapsam", "tur", "durum") - tree = ttk.Treeview(left, columns=columns, show="headings", height=14) - tree.heading("islem", text="İşlem") - tree.heading("kapsam", text="Kapsam") - tree.heading("tur", text="Tür") - tree.heading("durum", text="Durum") - tree.column("islem", width=320, anchor="w") - tree.column("kapsam", width=200, anchor="w") - tree.column("tur", width=100, anchor="center") - tree.column("durum", width=150, anchor="center") - tree.grid(row=2, column=0, sticky="nsew") - scroll = ttk.Scrollbar(left, orient="vertical", command=tree.yview) - tree.configure(yscrollcommand=scroll.set) - scroll.grid(row=2, column=1, sticky="ns") - tree.bind( - "<>", - lambda _e, t=tree, c=category: self._on_operation_selected(t, c), - ) - # Yazma islemleri listede KALIR (gizlenmez): tur sutunu "YIKICI" yazar - # ve yetkisiz oturumda durum sutunu "KAPALI — sudo bf" der. - tree.tag_configure("destructive", foreground=theme.DANGER) - tree.tag_configure("blocked", foreground=theme.IDLE) - setattr(self, f"_tree_{category}", tree) - self._render_rows(category) - - right = ttk.Frame(frame, style=self.styles["panel"]) - right.grid(row=0, column=1, sticky="nsew") - right.columnconfigure(0, weight=1) - ttk.Label(right, text="SEÇENEKLER VE AÇIKLAMA", - style=self.styles["panel_title"]).grid( - row=0, column=0, sticky="w", padx=theme.SP_3, - pady=(theme.SP_3, theme.SP_2)) - detail_label = ttk.Label(right, text="Bir işlem seçin.", - style=self.styles["panel_label"], - wraplength=420, justify="left") - detail_label.grid(row=1, column=0, sticky="w", padx=theme.SP_3) - options_frame = ttk.Frame(right, style=self.styles["panel"]) - options_frame.grid(row=2, column=0, sticky="ew", padx=theme.SP_3, - pady=theme.SP_3) - options_frame.columnconfigure(1, weight=1) - # Sekme basina ayri secenek alani: her sekme kendi islemini gosterir. - self._panes = getattr(self, "_panes", {}) - self._panes[category] = (detail_label, options_frame) - ttk.Label( - right, - text="Komut her zaman önce gösterilir ve onaysız çalıştırılmaz.", - style=self.styles["panel_muted"], - wraplength=420, - justify="left", - ).grid(row=3, column=0, sticky="w", padx=theme.SP_3, - pady=(0, theme.SP_3)) - - def _render_rows(self, category: str) -> None: - """Liste satirlarini (varsa suzgece gore) yeniden cizer.""" - tree = getattr(self, f"_tree_{category}") - query = self._filters[category].get().strip().casefold() - selected = tree.selection() - for item in tree.get_children(): - tree.delete(item) - for op in self._rows[category]: - if query and query not in f"{op.label} {op.id}".casefold(): - continue - blocked = write_blocked_reason(op) is not None - tags = ("destructive",) if op.destructive else () - if blocked: - tags += ("blocked",) - tree.insert( - "", "end", iid=op.id, - values=( - op.label, - SCOPE_LABELS.get(op.scope, op.scope), - TYPE_WRITE_LABEL if op.destructive else TYPE_READ_LABEL, - BLOCKED_LABEL if blocked else AVAILABLE_LABEL, - ), - tags=tags, - ) - for item in selected: - if tree.exists(item): - tree.selection_set(item) - break - - def _on_operation_selected(self, tree, category: str) -> None: - selection = tree.selection() - if not selection: - return - operation = self._ops_by_id.get(selection[0]) - if operation is None: - return - self._selected_operation = operation - self._render_options(operation, category) - self._refresh_run_control() - - def _operation_summary(self, operation: Operation) -> str: - """Secili islemin ekranda gorunen ozeti (kapsam + yetki + tur).""" - text = operation.label + "\n\n" + (operation.description or "Açıklama yok.") - if operation.playbook: - text += f"\n\nAnsible playbook: {PLAYBOOK_DIR_REL}/{operation.playbook}" - text += f"\nKapsam: {SCOPE_LABELS.get(operation.scope, operation.scope)}" - text += ("\nYetki: root gerekir (sudo bf)" if operation.needs_root - else "\nYetki: gerekmez (salt-okunur)") - if operation.destructive: - text += "\n\nBU İŞLEM YIKICIDIR: iki kez onay istenir." - return text - - def _render_options(self, operation: Operation, category: str) -> None: - """Secenekleri hizali bir izgaraya yerlestirir (etiketler ayni hizada).""" - detail_label, options_frame = self._panes[category] - for child in options_frame.winfo_children(): + def clear(self) -> None: + for child in self.winfo_children(): child.destroy() - self.option_widgets = {} - detail_label.configure(text=self._operation_summary(operation)) - row = 0 - for spec in operation.options: - ttk.Label(options_frame, text=spec.label + ":", - style=self.styles["field_label"], - width=self.FIELD_LABEL_WIDTH, anchor="e").grid( - row=row, column=0, sticky="e", padx=(0, theme.SP_2), - pady=theme.SP_1) - if spec.kind == "bool": - var = tk.BooleanVar(value=spec.default == "1") - widget = ttk.Checkbutton(options_frame, variable=var, - style=self.styles["panel_check"]) - self.option_widgets[spec.name] = ("bool", var) - elif spec.kind == "choice": - var = tk.StringVar(value=spec.default or (spec.choices[0] if spec.choices else "")) - widget = ttk.Combobox(options_frame, textvariable=var, - values=list(spec.choices), width=24, - state="readonly") - self.option_widgets[spec.name] = ("str", var) - else: - var = tk.StringVar(value=spec.default) - widget = ttk.Entry(options_frame, textvariable=var, width=26) - self.option_widgets[spec.name] = ("str", var) - widget.grid(row=row, column=1, sticky="w", pady=theme.SP_1) - if spec.hint: - ttk.Label(options_frame, text=spec.hint, - style=self.styles["panel_faint"], wraplength=200, - justify="left").grid(row=row, column=2, sticky="w", - padx=(theme.SP_2, 0)) - row += 1 - badges = ttk.Frame(options_frame, style=self.styles["panel"]) - self._badge(badges, - TYPE_WRITE_LABEL if operation.destructive else TYPE_READ_LABEL, - self.styles["badge_destructive"] if operation.destructive - else self.styles["badge_readonly"]).pack(side="left") - if write_blocked_reason(operation): - self._badge(badges, BLOCKED_LABEL, - self.styles["badge_closed"]).pack(side="left", - padx=(theme.SP_2, 0)) - badges.grid(row=row, column=0, columnspan=3, sticky="w", - pady=(theme.SP_2, 0)) + def title_row(self, title: str, subtitle: str) -> ttk.Frame: + row = ttk.Frame(self, style=self.styles["frame"]) + row.pack(fill="x", padx=theme.SP_6, pady=(theme.SP_5, theme.SP_3)) + ttk.Label(row, text=title, style=self.styles["section"]).pack(anchor="w") + ttk.Label(row, text=subtitle, style=self.styles["panel_muted"]).pack(anchor="w") + return row - # -- hedef ------------------------------------------------------------- - def _load_inventory(self) -> None: - waves = self.backend.list_waves() - self.wave_combo.configure(values=list(waves)) - if waves: - self.wave_var.set(waves[0]) - note = self.backend.inventory_note() - self.output.write_now(f"[i] {note}") - self._set_status("HAZIR", "idle") - self._on_target_change() - def _on_dealer_change(self) -> None: - value = self.dealer_var.get().strip() - problem = dealer_error(value) if value else None - # Alanin kendisi de durumu gosterir: gecersiz giris cercevede. - self.dealer_entry.configure( - style=self.styles["invalid_entry"] if problem else self.styles["entry"] +class ModePicker(SurfaceFrame): + """Neutral launcher: choosing a mode is the only shared interaction.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + super().__init__(root, backend, ctx) + root.title("Blueforce Field OS — çalışma alanı") + root.geometry("980x620") + root.minsize(760, 520) + self._render() + + def _active_identity(self) -> str: + return f"Aktif kullanıcı: {self.ctx.active_user}" if self.ctx.active_user else "Aktif kullanıcı ayarlanmadı" + + def _render(self) -> None: + self.title_row( + "Blueforce Field OS", + self._active_identity() + " · Çalışacağınız yüzeyi seçin.", ) - if problem: - self.target_status.configure(text=problem, - style=self.styles["panel_danger"]) - elif value: - self.target_status.configure( - text=f"Bayi no geçerli: {dealer_to_device_id(value)}", - style=self.styles["panel_ok"]) - self._set_status("HAZIR", "idle") - else: - self.target_status.configure(text="", style=self.styles["panel_muted"]) - self._update_header_target() + body = ttk.Frame(self, style=self.styles["frame"]) + body.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_3) + body.columnconfigure(0, weight=1) + body.columnconfigure(1, weight=1) - def _on_target_change(self) -> None: - if self.all_var.get(): - self.target_status.configure( - text="Tüm filo seçildi: dalga kapılı işlemler dalga dalga ilerler.", - style=self.styles["panel_muted"], - ) - self.dealer_entry.configure(style=self.styles["entry"]) - else: - self._on_dealer_change() - self._update_header_target() - - def _update_header_target(self) -> None: - """Basliktaki hedef kimligi her secim degisikliginde guncellenir.""" - target = self.current_target() - if target is None or (target.kind == "dealer" - and not validate_dealer(target.value)): - self.header_target.configure(text="— seçilmedi") - return - self.header_target.configure(text=target.label) - - def current_target(self) -> Target | None: - if self.all_var.get(): - return Target("all", "all") - dealer = self.dealer_var.get().strip() - if dealer: - return Target("dealer", dealer) - wave = self.wave_var.get().strip() - if wave: - return Target("group", wave) - return None - - # -- yetki ve birincil eylem ------------------------------------------ - def _apply_privilege_controls(self) -> None: - """Yetkisiz oturum: yazma dugmeleri KAPALI, nedeni dugmenin uzerinde. - - Gizleme yok: yetkisiz operatorde "bu islem var ama kapali" bilgisi - gorunur kalmali, yoksa araci eksik sanir (docs/DESIGN.md "Privilege"). - """ - if hasattr(self, "install_button"): - if self.privilege_level == PRIVILEGE_READ_ONLY: - self.install_button.configure( - text="Kur — kapalı (yönetici yetkisi gerekir: sudo bf)", - state="disabled", - ) - else: - self.install_button.configure(text=SIMPLE_MODE_BUTTONS[0], - state="normal") - if hasattr(self, "run_button"): - self._refresh_run_control() - - def _refresh_run_control(self) -> None: - """Birincil eylem: aksan, yikici icin kirmizi, yetkisizde kapali.""" - operation = self._selected_operation - self.run_reason.configure(text="") - if operation is None: - self.run_button.configure(text=RUN_BUTTON_LABEL, - style=self.styles["accent_button"], - state="normal") - return - reason = write_blocked_reason(operation) - if reason: - self.run_button.configure(text=RUN_BUTTON_LABEL + " — kapalı", - style=self.styles["button"], - state="disabled") - self.run_reason.configure(text=reason, style=self.styles["app_warn"]) - return - self.run_button.configure( - text=RUN_BUTTON_LABEL, - style=(self.styles["danger_button"] if operation.destructive - else self.styles["accent_button"]), - state="normal", + self._mode_card( + body, 0, "Kurulum konsolu", + "Yeni veya yeniden kurulacak tek cihaz için kontrollü, adım adım akış.", + ("Bayi numarası", "Ön kontrol", "Kurulum ve kayıt", "Canlı çıktı"), + "Kurulum alanına gir", + lambda: self._open(InstallerSurface), ) - if operation.destructive: - self.run_reason.configure(text="Yıkıcı işlem: iki kez onay istenir.", - style=self.styles["app_warn"]) - - def _toggle_output(self) -> None: - """Kanit panelini gizler/gosterir; sonuc durum rozetinde kalir.""" - self._output_visible = not self._output_visible - self.output.set_visible(self._output_visible) - self.output_button.configure( - text=OUTPUT_HIDE_LABEL if self._output_visible else OUTPUT_SHOW_LABEL + self._mode_card( + body, 1, "Uzaktan yönetim portalı", + "Envanterdeki bilgisayarları izleyin, seçin ve yetkili operasyonları çalıştırın.", + ("Filo görünümü", "Cihaz durumu", "Operasyon", "Denetim izi"), + "Portala gir", + lambda: self._open(RemotePortalSurface), ) + ttk.Label( + self, text="Modu sonradan üst çubuktan değiştirerek yeni bir çalışma alanı açabilirsiniz.", + style=self.styles["faint"], + ).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) - # -- mod degistirme ---------------------------------------------------- - def show_mode(self, *, full: bool) -> None: - self.simple_frame.pack_forget() - self.detail_frame.pack_forget() - if full: - self.detail_frame.pack(fill="both", expand=True) - self.mode_button.configure(text=SIMPLE_MODE_BACK_LABEL) - # Tek cikis kontrolu: detayli modda alt cubukta, basit modda ucuncu - # buyuk dugme olarak durur; ikisi ayni anda gorunmez. - self.exit_button.pack(side="right") - else: - self.simple_frame.pack(fill="both", expand=True) - self.mode_button.configure(text=SIMPLE_MODE_LABEL) - self.exit_button.pack_forget() - self.full_mode = full - - def toggle_mode(self) -> None: - self.show_mode(full=not self.full_mode) - - # -- calistirma akisi -------------------------------------------------- - def _on_run_clicked(self) -> None: - if self._selected_operation is None: - messagebox.showinfo( - "Seçim gerekli", - "Önce listeden bir işlem seçin, sonra 'Komutu göster ve çalıştır' düğmesine basın.", - ) - return - self._execute(self._selected_operation) - - def _collect_options(self, operation: Operation) -> dict[str, str]: - options: dict[str, str] = {} - for spec in operation.options: - entry = self.option_widgets.get(spec.name) - if not entry: - continue - kind, variable = entry - if kind == "bool": - options[spec.name] = "1" if variable.get() else "0" - else: - options[spec.name] = str(variable.get()).strip() - return options - - def _execute(self, operation: Operation) -> None: - if self.running: - messagebox.showinfo("Bekleyin", "Zaten çalışan bir işlem var.") - return - target = self.current_target() if operation.is_fleet or operation.needs_dealer else None - if operation.needs_dealer and (target is None or target.kind != "dealer"): - messagebox.showerror( - "Bayi numarası gerekli", - "Bu işlem için bayi numarası girin (8 hane, örn. 12010193).", - ) - return - if target is not None and target.kind == "dealer": - problem = dealer_error(target.value) - if problem: - messagebox.showerror("Geçersiz bayi numarası", problem) - return - options = self._collect_options(operation) - check_mode = bool(self.check_var.get()) - try: - draft = self.backend.build_command( - operation, target, options, check_mode=check_mode - ) - except CommandError as exc: - messagebox.showerror("Komut üretilemedi", str(exc)) - return - except BackendUnavailable as exc: - messagebox.showerror("Backend hatası", str(exc)) - return - - # Salt-okunur rapor (komut yok): dogrudan cikti alanina yazilir. - if draft.is_report_only: - self._current_plan = draft - self._show_plan(draft, title=f"Salt-okunur ekran: {operation.label}") - self._start_run(draft) - return - - # 1) Taslak komut GORSTERILIR ve onaylanir: onaysiz hicbir sey calismaz. - if not self._confirm_plan(draft, operation, target, title="Komutu onaylayın"): - self.output.write_now("[i] Kullanıcı vazgeçti; komut çalıştırılmadı.") - return - - # 2) Acik kapilar: dalga adi, production, filo geneli, yikici ikinci onay. - grants = self._collect_grants(operation, target) - if grants is None: - self.output.write_now("[i] Kapı onayı verilmedi; işlem iptal edildi.") - return - - # 3) Kapilar eklendikten sonraki KESIN komut yeniden gosterilir. - try: - plan = self.backend.build_command( - operation, target, options, grants=grants, check_mode=check_mode - ) - except CommandError as exc: - messagebox.showerror("Komut üretilemedi", str(exc)) - return - if plan.lines != draft.lines: - if not self._confirm_plan( - plan, operation, target, - title="KESİN KOMUT (onayların eklendiği son hâl)", - ): - self.output.write_now("[i] Kesin komut onaylanmadı; işlem iptal edildi.") - return - - self._current_plan = plan - self._show_plan(plan, title=f"Çalıştırılıyor: {operation.label}") - self._start_run(plan) - - def _confirm_plan( - self, plan: CommandPlan, operation: Operation, - target: Target | None, title: str, - ) -> bool: - """Komutu gosteren onay penceresi (yikici islemde uyari vurgusu).""" - preview = preview_text(plan, operation=operation, target=target) - return bool( - messagebox.askyesno( - title, - preview + "\n\nBu komut çalıştırılsın mı?", - icon="warning" if plan.destructive else "question", - default="no" if plan.destructive else "yes", - ) - ) - - def _collect_grants( - self, operation: Operation, target: Target | None - ) -> dict[str, bool] | None: - """Backend'in istedigi acik onaylari sorar; iptalde None doner.""" - try: - steps = self.backend.gate_steps(operation, target) - except CommandError as exc: - messagebox.showerror("Kapı onayları alınamadı", str(exc)) - return None - grants: dict[str, bool] = {} + def _mode_card(self, parent: ttk.Frame, column: int, title: str, + description: str, steps: tuple[str, ...], action: str, + command) -> None: + card = ttk.Frame(parent, style=self.styles["panel"]) + card.grid(row=0, column=column, sticky="nsew", + padx=(0, theme.SP_3) if column == 0 else (theme.SP_3, 0)) + inner = ttk.Frame(card, style=self.styles["panel"]) + inner.pack(fill="both", expand=True, padx=theme.SP_5, pady=theme.SP_5) + ttk.Label(inner, text=title, style=self.styles["panel_title"]).pack(anchor="w") + ttk.Label(inner, text=description, style=self.styles["panel_label"], + wraplength=330, justify="left").pack(anchor="w", pady=(theme.SP_2, theme.SP_5)) for step in steps: - if step.kind == "token": - answer = self._token_dialog(step.prompt or "Onay", step.message, step.token) - if answer is None or answer.strip() != step.token: - return None - else: - if not messagebox.askyesno( - step.prompt or "Onay", - step.message or "Devam edilsin mi?", - icon="warning", - default="no", - ): - return None - for name in step.unlocks: - grants[name] = True - return grants + ttk.Label(inner, text="— " + step, style=self.styles["panel_muted"]).pack(anchor="w", pady=1) + ttk.Button(inner, text=action, style=self.styles["accent_button"], + command=command).pack(anchor="w", pady=(theme.SP_6, 0)) - def _show_plan(self, plan: CommandPlan, title: str) -> None: - """Komutu/raporu cikti alanina yazar (kayit ve gozden gecirme icin).""" - self.output.write_now("") - self.output.write_now("=" * 72) - self.output.write_now(f" {title}") - if plan.report_lines and not plan.lines: - for line in plan.report_lines: - self.output.write_now(" " + line) - else: - for line in plan.lines: - self.output.write_now(" " + line) - self.output.write_now("=" * 72) + def _open(self, surface_cls) -> None: + self.destroy() + surface_cls(self.root, self.backend, self.ctx) - def _start_run(self, plan: CommandPlan) -> None: - if plan.needs_root and not write_operations_enabled(): - # The button is disabled for this case, so reaching here means the - # gate was bypassed (stale widget state, a future caller). Refusing - # in the one place every run goes through keeps the rule true for - # the whole surface instead of one control. - self.output.write_now("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT) - self._set_status("YETKİ YOK", "danger") + +class InstallerSurface(SurfaceFrame): + """Single-device installation surface; no fleet navigation exists here.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + super().__init__(root, backend, ctx) + root.title("Blueforce Field OS — Kurulum") + root.geometry("980x680") + root.minsize(760, 560) + self.dealer_var = tk.StringVar() + self.preferences = load_preferences() + self.dealer_var.set(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.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed()) + 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._render() + self._drain_output() + + def _render(self) -> None: + self.title_row("Kurulum konsolu", "Tek cihazı kurun, kaydedin ve doğrulayın.") + content = ttk.Frame(self, style=self.styles["frame"]) + content.pack(fill="both", expand=True, padx=theme.SP_6) + content.columnconfigure(1, weight=1) + steps = ttk.Frame(content, style=self.styles["panel"]) + steps.grid(row=0, column=0, sticky="ns", padx=(0, theme.SP_4)) + ttk.Label(steps, text="KURULUM AKIŞI", style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_4, pady=theme.SP_4) + for i, label in enumerate(("Cihazı tanımla", "Ön kontrol", "Kur ve kaydet", "Doğrula"), 1): + ttk.Label(steps, text=f"{i:02d} {label}", style=self.styles["panel_label"] if i == 1 else self.styles["panel_muted"]).pack( + anchor="w", padx=theme.SP_4, pady=(0, theme.SP_3)) + + main = ttk.Frame(content, style=self.styles["panel"]) + main.grid(row=0, column=1, sticky="nsew") + main.columnconfigure(0, weight=1) + ttk.Label(main, text="Cihaz kimliği", style=self.styles["panel_title"]).grid( + row=0, column=0, sticky="w", padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2)) + ttk.Label(main, text="8 haneli bayi numarasını girin. Bu akış yalnızca bu cihazı etkiler.", + style=self.styles["panel_muted"]).grid(row=1, column=0, sticky="w", padx=theme.SP_5) + ttk.Label(main, textvariable=self.identity_var, style=self.styles["panel_label"]).grid( + row=1, column=1, sticky="e", padx=theme.SP_5) + entry = ttk.Entry(main, textvariable=self.dealer_var, style=self.styles["entry"], width=22, + state="readonly" if self.dealer_locked else "normal") + self.dealer_entry = entry + entry.grid(row=2, column=0, sticky="w", padx=theme.SP_5, pady=(theme.SP_4, theme.SP_2)) + entry.focus_set() + self.change_dealer_button = ttk.Button( + main, text="Bayi no değiştir", style=self.styles["quiet_button"], + command=self._unlock_dealer, + state="normal" if self.dealer_locked else "disabled") + self.change_dealer_button.grid(row=2, column=1, sticky="w", padx=theme.SP_3, + pady=(theme.SP_4, theme.SP_2)) + self.install_button = ttk.Button( + 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( + row=4, column=0, sticky="w", padx=theme.SP_5, pady=(0, theme.SP_4)) + + output = ttk.Frame(self, style=self.styles["panel"]) + output.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_4) + ttk.Label(output, text="Kurulum çıktısı", style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_4, pady=(theme.SP_3, theme.SP_2)) + self.output = tk.Text(output, height=8, wrap="none", state="disabled", + font=("TkFixedFont", 10), relief="flat", padx=8, pady=8) + self.output.pack(fill="both", expand=True, padx=theme.SP_3, pady=(0, theme.SP_3)) + ttk.Button(self, text="Çalışma alanı seçimine dön", style=self.styles["quiet_button"], + command=self._back).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) + + 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 - self.running = True - self.cancel_event.clear() - self.run_button.configure(state="disabled") - self.cancel_button.configure(state="normal") - self._set_status("ÇALIŞIYOR", "info") + dealer = self.dealer_var.get().strip() + problem = dealer_error(dealer) + if problem: + self.status_var.set(problem) + 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: + saved = save_dealer(dealer) + except (OSError, PermissionError, ValueError) as exc: + self.status_var.set(f"Ayar kaydedilemedi: {exc}") + return + self.ctx.active_dealer = saved["dealer_id"] + self.ctx.active_user = saved["active_user"] + self.dealer_locked = True + self.dealer_entry.configure(state="readonly") + self.change_dealer_button.configure(state="normal") + operation = operation_by_id("install-local") + if operation is None: + self.status_var.set("Kurulum operasyonu katalogda bulunamadı.") + return + target = Target("dealer", dealer) + try: + plan = self.backend.build_command(operation, target, {}, check_mode=False) + except Exception as exc: + self.status_var.set(f"Plan üretilemedi: {exc}") + return + if not messagebox.askyesno("Kurulumu onaylayın", "Bu cihaz için kurulum başlatılsın mı?\n\n" + "\n".join(plan.lines), default="no"): + self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.") + return + self.status_var.set("Kurulum çalışıyor…") + self._append("\n" + "\n".join(plan.lines) + "\n") def worker() -> None: try: - result = self.backend.run( - plan, on_line=self.output.append, cancel=self.cancel_event - ) - except Exception as exc: # backend hatasi arayuzu kilitlemesin - self.output.append(f"[HATA] Çalıştırma başarısız: {exc}") - result = RunResult(1, False, "") - self.root.after(0, lambda: self._finish_run(result)) - + result = self.backend.run(plan, on_line=self.output_queue.put) + self.output_queue.put(f"\n{'[OK]' if result.ok else '[HATA]'} Çıkış kodu: {result.exit_code}\n") + except Exception as exc: + self.output_queue.put(f"[HATA] {exc}\n") threading.Thread(target=worker, daemon=True).start() - def _finish_run(self, result: RunResult) -> None: - self.running = False - self._refresh_run_control() - self.cancel_button.configure(state="disabled") - if result.ok: - self.output.append("[OK] Komut başarıyla tamamlandı (çıkış kodu 0).") - self._set_status("TAMAM (çıkış kodu 0)", "ok") + def _identity_text(self) -> str: + dealer = self.dealer_var.get().strip() + return f"Aktif kullanıcı: bf-{dealer}" if dealer else "Aktif kullanıcı ayarlanmadı" + + def _on_dealer_changed(self) -> None: + self.identity_var.set(self._identity_text()) + + def _unlock_dealer(self) -> None: + if not messagebox.askyesno( + "Bayi numarasını değiştir", + "Mevcut bayi kimliği kilidini açıp yeni bir numara girmek istiyor musunuz?", + default="no", + ): + return + self.dealer_locked = False + self.dealer_entry.configure(state="normal") + self.dealer_var.set("") + self.change_dealer_button.configure(state="disabled") + self.status_var.set("Yeni bayi numarası girin; kurulum onayında kaydedilecek.") + self.dealer_entry.focus_set() + + def _append(self, text: str) -> None: + self.output.configure(state="normal") + self.output.insert("end", text) + self.output.see("end") + self.output.configure(state="disabled") + + def _drain_output(self) -> None: + try: + while True: + self._append(self.output_queue.get_nowait()) + self.status_var.set("Kurulum çıktısı güncelleniyor…") + except queue.Empty: + pass + self.after(100, self._drain_output) + + def _back(self) -> None: + self.destroy() + ModePicker(self.root, self.backend, self.ctx) + + +class RemotePortalSurface(SurfaceFrame): + """Fleet portal surface; its state never reuses installer controls.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + super().__init__(root, backend, ctx) + root.title("Blueforce Field OS — Uzaktan Yönetim") + root.geometry("1180x760") + root.minsize(860, 620) + self.search_var = tk.StringVar() + self.selected_host: str | None = None + self.preferences = load_preferences() + self.status_var = tk.StringVar(value="Bir cihaz seçin") + self.output_queue: queue.Queue[str] = queue.Queue() + self._render() + root.bind("", self._on_resize) + self.search_var.trace_add("write", lambda *_: self._render_devices()) + self._render_devices() + + def _render(self) -> None: + self.title_row("Uzaktan yönetim portalı", "Envanter, sağlık ve kontrollü uzaktan operasyonlar · " + + (f"Aktif kullanıcı: {self.ctx.active_user}" if self.ctx.active_user else "Aktif kullanıcı ayarlanmadı")) + body = ttk.Frame(self, style=self.styles["frame"]) + body.pack(fill="both", expand=True, padx=theme.SP_6) + body.columnconfigure(1, weight=1) + body.rowconfigure(1, weight=1) + nav = ttk.Frame(body, style=self.styles["panel"]) + nav.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, theme.SP_4)) + ttk.Label(nav, text="PORTAL", style=self.styles["panel_title"]).pack(anchor="w", padx=theme.SP_4, pady=theme.SP_4) + for label in ("Genel bakış", "Cihazlar", "Operasyonlar", "Denetim kaydı"): + ttk.Button(nav, text=label, style=self.styles["quiet_button"], state="disabled" if label != "Cihazlar" else "normal").pack(fill="x", padx=theme.SP_3, pady=2) + ttk.Button(nav, text="Kurulum alanına geç", style=self.styles["quiet_button"], command=self._back).pack(side="bottom", fill="x", padx=theme.SP_3, pady=theme.SP_3) + ttk.Button(nav, text="Ayarlar · bayi no değiştir", style=self.styles["quiet_button"], command=self._change_dealer).pack(side="bottom", fill="x", padx=theme.SP_3, pady=(0, theme.SP_3)) + + metrics = ttk.Frame(body, style=self.styles["frame"]) + metrics.grid(row=0, column=1, sticky="ew", pady=(0, theme.SP_4)) + for col, (label, value) in enumerate(self._metrics()): + metrics.columnconfigure(col, weight=1) + card = ttk.Frame(metrics, style=self.styles["panel"]) + card.grid(row=0, column=col, sticky="ew", padx=(0, theme.SP_3) if col < 3 else (0, 0)) + ttk.Label(card, text=label, style=self.styles["panel_title"]).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_3, 0)) + ttk.Label(card, text=value, style=self.styles["panel_label"]).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_1, theme.SP_3)) + + devices = ttk.Frame(body, style=self.styles["panel"]) + devices.grid(row=1, column=1, sticky="nsew") + devices.columnconfigure(0, weight=1) + devices.columnconfigure(1, weight=1) + devices.rowconfigure(2, weight=1) + ttk.Label(devices, text="Cihaz envanteri", style=self.styles["panel_title"]).grid(row=0, column=0, sticky="w", padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2)) + ttk.Entry(devices, textvariable=self.search_var, style=self.styles["entry"]).grid(row=1, column=0, sticky="ew", padx=theme.SP_4, pady=(0, theme.SP_3)) + columns = ("host", "wave", "address") + self.tree = ttk.Treeview(devices, columns=columns, show="headings", selectmode="browse") + for column, heading in zip(columns, ("CİHAZ", "DALGA", "ADRES")): + self.tree.heading(column, text=heading) + self.tree.column(column, width=180, anchor="w") + self.tree.grid(row=2, column=0, sticky="nsew", padx=(theme.SP_4, theme.SP_2), pady=(0, theme.SP_3)) + self.tree.bind("<>", self._select_device) + + self.detail = ttk.Frame(devices, style=self.styles["panel"]) + self.detail.grid(row=2, column=1, sticky="nsew", padx=(theme.SP_2, theme.SP_4), pady=(0, theme.SP_3)) + ttk.Label(self.detail, text="Cihaz detayları", style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2)) + self.detail_value = ttk.Label(self.detail, text="Bir cihaz seçin", style=self.styles["panel_label"], justify="left") + self.detail_value.pack(anchor="w", padx=theme.SP_4, pady=(0, theme.SP_3)) + self.detail_hint = ttk.Label(self.detail, text="Envanterden bir satır seçtiğinizde kimlik ve hedef bilgisi burada görünür.", + style=self.styles["panel_muted"], wraplength=260, justify="left") + self.detail_hint.pack(anchor="w", padx=theme.SP_4) + + action = ttk.Frame(self, style=self.styles["panel"]) + action.pack(fill="x", padx=theme.SP_6, pady=(theme.SP_4, theme.SP_3)) + ttk.Label(action, textvariable=self.status_var, style=self.styles["panel_muted"]).pack(side="left", padx=theme.SP_4, pady=theme.SP_3) + self.operation = ttk.Combobox(action, state="readonly", width=32, values=tuple(op.label for op in self.backend.list_operations() if not op.destructive)) + self.operation.pack(side="right", padx=(theme.SP_2, theme.SP_4), pady=theme.SP_3) + ttk.Button(action, text="Salt-okunur çalıştır", style=self.styles["accent_button"], command=self._run_read_only).pack(side="right", pady=theme.SP_3) + + def _metrics(self) -> tuple[tuple[str, str], ...]: + groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} + hosts = sorted({host for values in groups.values() for host in values}) + return (("TOPLAM CİHAZ", str(len(hosts))), ("ÇEVRİMİÇİ", "—"), + ("ÇALIŞAN İŞLEM", "yok"), ("UYARI", "yok")) + + def _on_resize(self, event) -> None: + if event.widget is not self.root or not hasattr(self, "detail"): + return + if event.width < 1050: + self.detail.grid_remove() else: - self.output.append(f"[HATA] Komut {result.exit_code} koduyla bitti.") - self._set_status(f"HATA (çıkış kodu {result.exit_code})", "danger") + self.detail.grid() - def _cancel(self) -> None: - self.cancel_event.set() - self._set_status("İPTAL İSTENDİ", "warn") + def _render_devices(self) -> None: + if not hasattr(self, "tree"): + return + for item in self.tree.get_children(): + self.tree.delete(item) + groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} + query = self.search_var.get().strip().lower() + hosts = sorted({host for values in groups.values() for host in values}) + for host in hosts: + if query and query not in host.lower(): + continue + wave = next((name for name, values in groups.items() if host in values), "—") + self.tree.insert("", "end", iid=host, values=(host, wave, "envanterden okunur")) - def _clear_output(self) -> None: - self.output.clear() + def _select_device(self, _event=None) -> None: + selected = self.tree.selection() + self.selected_host = selected[0] if selected else None + self.status_var.set(f"Seçili cihaz: {self.selected_host}" if self.selected_host else "Bir cihaz seçin") + if self.selected_host: + item = self.tree.item(self.selected_host) + values = item.get("values", ()) + self.detail_value.configure(text=( + f"{self.selected_host}\n\n" + f"Durum: bağlantı bekleniyor\n" + f"Dalga: {values[1] if len(values) > 1 else '—'}\n" + f"Adres: {values[2] if len(values) > 2 else '—'}" + )) + self.detail_hint.configure(text="Salt-okunur operasyonlar seçili cihaza uygulanır. Yıkıcı işlemler ayrıca onay ve yetki kapısından geçer.") - # -- kucuk diyaloglar -------------------------------------------------- - def _choice_dialog(self, title: str, message: str, - choices: Sequence[tuple[str, str | None]]) -> str | None: + def _run_read_only(self) -> None: + if not self.selected_host: + messagebox.showinfo("Cihaz seçin", "Önce envanterden bir cihaz seçin.") + return + label = self.operation.get() + operation = next((op for op in self.backend.list_operations() if op.label == label), None) + if operation is None: + messagebox.showinfo("Operasyon seçin", "Salt-okunur bir operasyon seçin.") + return + self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}") + # The existing backend remains the only command authority. + try: + plan = self.backend.build_command(operation, Target("dealer", self.selected_host.replace("bf-", "")), {}, check_mode=True) + if not messagebox.askyesno("Salt-okunur operasyon", "Komut çalıştırılsın mı?\n\n" + "\n".join(plan.lines), default="no"): + self.status_var.set("İptal edildi") + return + def worker() -> None: + try: + result = self.backend.run(plan) + self.root.after(0, lambda: self.status_var.set( + f"Tamamlandı: çıkış kodu {result.exit_code}")) + except Exception as exc: + message = str(exc) + self.root.after(0, lambda: self.status_var.set(f"Hata: {message}")) + threading.Thread(target=worker, daemon=True).start() + except Exception as exc: + self.status_var.set(f"Hata: {exc}") + + def _back(self) -> None: + self.destroy() + ModePicker(self.root, self.backend, self.ctx) + + def _change_dealer(self) -> None: dialog = tk.Toplevel(self.root) - dialog.title(title) - dialog.configure(bg=theme.BG_APP) + dialog.title("Bayi numarasını değiştir") dialog.transient(self.root) dialog.grab_set() - result: dict[str, str | None] = {"value": None} - - ttk.Label(dialog, text=message, style=self.styles["dialog"], - wraplength=460, justify="left").pack( - padx=theme.SP_4, pady=theme.SP_4, anchor="w" - ) - - def choose(value: str | None) -> None: - result["value"] = value + value = tk.StringVar(value=self.preferences.get("dealer_id", "")) + ttk.Label(dialog, text="Yeni bayi no (8 hane)", style=self.styles["panel_title"]).pack( + padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2), anchor="w") + entry = ttk.Entry(dialog, textvariable=value, width=20, style=self.styles["entry"]) + entry.pack(padx=theme.SP_5, pady=(0, theme.SP_4), anchor="w") + def apply() -> None: + problem = dealer_error(value.get().strip()) + if problem: + messagebox.showerror("Geçersiz bayi numarası", problem, parent=dialog) + return + if not messagebox.askyesno("Kimliği değiştir", "Aktif kullanıcı bf-" + value.get().strip() + " olarak değiştirilsin mi?", parent=dialog, default="no"): + return + saved = save_dealer(value.get().strip()) + self.ctx.active_dealer = saved["dealer_id"] + self.ctx.active_user = saved["active_user"] dialog.destroy() - - row = ttk.Frame(dialog, style=self.styles["frame"]) - row.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_4)) - for index, (label, value) in enumerate(choices): - # Ilk secenek aksandir (operatorun geldigi eylem), "Vazgec" sessiz. - style_name = (self.styles["quiet_button"] if value is None - else self.styles["accent_button"] if index == 0 - else self.styles["button"]) - ttk.Button(row, text=label, style=style_name, - command=lambda v=value: choose(v)).pack( - side="left", padx=(0, theme.SP_2) - ) - dialog.wait_window() - return result["value"] - - def _token_dialog(self, title: str, message: str, token: str) -> str | None: - dialog = tk.Toplevel(self.root) - dialog.title(title) - dialog.configure(bg=theme.BG_APP) - dialog.transient(self.root) - dialog.grab_set() - result: dict[str, str | None] = {"value": None} - ttk.Label(dialog, text=message, style=self.styles["dialog"], - wraplength=460, justify="left").pack( - padx=theme.SP_4, pady=theme.SP_4, anchor="w" - ) - var = tk.StringVar() - entry = ttk.Entry(dialog, textvariable=var, width=32, - style=self.styles["entry"]) - entry.pack(padx=theme.SP_4, anchor="w") + self._back() + ttk.Button(dialog, text="Kaydet ve portalı yenile", style=self.styles["accent_button"], command=apply).pack( + padx=theme.SP_5, pady=(0, theme.SP_5), anchor="w") entry.focus_set() - def confirm() -> None: - result["value"] = var.get().strip() - dialog.destroy() - def cancel() -> None: - result["value"] = None - dialog.destroy() +def launch_surface(root: tk.Tk, backend: Any, ctx: GuiContext, mode: str | None = None) -> SurfaceFrame: + """Launch one independent surface, or the neutral mode picker.""" + surface = {"install": InstallerSurface, "portal": RemotePortalSurface}.get(mode, ModePicker) + return surface(root, backend, ctx) + - row = ttk.Frame(dialog, style=self.styles["frame"]) - row.pack(fill="x", padx=theme.SP_4, pady=theme.SP_4) - ttk.Button(row, text="Onayla", style=self.styles["accent_button"], - command=confirm).pack(side="left", padx=(0, theme.SP_2)) - ttk.Button(row, text="Vazgeç", style=self.styles["quiet_button"], - command=cancel).pack(side="left") - dialog.bind("", lambda _e: confirm()) - dialog.wait_window() - return result["value"] + +"""bfos/ui_theme.py -- design tokens for the device console. + +The values in this file MIRROR ``web/static/tokens.css``, which is the single +source of truth for both operator surfaces (the device console and the central +panel). Nothing here invents a colour: a colour means a device state, a +severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md, +"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the +CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py`` +parses ``tokens.css`` and compares the two files, so a token changed on one +surface and not the other fails the suite instead of drifting. + +Why light, not dark +------------------- +The console runs 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 (``BG_APP``), the panels are +white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and +saturation is spent only where it carries meaning. + +Faces +----- +No webfont, and no font download: the fleet installs offline, so the UI face is +whatever the platform ships and the data face is monospace (ids, counts, exit +codes, timestamps line up in a column). ``apply_theme`` picks the first family +the platform actually has, from a candidate list -- never a missing font name, +which is a silently different layout. + +Sizes and the 4px rhythm +------------------------ +Type sizes and space steps are kept in the token's own unit (CSS px) so the +mirror is exact. Tk font sizes are points, so ``point_size`` converts with the +96 dpi relation the tokens assume; padding and row heights are already pixels +in Tk, so they are used as ``SP_*`` values directly. + +Usage +----- + theme = ui_theme.apply_theme(root) # once, before any widget is built + ttk.Label(parent, style=theme.panel_label) +""" + +from __future__ import annotations + +from typing import Dict, Mapping, Sequence + +try: # pragma: no cover - environment dependent + from tkinter import ttk + + HAVE_TKINTER = True +except Exception: # pragma: no cover - environment dependent + ttk = None # type: ignore[assignment] + HAVE_TKINTER = False # --------------------------------------------------------------------------- -# Katalog yazdirma (pencere ACMADAN dogrulama icin) +# Ground and structure -- tokens.css: "Ground and structure" # --------------------------------------------------------------------------- -def print_catalogue(backend, stream=None) -> None: - out = stream or sys.stdout - operations = backend.list_operations() - print(f"Blueforce Field OS — işlem kataloğu ({len(operations)} işlem)", file=out) - for category in CATEGORIES: - items = [op for op in operations if op.category == category] - print(f"\n== {category} ({len(items)} işlem) ==", file=out) - for op in items: - kind = "YIKICI" if op.destructive else "salt-okunur" - playbook = op.playbook or "-" - print( - f" {op.id:<24} {op.label:<46} kapsam={op.scope:<8} {kind:<11} playbook={playbook}", - file=out, - ) - +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 # --------------------------------------------------------------------------- -# Yetki kapisi +# Text # --------------------------------------------------------------------------- -def is_root() -> bool: - """True when the process can install, write state and drive Ansible. +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" - The installer writes to the filesystem, manages services and applies the - firewall baseline, so the GUI is expected to run under sudo. Reports read - the repository and the inventory and work without it, which is why the - console still starts as a normal user and explains the difference instead - of refusing to launch. - """ - geteuid = getattr(os, "geteuid", None) - return True if geteuid is None else geteuid() == 0 +# --------------------------------------------------------------------------- +# Action -- one accent: the action the operator came to take, and the +# selected row. Nothing else. +# --------------------------------------------------------------------------- +ACCENT = "#1d4ed8" +ACCENT_HOVER = "#1a43bd" +ACCENT_ACTIVE = "#16389e" +ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill +FOCUS_RING = "#1d4ed8" +# --------------------------------------------------------------------------- +# State severities -- four, each with a strong form for text and a soft form +# for fills, so a badge is built from one palette without inventing a second. +# --------------------------------------------------------------------------- +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" -ROOT_REQUIRED_NOTICE = ( - "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" +# --------------------------------------------------------------------------- +# Device state machine -- PROVISIONED_OFFLINE -> ENROLLED -> READY, plus the +# failure and the absence that sit outside the sequence. Named so no surface +# repaints a stage by accident. +# +# PROVISIONED is deliberately NOT the action accent (tokens.css states the +# reason): the accent means "the action you came to take" and "the selected +# row", so a KURULDU badge built from it made a state and a selection the same +# blue. It is a slate of its own, one step along the path and distinct from the +# neutral --idle. +# --------------------------------------------------------------------------- +STATE_PROVISIONED = "#475569" +STATE_ENROLLED = WARN +STATE_READY = OK +STATE_FAILED = DANGER +STATE_ABSENT = IDLE + +# --------------------------------------------------------------------------- +# Type (px, as the tokens declare them) +# --------------------------------------------------------------------------- +SIZE_XS = 12 +SIZE_SM = 13 +SIZE_MD = 15 +SIZE_LG = 18 +SIZE_XL = 22 +SIZE_2XL = 30 + +LEADING_TIGHT = 1.25 +LEADING_NORMAL = 1.5 + +# --------------------------------------------------------------------------- +# Space -- a 4px rhythm, named so a component never hand-picks 7px +# --------------------------------------------------------------------------- +SP_1 = 4 +SP_2 = 8 +SP_3 = 12 +SP_4 = 16 +SP_5 = 24 +SP_6 = 32 +SP_7 = 48 + +RADIUS = 6 +RADIUS_LG = 10 + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- +ROW_H = 44 # fleet table row: comfortable at arm's length +CONTROL_H = 36 # buttons and inputs share one height +HEADER_H = 56 +SIDEBAR_W = 220 + +# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names +# are kept so the two token sets stay comparable and the absence is explicit. +SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)" +SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)" + +# --------------------------------------------------------------------------- +# Fonts -- candidates in preference order, resolved against the platform +# --------------------------------------------------------------------------- +FONT_UI_CANDIDATES: Sequence[str] = ( + "Noto Sans", + "DejaVu Sans", + "Ubuntu", + "Liberation Sans", + "Segoe UI", + "Helvetica", ) -NON_ROOT_DISABLED_HINT = "Bu işlem yönetici yetkisi gerektirir (sudo bf)." +FONT_DATA_CANDIDATES: Sequence[str] = ( + "DejaVu Sans Mono", + "Liberation Mono", + "Noto Sans Mono", + "Consolas", + "Menlo", + "Courier New", +) +FONT_UI_FALLBACK = "TkDefaultFont" +FONT_DATA_FALLBACK = "TkFixedFont" +#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula: +#: rounding is per size so the console keeps the token's steps instead of +#: collapsing two of them onto the same point size. +POINT_SIZES: Mapping[int, int] = { + SIZE_XS: 9, + SIZE_SM: 10, + SIZE_MD: 11, + SIZE_LG: 14, + SIZE_XL: 16, + SIZE_2XL: 22, +} -def write_operations_enabled() -> bool: - """Whether operations that change the device may be offered.""" - return is_root() +# --------------------------------------------------------------------------- +# The mirror table -- CSS custom property -> value. Kept as data so the parity +# test reads the same list the code uses. +# --------------------------------------------------------------------------- +TOKENS: Mapping[str, str] = { + "--bg-app": BG_APP, + "--bg-panel": BG_PANEL, + "--bg-sunken": BG_SUNKEN, + "--bg-header": BG_HEADER, + "--border": BORDER, + "--border-strong": BORDER_STRONG, + "--text": TEXT, + "--text-muted": TEXT_MUTED, + "--text-faint": TEXT_FAINT, + "--text-on-dark": TEXT_ON_DARK, + "--text-on-accent": TEXT_ON_ACCENT, + "--accent": ACCENT, + "--accent-hover": ACCENT_HOVER, + "--accent-active": ACCENT_ACTIVE, + "--accent-soft": ACCENT_SOFT, + "--focus-ring": FOCUS_RING, + "--ok": OK, + "--ok-soft": OK_SOFT, + "--warn": WARN, + "--warn-soft": WARN_SOFT, + "--danger": DANGER, + "--danger-soft": DANGER_SOFT, + "--info": INFO, + "--info-soft": INFO_SOFT, + "--idle": IDLE, + "--idle-soft": IDLE_SOFT, + "--state-provisioned": STATE_PROVISIONED, + "--state-enrolled": STATE_ENROLLED, + "--state-ready": STATE_READY, + "--state-failed": STATE_FAILED, + "--state-absent": STATE_ABSENT, +} + +#: Space tokens, mirrored by name (the parity test compares these too). +SPACE_TOKENS: Mapping[str, int] = { + "--sp-1": SP_1, + "--sp-2": SP_2, + "--sp-3": SP_3, + "--sp-4": SP_4, + "--sp-5": SP_5, + "--sp-6": SP_6, + "--sp-7": SP_7, +} + +#: Type-size tokens, mirrored by name. +SIZE_TOKENS: Mapping[str, int] = { + "--size-xs": SIZE_XS, + "--size-sm": SIZE_SM, + "--size-md": SIZE_MD, + "--size-lg": SIZE_LG, + "--size-xl": SIZE_XL, + "--size-2xl": SIZE_2XL, +} + +#: Metric tokens, mirrored by name. +METRIC_TOKENS: Mapping[str, int] = { + "--row-h": ROW_H, + "--control-h": CONTROL_H, + "--header-h": HEADER_H, + "--sidebar-w": SIDEBAR_W, +} + +#: Every mirrored value, by CSS token name (used by the parity test). +#: Lengths are stored WITHOUT their unit: the unit is the token's (px at +#: 96 dpi), and the console passes the number straight to Tk padding, which is +#: already in pixels. The parity test strips the CSS unit to compare. +ALL_TOKENS: Mapping[str, str] = { + **TOKENS, + **{name: str(value) for name, value in SPACE_TOKENS.items()}, + **{name: str(value) for name, value in SIZE_TOKENS.items()}, + **{name: str(value) for name, value in METRIC_TOKENS.items()}, +} + +# --------------------------------------------------------------------------- +# Severity vocabulary -- the fill/text pair a badge or a row is built from +# --------------------------------------------------------------------------- +SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle") + +SEVERITY_FILL: Mapping[str, str] = { + "ok": OK_SOFT, + "warn": WARN_SOFT, + "danger": DANGER_SOFT, + "info": INFO_SOFT, + "idle": IDLE_SOFT, +} +SEVERITY_TEXT: Mapping[str, str] = { + "ok": OK, + "warn": WARN, + "danger": DANGER, + "info": INFO, + "idle": IDLE, +} + +#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md). +STATE_WORDS: Mapping[str, str] = { + "provisioned": "KURULDU", + "enrolled": "KAYITLI", + "ready": "HAZIR", + "failed": "HATA", + "absent": "YOK", +} +STATE_FILL: Mapping[str, str] = { + # The tokens give no soft form for provisioned; the console borrows the + # sunken well fill rather than inventing a hue, so a KURULDU badge cannot be + # mistaken for the accent-soft selection (see tokens.css, device state). + "provisioned": BG_SUNKEN, + "enrolled": WARN_SOFT, + "ready": OK_SOFT, + "failed": DANGER_SOFT, + "absent": IDLE_SOFT, +} +STATE_TEXT_COLOUR: Mapping[str, str] = { + "provisioned": STATE_PROVISIONED, + "enrolled": STATE_ENROLLED, + "ready": STATE_READY, + "failed": STATE_FAILED, + "absent": STATE_ABSENT, +} + +#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege"). +PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"} + +#: ttk style names, resolved once by apply_theme and read by the view. +_FAMILIES: Dict[str, str] = {} # --------------------------------------------------------------------------- -# Baslatma +# Small helpers # --------------------------------------------------------------------------- -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="python3 -m bfos.ui_gui", - description="Blueforce Field OS masaüstü arayüzü (Türkçe, tkinter).", - ) - parser.add_argument("--full", action="store_true", - help="Doğrudan detaylı modda başlat (kategori sekmeleri).") - parser.add_argument("--repo", default=None, help="Depo kökü (varsayılan: bfos'un üst dizini).") - parser.add_argument("--inventory", default=None, help="Ansible envanteri (varsayılan: otomatik).") - parser.add_argument("--dry-run", action="store_true", - help="Komutları göster, hiçbir şey çalıştırma.") - parser.add_argument("--list", action="store_true", - help="Pencere açmadan işlem kataloğunu yazdır ve çık.") - return parser.parse_args(argv) +def point_size(px: int) -> int: + """Token px -> Tk point size (see POINT_SIZES).""" + return POINT_SIZES.get(px, max(7, round(px * 0.75))) -def main(argv: Sequence[str] | None = None) -> int: - args = parse_args(argv) - ctx = GuiContext.detect(repo=args.repo, inventory=args.inventory, dry_run=args.dry_run) - backend, note = load_backend(ctx) +def ui_family() -> str: + """The UI face resolved by :func:`apply_theme` (platform default before).""" + return _FAMILIES.get("ui", FONT_UI_FALLBACK) - if args.list: - print_catalogue(backend) - print(f"\n[i] {note}") - return 0 - if not HAVE_TKINTER: - print(tkinter_missing_message(TK_IMPORT_ERROR), file=sys.stderr) - return 3 - problem = headless_error() - if problem: - print(problem, file=sys.stderr) - return 3 +def data_family() -> str: + """The monospace face resolved by :func:`apply_theme`.""" + return _FAMILIES.get("data", FONT_DATA_FALLBACK) - root = tk.Tk() + +def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple: + """A Tk font tuple in the platform UI face.""" + if weight == "normal": + return (ui_family(), point_size(size)) + return (ui_family(), point_size(size), weight) + + +def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple: + """A Tk font tuple in the data (monospace) face.""" + if weight == "normal": + return (data_family(), point_size(size)) + return (data_family(), point_size(size), weight) + + +#: State key -> badge style, built from STATE_WORDS so the word and the colour +#: can never be paired up wrongly on one surface. +STATE_BADGE_STYLE: Mapping[str, str] = { + key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS +} + +#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else). +PRIVILEGE_BADGE_STYLE: Mapping[str, str] = { + "full": "Privilege.Badge.TLabel", + "read_only": "ReadOnly.Badge.TLabel", +} + + +def severity_style(severity: str) -> str: + """ttk style name for a severity pill (ok / warn / danger / info / idle).""" + return f"{severity.capitalize()}.Status.TLabel" + + +def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str: + """First family the platform actually has; the Tk default otherwise.""" try: - BfGui(root, backend, ctx, full_mode=args.full) - except tk.TclError as exc: # DISPLAY var ama erisilemiyor - print(window_start_error(exc), file=sys.stderr) - return 3 - root.mainloop() - return 0 + from tkinter import font as tkfont + + available = {name.lower() for name in tkfont.families(root)} + except Exception: # pragma: no cover - font database unavailable + return fallback + for name in candidates: + if name.lower() in available: + return name + return fallback -if __name__ == "__main__": # pragma: no cover - sys.exit(main()) +def _configure(style, name: str, **options) -> None: + """Configure a ttk style, ignoring options the platform's theme rejects. + + ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs + 9.x): a missing element option raises TclError. Losing one border colour is + a cosmetic difference; crashing the console on startup is not. + """ + try: + style.configure(name, **options) + except Exception: + for key, value in options.items(): + try: + style.configure(name, **{key: value}) + except Exception: + continue + + +def _map(style, name: str, **options) -> None: + """State map for a ttk style, same tolerance as :func:`_configure`.""" + try: + style.map(name, **options) + except Exception: + for key, value in options.items(): + try: + style.map(name, **{key: value}) + except Exception: + continue + + +# --------------------------------------------------------------------------- +# The one setup function, called once at startup +# --------------------------------------------------------------------------- +def apply_theme(root) -> Mapping[str, str]: + """Install the token set on ``root``'s ttk styles; returns the style names. + + Idempotent and cheap: the GUI calls it once before building any widget, and + a second call simply re-applies the same values (tests may build several + roots in one process). + """ + if not HAVE_TKINTER: # pragma: no cover - environment dependent + raise RuntimeError("tkinter yok: tema uygulanamaz") + assert ttk is not None # narrowing for the type checker + + _FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK) + _FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK) + + try: + root.configure(bg=BG_APP) + except Exception: # pragma: no cover - non-Tk root + pass + + style = ttk.Style(root) + # clam honours background/bordercolor on the widget elements we restyle; + # the 'default' theme on Linux ignores most of them, which is how a + # console ends up grey whatever the tokens say. + try: + if "clam" in style.theme_names(): + style.theme_use("clam") + except Exception: # pragma: no cover - theme list unavailable + pass + + base_font = ui_font(SIZE_MD) + _configure( + style, ".", + background=BG_APP, + foreground=TEXT, + font=base_font, + bordercolor=BORDER, + lightcolor=BORDER, + darkcolor=BORDER, + troughcolor=BG_SUNKEN, + focuscolor=FOCUS_RING, + ) + + # -- frames: the ground, the raised panel, the well, the one dark band -- + _configure(style, "TFrame", background=BG_APP, borderwidth=0) + _configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1, + relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) + _configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1, + relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) + _configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0) + _configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1, + relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT, + darkcolor=WARN_SOFT) + _configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1, + relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT, + darkcolor=INFO_SOFT) + _configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1, + relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT, + darkcolor=ACCENT_SOFT) + + # -- labels ------------------------------------------------------------- + _configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font) + _configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT) + _configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT) + _configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM)) + _configure(style, "PanelMuted.TLabel", background=BG_PANEL, + foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) + _configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT, + font=ui_font(SIZE_XS)) + _configure(style, "PanelFaint.TLabel", background=BG_PANEL, + foreground=TEXT_FAINT, font=ui_font(SIZE_XS)) + _configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM), anchor="e") + _configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_LG, "bold")) + _configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, + font=ui_font(SIZE_XS, "bold")) + # The one loud figure per view. + _configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_2XL, "bold")) + _configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK, + font=ui_font(SIZE_SM)) + _configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_SM)) + _configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO, + font=ui_font(SIZE_SM)) + _configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER, + font=ui_font(SIZE_SM)) + _configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN, + font=ui_font(SIZE_SM, "bold")) + _configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK, + font=ui_font(SIZE_SM, "bold")) + _configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER, + font=ui_font(SIZE_SM, "bold")) + # Dialogs sit on the app ground, not on a panel. + _configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_MD)) + _configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_LG, "bold")) + _configure(style, "DialogMuted.TLabel", background=BG_APP, + foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) + # Header (dark) region. + _configure(style, "HeaderTitle.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold")) + _configure(style, "HeaderMeta.TLabel", background=BG_HEADER, + foreground=TEXT_FAINT, font=ui_font(SIZE_SM)) + _configure(style, "HeaderTarget.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=data_font(SIZE_SM)) + _configure(style, "HeaderFigure.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold")) + + # -- badges: state word + colour, never colour alone ------------------- + _configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE, + font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1)) + for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"), + ("ready", "Ready"), ("failed", "Failed"), + ("absent", "Absent")): + _configure( + style, f"State{word_style}.Badge.TLabel", + background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key], + font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1), + ) + _configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT, + foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT, + foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + + # -- status pill (footer): the console's own state --------------------- + _configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2)) + for severity in SEVERITIES: + _configure( + style, f"{severity.capitalize()}.Status.TLabel", + background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity], + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2), + ) + + # -- inputs: one height with the buttons --------------------------------- + entry_opts = dict( + fieldbackground=BG_PANEL, + background=BG_PANEL, + foreground=TEXT, + bordercolor=BORDER_STRONG, + lightcolor=BORDER_STRONG, + darkcolor=BORDER_STRONG, + insertcolor=TEXT, + padding=(SP_2, SP_1), + font=ui_font(SIZE_MD), + ) + _configure(style, "TEntry", **entry_opts) + _map(style, "TEntry", + bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)], + foreground=[("disabled", TEXT_FAINT)], + fieldbackground=[("disabled", BG_SUNKEN)]) + _configure(style, "Invalid.TEntry", **entry_opts) + _configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER, + darkcolor=DANGER) + _map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)]) + + _configure(style, "TCombobox", **entry_opts) + _configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT, + selectforeground=TEXT, padding=(SP_2, SP_1)) + _map(style, "TCombobox", + fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)], + foreground=[("disabled", TEXT_FAINT)], + bordercolor=[("focus", FOCUS_RING)]) + + _configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1)) + _map(style, "TCheckbutton", + background=[("active", BG_APP)], + foreground=[("disabled", TEXT_FAINT)], + indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT, + font=ui_font(SIZE_SM), padding=(SP_1, SP_1)) + _map(style, "Panel.TCheckbutton", + background=[("active", BG_PANEL), ("disabled", BG_PANEL)], + foreground=[("disabled", TEXT_FAINT)]) + + # -- buttons: hairline secondary, one accent primary, danger for + # destructive work ------------------------------------------------ + _configure(style, "TButton", background=BG_PANEL, foreground=TEXT, + bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL, + font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid", + focusthickness=1, focuscolor=FOCUS_RING, anchor="center") + _map(style, "TButton", + background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN), + ("active", ACCENT_SOFT)], + foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)], + bordercolor=[("focus", FOCUS_RING)], + lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)], + darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)]) + _configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT, + bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT) + _map(style, "Accent.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), + ("active", ACCENT_HOVER)], + foreground=[("disabled", TEXT_FAINT)], + bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)], + lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], + darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT, + bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER) + _map(style, "Danger.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT), + ("active", DANGER_SOFT)], + foreground=[("disabled", TEXT_FAINT), ("active", DANGER), + ("pressed", DANGER)], + bordercolor=[("focus", DANGER)], + lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)], + darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)]) + # Simple mode: the three big buttons, one of them the action accent. + _configure(style, "Big.TButton", font=ui_font(SIZE_LG), + padding=(SP_5, SP_4), anchor="w") + _configure(style, "Accent.Big.TButton", background=ACCENT, + foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT, + darkcolor=ACCENT) + _map(style, "Accent.Big.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), + ("active", ACCENT_HOVER)], + foreground=[("disabled", TEXT_FAINT)], + lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], + darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER) + + # -- table: the fleet/operation list ------------------------------------ + _configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL, + foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER, + darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H) + _configure(style, "Treeview.Heading", background=BG_SUNKEN, + foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"), + relief="flat", padding=(SP_2, SP_2)) + _map(style, "Treeview", + background=[("selected", ACCENT_SOFT)], + foreground=[("selected", TEXT)]) + _map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)]) + + # -- tabs, separators, scrollbars -------------------------------------- + _configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER, + lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0)) + _configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER) + _map(style, "TNotebook.Tab", + background=[("selected", BG_PANEL), ("active", BG_PANEL)], + foreground=[("selected", TEXT), ("active", TEXT)], + expand=[("selected", (0, 0, 0, 0))]) + _configure(style, "TSeparator", background=BORDER) + for orient in ("Vertical", "Horizontal"): + _configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN, + troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED, + lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN) + _map(style, f"{orient}.TScrollbar", + background=[("active", BORDER_STRONG)]) + + return { + "frame": "TFrame", + "panel": "Panel.TFrame", + "sunken": "Sunken.TFrame", + "header": "Header.TFrame", + "warn_band": "Warn.TFrame", + "info_band": "Info.TFrame", + "label": "TLabel", + "panel_label": "Panel.TLabel", + "panel_title": "PanelTitle.TLabel", + "muted": "Muted.TLabel", + "panel_muted": "PanelMuted.TLabel", + "faint": "Faint.TLabel", + "panel_faint": "PanelFaint.TLabel", + "field_label": "FieldLabel.TLabel", + "section": "Section.TLabel", + "figure": "Figure.TLabel", + "ok_label": "Ok.TLabel", + "warn_label": "Warn.TLabel", + "info_label": "InfoText.TLabel", + "danger_label": "Danger.TLabel", + "app_warn": "AppWarn.TLabel", + "panel_ok": "PanelOk.TLabel", + "panel_danger": "PanelDanger.TLabel", + "header_title": "HeaderTitle.TLabel", + "header_meta": "HeaderMeta.TLabel", + "header_target": "HeaderTarget.TLabel", + "header_figure": "HeaderFigure.TLabel", + "badge": "Badge.TLabel", + "badge_info": "Info.Badge.TLabel", + "badge_neutral": "Neutral.Badge.TLabel", + "badge_destructive": "Type.Destructive.Badge.TLabel", + "badge_readonly": "Type.ReadOnly.Badge.TLabel", + "badge_open": "Open.Badge.TLabel", + "badge_closed": "Closed.Badge.TLabel", + "privilege": "Privilege.Badge.TLabel", + "readonly": "ReadOnly.Badge.TLabel", + "status": "Status.TLabel", + "button": "TButton", + "accent_button": "Accent.TButton", + "danger_button": "Danger.TButton", + "big_button": "Big.TButton", + "big_accent_button": "Accent.Big.TButton", + "quiet_button": "Quiet.TButton", + "entry": "TEntry", + "invalid_entry": "Invalid.TEntry", + "panel_check": "Panel.TCheckbutton", + "check": "TCheckbutton", + "dialog": "Dialog.TLabel", + "dialog_title": "DialogTitle.TLabel", + "dialog_muted": "DialogMuted.TLabel", + } + + +def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial + """The danger colour used for a focused destructive control. + + A named function (rather than a bare literal) so the fifth-hue rule stays + visible: focus on a dangerous action reuses the danger token. + """ + return DANGER + + + +# Purpose: Harden field-device SSH to key-only maintenance access. +# Scope: Applies to the blueforce user; root and password logins stay disabled. +# Safety: Drop-in for sshd_config.d; validate with sshd -t before reload. +# Ref: docs/06-USERS-SSH-AND-PERMISSIONS.md, K-13. + +# No direct root login; privilege is gained only via audited sudo. +PermitRootLogin no + +# Key-only auth: passwords and interactive challenges are disabled. +PasswordAuthentication no +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes + +# Only the maintenance user may open SSH sessions. +# Identity-specific SSH policy is generated by installer module 07-ssh as +# /etc/ssh/sshd_config.d/99-bf-.conf. + +# Reduce idle-session and brute-force exposure. +X11Forwarding no +MaxAuthTries 3 +LoginGraceTime 60 +ClientAliveInterval 300 +ClientAliveCountMax 2 @@ -72811,241 +59694,6 @@ flowchart TB ``` - -# 19 — Sorun Giderme (Troubleshooting) - -> Kısa özet: Sahada ve merkezde en sık görülen arızaların belirti → neden → komut → çözüm tablosu. 17-RECOVERY "hangi seviye" sorusunu, bu doküman "o seviyede hangi komut" sorusunu cevaplar. - -- Dosya: `docs/19-TROUBLESHOOTING.md` -- İlgili kararlar: `24-DECISION-LOG.md#K-03` (xRDP/GUI), `#K-04` (erişim kanalları), `#K-05` (WireGuard), `#K-10` (Docker), `#K-14` (`bf-status`, support-bundle) -- Durum: [ ] Taslak - ---- - -## 1. Amaç - -Tekrar eden arızalarda teşhisi hızlandırmak: belirtiyi bulan teknisyen/admin, tabloya bakıp ilk komutu 30 saniyede çalıştırır. Her girdide "ne zaman üst seviyeye çık" notu vardır. - -## 2. Kapsam - -- Kapsam içi: boot, ağ/VPN, SSH, RDP/GUI, RustDesk, MeshCentral, Docker/MEG, disk, monitoring ve operatör arayüzü (grafik/terminal konsol, paket deposu keşfi) belirti tabloları. -- Kapsam dışı: seviye kararları (17), adım-adım kurulum (20), merkez onay akışı (21), arayüzün tüm ekranları (33). - -## 3. Kararlar - -```text -KARAR: Her arıza girdisi aynı formatta yazılır: Belirti → Olası nedenler (olasılık sırasıyla) → Tanı komutları → Çözüm → Yükseltme kriteri. -GEREKÇE: Standart format, acemi teknisyenin bile doğru sırayla (tanıdan önce çözüm yok) ilerlemesini sağlar; her çözüm denenebilir komutla verilir. -ALTERNATİF: Serbest-anlatım sorun listesi — komutlar dağınık kalır, otomasyona çevrilemez; elendi. -RİSK: Tablo eskirse yanlış yönlendirir; azaltma: her PİLOT dalgası sonrası tablo gözden geçirilir. -MALİYET: Ücretsiz. -LİSANS: Yok. -``` - -## 4. Neden Bu Karar? - -Sorun giderme, kurtarma merdiveninin (17) "içini" doldurur: 17 hangi LEVEL, 19 o LEVEL'de hangi komut. Olasılık sırası önemlidir — en yaygın neden önce yazılır, teknisyen zamanı boşa harcanmaz. Yükseltme kriteri her girdinin sigortasıdır (inatla aynı komutu tekrarlamak yasak). - -## 5. Alternatifler - -| Alternatif | Artı | Eksi | Sonuç | -|---|---|---|---| -| Harici bilgi tabanı (Wiki araması) | Aranabilir | Çevrimdışı sahada erişilemez, Git akışı yok | Elendi (22'de Docusaurus içinde bu dosya zaten aranabilir) | -| Her arıza için ayrı sayfa | Detaylı | 700 cihazda 100 sayfa yönetilemez | Elendi (gerekirse Ek'e taşınır) | - -## 6. Avantajlar - -- Tek dosya, çevrimdışı da okunur (USB'de veya basılı). -- Komutlar kopyala-yapıştırılabilir; açıklamalar Türkçe, komut adları İngilizce orijinal. - -## 7. Dezavantajlar - -- 26.04.1'e özgü çıktı farkları LAB'da güncellenmelidir (kesin servis adları, log yolları). -- MEG'e özgü arızalar 13-MEG-ACCEPTANCE'tan beslenmelidir; bu dosya iskelettir. - -## 8. Riskler - -| Risk | Olasılık | Etki | Azaltma | -|---|---|---|---| -| Eski komut (servis adı değişti) | Orta | Orta | LAB'da her komut çalıştırılıp doğrulanır | -| Secret'lı komut geçmişe düşer | Düşük | Yüksek | Support-bundle kuralı (K-14); komutlarda secret yazılmaz | - -## 9. Uygulama Planı - -Önce genel tanı (her arızada ilk 60 saniye): - -```bash -bf-status -systemctl --failed -ip -brief addr; ip route show default -wg show wg0 2>&1 | head -20 -docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.RestartPolicy}}' -df -h / /var/lib/docker | cat -``` - -Aynı raporlar operatör arayüzünden de okunur (komut ezberi gerekmez): - -```text -Arayüzden: sudo bf → `Durum` düğmesi (Durum özeti) ya da Detaylı mod → Cihaz bilgisi sekmesi - (kimlik, ağ/WireGuard, kaynaklar, servisler, son journal hataları — hepsi salt-okunur) -Komutla (yedek): sudo bf --tui → Cihaz bilgisi menüsü -``` - -### T-1 — Cihaz açılmıyor / ekranda hiçbir şey yok - -| # | Neden (olasılık sırası) | Tanı | Çözüm | -|---|---|---|---| -| 1 | Elektrik yok / BIOS güç ayarı | Priz, adaptör, güç LED'i | BIOS "Restore on AC Power Loss = Power On" (16) | -| 2 | Monitör/kablo | Başka kablo/ekran dene | Değiştir | -| 3 | Disk ölmüş | BIOS diski görmüyor | Donanım değişimi → L8 yeni diskte | - -Yükseltme: donanım sağlamsa ama OS açılmıyorsa T-2'ye geç. - -### T-2 — Boot terminale düşmüyor / servisler kalkmıyor - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | Failed servis var | `systemctl --failed`, `journalctl -xb -p err` | İlgili servis girdisine git (T-3…T-6) | -| 2 | Disk dolu | `df -h /` | Docker log temizliği + rotasyon kontrolü (T-7) | -| 3 | Bozuk çekirdek/paket | `dpkg --audit`, `apt --fix-broken install` | 17-L7; olmazsa L8 | - -### T-3 — WireGuard tüneli yok (`wg show` boş / handshake eski) - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | Servis inaktif | `systemctl status wg-quick@wg0` | `sudo systemctl enable --now wg-quick@wg0` | -| 2 | Anahtar/config hatası | `journalctl -u wg-quick@wg0 --no-pager`, `wg show wg0 latest-handshakes` | Config'i merkezden yeniden bas (17-L5); peer adı `bf-` mi kontrol et | -| 3 | Saha interneti yok / CGNAT | `ping -c3 8.8.8.8`, modem arayüzü | Yerel ağ düzeltilir; kopma sayacı not edilir (P1 görevi) | -| 4 | Merkez hub down | Başka cihazdan hub'a bak | 17-L9 kolu işletilir | - -Yükseltme: WG kalkmıyorsa RustDesk/MeshCentral ile devam (17-L4); onlar de yoksa saha ziyareti. - -### T-4 — SSH bağlanmıyor (WG ayaktayken) - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | sshd down / anahtar eşleşmiyor | `systemctl status ssh`, `ssh -vvv blueforce@bf-` | Anahtarı merkezden yeniden dağıt (K-13); password denemesi YOK | -| 2 | Yanlış IP/hostname | `ip -brief addr`, envanter karşılaştır | 02-NAMING'e göre düzelt | -| 3 | Güvenlik duvarı | `sudo ufw status`, `ss -tlnp` | 11-HARDENING standardına döndür | - -### T-5 — RDP açılmıyor / siyah ekran / hemen kopuyor - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | GUI katmanı kapalı | `systemctl status display-manager xrdp xrdp-sesman` | `bf-gui-on`, tekrar dene | -| 2 | Oturum çakışması (yerel + RDP aynı kullanıcı) | `who`, `loginctl list-sessions` | Ayrı kullanıcı/oturum; eski oturumu kapat | -| 3 | xRDP config bozulmuş | `journalctl -u xrdp --no-pager` | Bilinen-iyi config'i bas (17-L5) | - -### T-6 — RustDesk bağlanmıyor - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | İstemci servis/uygulama kapalı | RustDesk durumu, ID görünüyor mu | Servisi başlat; ID+röle adresi merkez değerlerle aynı mı | -| 2 | Key uyuşmazlığı | İstemci key vs hbbs key | Merkezi key'i yeniden bas (07-REMOTE-ACCESS) | -| 3 | hbbs/hbbr down | Merkezden konteyner/servis durumu | Merkez tarafını ayağa kaldır; health monitörü (Kuma) ne diyor | - -### T-7 — Docker/MEG kalkmıyor veya sürekli restart - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | Restart loop (config/entrypoint) | `docker ps`, `docker logs --tail 100 ` | Config düzelt + `docker compose up -d` (17-L6) | -| 2 | Disk dolu (rotasyonsuz log) | `df -h /var/lib/docker`, `du -sh /var/lib/docker/containers/*` | `daemon.json` rotasyonu uygula; taşan logu buda | -| 3 | `latest` çekilmiş / sürüm kaymış | `docker images`, compose tag kontrolü | Sabit tag'e döndür (K-10); Watchtower varsa kaldır | -| 4 | Port çakışması / UFW bypass | `ss -tlnp`, `docker port ` | `127.0.0.1`-bind standardına döndür (K-10) | - -### T-8 — Disk doluyor - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | Docker json-file logları | `du -sh /var/lib/docker/containers/*` | Rotasyon + budama, `docker system prune` (dikkatli) | -| 2 | journald şişmiş | `journalctl --disk-usage` | `SystemMaxUse` limiti + `journalctl --vacuum-size=` | -| 3 | Prometheus retention (merkez) | `df -h`, retention ayarı | 30–90 gün bandına çek (K-07) | - -### T-9 — Monitoring'de cihaz gri/kayıp - -| # | Neden | Tanı | Çözüm | -|---|---|---|---| -| 1 | Node Exporter down | `systemctl status node_exporter`, hedef scrape logu | Servisi başlat; textfile dizin izni | -| 2 | Uptime Push gelmiyor | Kuma "last seen" | Cihaz interneti + push job'ı (T-3'e dön) | -| 3 | Etiket yanlış (`BF-`) | Prometheus target etiketleri | Envanterle eşitle (02) | - -### T-10 — Grafik operatör arayüzü açılmıyor (`sudo bf` pencere açmıyor / hemen kapanıyor) - -| # | Neden (olasılık sırası) | Tanı | Çözüm | -|---|---|---|---| -| 1 | Ekran yok (SSH/başsız oturum) | Ekranda "Grafik ekran bulunamadı (DISPLAY ve WAYLAND_DISPLAY tanımlı değil)" + yönlendirme; çıkış kodu `3` | `sudo bf --tui` ile aynı işlemleri terminal konsolunda yürüt; yerel ekranda pencereli kullanmak için oturum açık bir makinede `sudo bf` | -| 2 | `python3-tk` kurulu değil (minimal imaj) | "tkinter bulunamadı; masaüstü arayüzü başlatılamıyor" + paket ipucu; çıkış kodu `3` | Çevrimdışı sahada paket kurulmaz: paket offline depoda pinlidir (`manifests/base-packages.txt`) ve normal kurulumla gelir. Eksikse `sudo bf --tui` ile devam et ve merkeze bildir; kurulum sonu kapısı (`18-final-check`) bu durumda **WARN** yazar ve kurulumu düşürmez | -| 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 | - -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. - -### T-11 — "Yerel paket deposu bulunamadı" (kurulum paket adımlarına geçmiyor) - -| # | Neden (olasılık sırası) | Tanı | Çözüm | -|---|---|---|---| -| 1 | Depo cihazda hiç yok | Ekranda `HATA: Yerel paket deposu yok. Repo sorumlusuna başvurun: provisioning/offline-repo/README.md` + **aranan yollar** + `ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.`; çıkış kodu `1`, sistem değişmez | Paket setini git ile tam olarak getir (paketler Git LFS ile gelir) ya da repo sorumlusunun bıraktığı kopyayı `/opt/blueforce/offline-repo` altına koy; kurulumu tekrar çalıştır | -| 2 | `packages/` dolu ama indeks (`built/`) yok | Aynı "depo yok" hatası: aday eksiksiz sayılmaz | Kurucu `Packages`, `Packages.gz`, `packages.lock.tsv`, `SHA256SUMS` dosyalarının **hepsini** arar; yalnız `.deb` havuzu yetmez. Repo sorumlusu `build-index.sh` ile `built/` dizinini üretip commit eder | -| 3 | Depo bulundu ama bozuk/eksik | `OFFLINE BLOCKED: ` + Türkçe çıkış nedeni (ör. checksum doğrulaması başarısız, kilit biçimi bozuk, pinlenen paket depoda yok); çıkış kodu `1` | Sahada onarılmaz: repo sorumlusu seti `add-packages.sh` + `build-index.sh` ile yeniden üretir, güncel git tarafı çekilir ve komut tekrarlanır. Cihazda hiçbir değişiklik yapılmadı | -| 4 | `BF_OFFLINE_REPO` yanlış | `OFFLINE BLOCKED: BF_OFFLINE_REPO mutlak bir yerel dizin olmalıdır.` | Değişkeni kaldır (varsayılan arama sırası kullanılır) veya geçerli bir mutlak dizin ver | -| 5 | "--offline bayrağını vermedim" kuşkusu | — | Bayrak **zorunlu değil**: kurucu depoyu her durumda otomatik arar (sıra: `BF_OFFLINE_REPO` → `/provisioning/offline-repo/built` → `/opt/blueforce/offline-repo`) | - -Yükseltme: paket seti sahada üretilmez/onarılmaz. İki denemede de aynı hata varsa cihazda hata ayıklamayı bırak, repo sorumlusuna başvur; kurulum paket adımlarına geçmediği için cihaz yarım durumda **kalmaz** (fail-closed). - -## 10. Test Planı - -| Test | Beklenen sonuç | Ortam | -|---|---|---| -| Her T girdisindeki ilk tanı komutu LAB'da çalışır | Komut + örnek çıktı bu dosyada | LAB(2) | -| WG-kill → T-3 → L4 onarım tatbikatı | < hedef sürede tünel döner | P1(5) | -| Disk-doldur simülasyonu → T-8 | Alarm + temizlik çalışır | LAB(2) | -| `DISPLAY` yok + `python3-tk` yok senaryoları → T-10 | Her iki durumda Türkçe yönlendirme, çıkış kodu 3, `--tui` önerisi | LAB(2) | -| Depo dizini kaldırılmış cihazda kurulum → T-11 | Türkçe hata + aranan yollar + çıkış nedeni, exit 1, sistem değişmez | LAB(2) | -| Yalnız `packages/`, indeks yok → T-11/2 | Kurulum "depo yok" diyerek durur (fail-closed) | LAB(2) | - -## 11. Rollback - -Yanlış çözüm uygulandıysa 17-L3 (son değişikliği geri alma) işletilir; tanı komutlarının kendisi read-only'dir, rollback gerektirmez. - -## 12. Kontrol Listesi - -- [ ] Her girdide Yükseltme kriteri var. -- [ ] Komutların tamamı LAB 26.04.1 imajında denendi. -- [ ] Hiçbir girdide secret/şifre yazılmıyor. -- [ ] MEG'e özgü satırlar 13 ile çapraz linkli. -- [ ] T-10/T-11 çıkış kodları (3 / 1) ve hata metinleri gerçek çıktıyla doğrulandı. -- [ ] Arayüz yolu belirtilen her girdide "Arayüzden" + "Komutla (yedek)" satırı var. - -## 13. Açık Sorular - -- [ ] Kesin `bf-gui-*` birim adları + GNOME oturum satırı — LAB çıktısı (sahibi: Faz 3). -- [ ] MEG log hacmi ölçümü → T-8 eşikleri (sahibi: 12-DOCKER yazarı). -- [ ] Turkcell CGNAT tanı notları — P1 verisi (sahibi: 08-WIREGUARD yazarı). -- [ ] `python3-tk` dışındaki grafik bağımlılıklarının da offline pinde gerekli olup olmadığı — LAB doğrulaması (sahibi: 27 + 33 yazarları). - ---- - -## Ek: Triyaj Akışı (hangi T girdisi?) - -```mermaid -flowchart TB - SORUN["Arıza bildirimi
    bf-status + systemctl --failed"] --> ARAC{"Sorun cihaz mı,
    operatör arayüzü mü?"} - ARAC -->|"Arayüz açılmıyor"| T10["T-10: DISPLAY / tkinter → sudo bf --tui"] - ARAC -->|"Kurulum paket adımına geçmiyor"| T11["T-11: yerel paket deposu"] - ARAC -->|Cihaz| ACILIR{"Cihaz açılıyor mu?"} - ACILIR -->|Hayır| T1["T-1: güç / BIOS / disk"] - ACILIR -->|Evet| BOOTOK{"Servisler kalkıyor mu?"} - BOOTOK -->|Hayır| T2["T-2: failed servis / disk dolu"] - BOOTOK -->|Evet| WG{"wg show handshake güncel mi?"} - WG -->|Hayır| T3["T-3: WireGuard + L4 yedek kanal"] - WG -->|Evet| SSH{"SSH oluyor mu?"} - SSH -->|Hayır| T4["T-4: sshd / anahtar / UFW"] - SSH -->|Evet| RDP{"RDP açılıyor mu?"} - RDP -->|Hayır| T5["T-5: bf-gui-on / xRDP"] - RDP -->|Evet| DERIN["T-6+: Docker / MEG / disk (T-7/T-8)"] -``` -
    - # 24 — Karar Günlüğü (Decision Log) @@ -73706,185 +60354,43 @@ flowchart LR ``` - -# First boot — terminal wizard, kimlik koruması ve yarıda kalan kurulum + +[Unit] +Description=Blueforce Field OS first-boot offline provisioning (terminal wizard) +Documentation=file:/usr/share/doc/blueforce/firstboot/README.md +After=multi-user.target +# Prerequisites that must exist before the wizard may run: the seed-provided administrator +# public key is mandatory for the offline installer. The dealer number is NOT a condition, +# because a technician at the field console enters it through the wizard. +ConditionPathExists=/etc/blueforce/admin-authorized-key.pub +# Run once per machine: once the completion marker exists the unit is skipped (not failed). +ConditionPathExists=!/var/lib/blueforce/firstboot-complete -Bu dizin, cihazın ilk açılışında sahada çalışan giriş noktasını barındırır: +[Service] +Type=oneshot +Environment=BF_ADMIN_PUBLIC_KEY_FILE=/etc/blueforce/admin-authorized-key.pub +ExecStart=/usr/local/sbin/bf-firstboot +# The wizard reads and writes the field console. If no TTY can be set up (headless unit, +# no console attached) systemd fails this unit and boot continues; the script itself also +# falls back to the --dealer-id/--dealer-id-file path when no terminal is available. +StandardInput=tty-force +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +# Upper bound so a console that nobody answers can never hold the boot job open. The +# wizard's own input read is bounded too (BF_FIRSTBOOT_INPUT_TIMEOUT). +TimeoutStartSec=45min +RemainAfterExit=yes +# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes +# and similar sandboxing are deliberately absent: the installer legitimately writes /etc, +# /usr and the identity-derived /home/bf-, and those options would break apt/dpkg and the user module. +NoNewPrivileges=yes +PrivateTmp=yes -- `bf-firstboot` — bayi numarasını toplayan/doğrulayan ve yerel offline kurucuyu bir kez çalıştıran script. Kurucu paket deposunu kendisi bulur; internet gerekmez, teknisyen hiçbir şey indirmez. -- `blueforce-firstboot.service` — boot'ta script'i bir kez çalıştıran systemd unit'i. -- Bu doküman — gerçek davranış, otomasyon yolu, kimlik değişimi prosedürü ve secret kuralları. - -İlk boot, yalnız Subiquity'nin assisted storage onayıyla tamamlanan kurulumdan ve yeniden başlatmadan sonra çalışır. -Kimlik formatı `^[0-9]{8}$` (`docs/02-DEVICE-NAMING-AND-INVENTORY.md`) ve merkezi durumlar -(`PROVISIONED_OFFLINE` → `ENROLLED` → `READY`, `docs/29-DEVICE-ENROLLMENT.md`) bu adımın dışındadır: -firstboot yalnız offline provision'ı yapar, enrollment ve READY ayrı adımlardır. - -## 1. Wizard (teknisyen ekranı) - -Konsolda (TTY mevcut ve otomasyon istenmemiş) teknisyen şu ekranı görür: - -```text -# BLUEFORCE FIELD OS -8 Haneli Bayi Numarası: -> _ -``` - -Davranış: - -- Girdi `^[0-9]{8}$` ile doğrulanır. **Hatalı girdi kabul edilmez**; hata satırı ve `(deneme N/3)` ile yeniden sorulur. - Yalnız baştaki/sondaki boşluk kırpılır (yapıştırma artığı); değerin içinde boşluk veya rakam dışı karakter varsa geçersiz sayılır. -- 3 hatalı denemeden sonra script net bir hatayla `exit 2` verir ve hiçbir değişiklik yapmaz - (`BF_FIRSTBOOT_MAX_ATTEMPTS` ile deneme sayısı değiştirilebilir). -- Doğru girdide onay ekranı gösterilir; onay `e`/`evet`/`y`/`yes` ile alınır: - -```text -Device ID (cihaz kimliği): BF- -Hostname (makine adı) : bf- -Kurulum bu kimlikle başlatılsın mı? [e/H]: -``` - -- Onay verilmezse kurulum başlamaz; deneme hakkı tükenirse `exit 2`. -- `read` zaman aşımına uğrarsa (varsayılan 300 sn, `BF_FIRSTBOOT_INPUT_TIMEOUT`) script - "no terminal input arrived" hatasıyla `exit 2` verir; konsolda kimse olmadığında sonsuza kadar bloklamaz. - -TTY seçimi: stdin zaten bir terminal ise (systemd `StandardInput=tty-force` durumu) fd 0 kullanılır; -değilse `/dev/tty` açılır; o da açılamıyorsa wizard atlanır ve argüman/dosya yoluna düşülür. - -## 2. Kimlik çözümleme sırası - -| # | Kaynak | Mod | -|---|---|---| -| 1 | `--dealer-id <8hane>` | non-interactive (otomasyon) | -| 2 | `--dealer-id-file ` | non-interactive (otomasyon) | -| 3 | `--interactive` | wizard (TTY zorunlu) | -| 4 | `BF_FIRSTBOOT_NONINTERACTIVE=1` | non-interactive; seed dosyası (`/etc/blueforce/dealer-id`) okunur | -| 5 | Seed dosyası `/etc/blueforce/dealer-id` geçerliyse | non-interactive; wizard atlanır (log satırı yazılır) | -| 6 | TTY mevcut | wizard | -| 7 | Hiçbiri | net hata + `exit 2` (argüman/dosya önerilir) | - -Otomasyon yolu (1, 2, 4) hiçbir soru sormaz: seed/USB ile kurulan cihazda teknisyen girdisi gerekmez. -1. ve 2. maddeler açıkça verildiği için onay ekranı çıkmaz; bu bilinçli bir otomasyon kapısıdır. - -### Komut arayüzü - -```bash -sudo bf-firstboot # wizard (konsol) -sudo bf-firstboot --dealer-id <8hane> # otomasyon -sudo bf-firstboot --dealer-id-file /path/to/dealer-id -sudo bf-firstboot --check --dealer-id <8hane> # salt-okunur denetim -sudo bf-firstboot --interactive # seed dosyası olsa bile wizard -BF_FIRSTBOOT_NONINTERACTIVE=1 sudo bf-firstboot # boot/otomasyon -``` - -Ortam değişkenleri: `BF_FIRSTBOOT_MARKER` (marker yolu), `STATE_DIR` (varsayılan `/var/lib/blueforce`), -`BF_INSTALLER_PATH` (kurucu yolu), `BF_ADMIN_PUBLIC_KEY_FILE`, `BF_FIRSTBOOT_MAX_ATTEMPTS`, -`BF_FIRSTBOOT_INPUT_TIMEOUT`, `BF_FIRSTBOOT_TEST_MODE`, `BF_FIRSTBOOT_NONINTERACTIVE`. - -### Çıkış kodları - -| Kod | Anlam | -|---|---| -| 0 | Kurulum tamam (marker yazıldı) veya zaten tamamlanmış (idempotent) veya `--check` raporu | -| 1 | Offline önkoşul eksik (admin public key), root yok, kurucu yok, veya kurucu hata verdi | -| 2 | Girdi hatası: geçersiz bayi numarası, terminal yok, onay verilmedi, deneme tükendi | - -## 3. İdempotentlik ve yarıda kalan kurulum - -- Marker: `/var/lib/blueforce/firstboot-complete` (mode `600`, içinde bayi numarası). -- Marker varsa script **hiçbir şey yapmaz**: sormaz, yazmaz, kurucuyu çağırmaz, `exit 0`. Wizard bu durumda asla açılmaz. -- Marker varken farklı bir bayi numarası verilirse identity değişmez; yalnız uyarı yazılır ve admin prosedürüne - (§4) işaret edilir. Sıradan kullanıcı yoluyla kimlik değiştirilemez. -- Marker yalnız kurucu başarıyla bittikten sonra yazılır (`set -euo pipefail` sayesinde hata hâlinde yazılmaz). -- Önceki koşu yarıda kaldıysa (`$STATE_DIR/install-state` dolu) kurucu `--resume` ile çağrılır: - kurucu `--offline --dealer-id --yes` (+ gerekirse `--resume`) argümanlarıyla koşar. -- Script kurucuyu `--offline` ile çağırır. **Bu bayrak artık zorunlu değildir**: kurucu yerel - paket deposunu kendisi bulur (`BF_OFFLINE_REPO` → `/provisioning/offline-repo/built` → - `/opt/blueforce/offline-repo`). firstboot bayrağı geriye uyumluluk için verir; sonuç aynıdır: - yerel depodan, internet olmadan kurulum ve `PROVISIONED_OFFLINE` durumu. -- `--check` hiçbir dosya/dizin/log üretmez ve kurucuyu çağırmaz. - -### Depo bulunamazsa / doğrulanamazsa - -firstboot kurucuyu çağırır; paket deposuna ilişkin karar ve mesaj **kurucunun** sorumluluğundadır. -İki farklı durum vardır ve ikisinde de firstboot marker **yazmaz** (cihaz provision edilmiş sayılmaz): - -| Durum | Kim söyler | Ekranda ne görünür (kısaltılmış) | Sonuç | -|---|---|---|---| -| Yerel depo hiç yok (üç arama yerinde de yok) | Kurucu | `HATA: Yerel paket deposu yok. Repo sorumlusuna başvurun: provisioning/offline-repo/README.md` … `ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.` | `exit 1`; hiçbir şey değişmez | -| Depo bulundu ama doğrulanamadı (checksum, kilit, eksik pin) | Kurucu | `OFFLINE BLOCKED: ` … `ÇIKIŞ NEDENİ: yerel paket deposu doğrulanamadı…` | `exit 1`; hiçbir şey değişmez | -| Admin public key eksik/geçersiz | firstboot'un kendi kapısı | `OFFLINE BLOCKED: seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required.` (veya anahtar kuralı satırı) | `exit 1`; kurucu hiç çağrılmaz | - -Çözüm sahadaki teknisyenin işi değildir: mesajı repo sorumlusuna iletmek ve depo düzeltildikten -sonra kurulumu tekrar çalıştırmak yeterlidir. Kurulumu yapan kişi hiçbir şey indirmez; paketler -git deposunda taşınır. - -## 4. Kimlik değişimi prosedürü (admin / recovery — varsayılan KAPALI) - -Bayi numarası değişimi **sıradan kullanıcı yolu değildir**. Wizard ve normal koşu bu yolu içermez. -Yalnız root + açık operatör kapısı + yazılı onay + audit kaydı ile çalışır: - -1. Fiziksel konsolda root oturumu açılır ve operatör kapısı açıkça verilir: `BF_ALLOW_REIDENTITY=1`. -2. Komut: `BF_ALLOW_REIDENTITY=1 sudo -E bf-firstboot --force-reidentity --dealer-id `. -3. Ekranda eski/yeni kimlik ve "dokuz merkezi sistem güncellenmeli" uyarısı gösterilir. -4. Onay, **yeni bayi numarasının yeniden yazılması** ile alınır; eşleşmezse hiçbir değişiklik yapılmaz (`exit 2`). -5. Kurucu yeni kimlikle koşar, eski marker `firstboot-complete.reidentified-` olarak saklanır, - yeni marker yazılır. -6. Her adım `/var/log/blueforce-reidentity.log` (mode `600`, UTC zaman + `uid=`) ve journal'a yazılır: - kim, ne zaman, eski → yeni. Audit log yazılamıyorsa değişim **reddedilir** (fail-closed). - -Kapılar: root değilse `exit 1`; `BF_ALLOW_REIDENTITY=1` yoksa `exit 1`; TTY yoksa `exit 2`. -Değişimden sonra `docs/02-DEVICE-NAMING-AND-INVENTORY.md` §9'daki yeniden-numaralandırma adımları -(WireGuard peer, RustDesk/MeshCentral etiketi, Ansible inventory, monitoring etiketi) uygulanır. - -## 5. Secret kuralları - -- `BF_ADMIN_PUBLIC_KEY_FILE` **yalnız public SSH key** içerebilir; root sahipliğinde ve grup/dünya - tarafından yazılamaz olmalıdır (`root:600` veya `root:640`). Private key, inline anahtar veya - boş/okunamayan dosya `OFFLINE BLOCKED` ile reddedilir. -- Bu kural değiştirilmedi: dosya `ssh-ed25519`/`ssh-rsa`/`ssh-ecdsa`/`sk-ssh-ed25519` public key satırı - ile başlamalı ve `PRIVATE KEY` içermemelidir. -- Script secret **loglamaz**: mesajlarda yalnız dosya yolları ve cihaz kimlikleri geçer; anahtar içeriği, - token, parola hiçbir satıra yazılmaz (`--force-reidentity` audit kaydı da yalnız kimlikleri yazar). -- Wizard girdisi ekrana yankılanır (teknisyen yazar); bu ekran token/parola istemez, yalnız bayi numarası alır. -- Kurucu, yerel paket deposunu kendisi bulur (`BF_OFFLINE_REPO` → `/provisioning/offline-repo/built` - → `/opt/blueforce/offline-repo`); `--offline` bayrağı gerekmez. Internet kurulum için gerekmez. - Public-key önkoşulu geçtikten sonra: - * depo hiç yoksa kurucu Türkçe `HATA: Yerel paket deposu yok…` + `ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.` ile durur; - * depo bulunup da doğrulanamazsa (geçerli yerel APT indeksleri, checksum/lock ya da tüm somut - pinli paketler eksik/bozuk) kurucu `OFFLINE BLOCKED: ` ile durur. - Her iki durumda cihaz provision edilmiş sayılmaz ve firstboot marker yazmaz. Yalnız yerel depo - + public-key önkoşulları geçerse `PROVISIONED_OFFLINE` yazılır. Ayrıntı: §3 ve - `provisioning/offline-repo/README.md` §3. - -## 6. systemd unit ve boot'u kilitlememe - -`blueforce-firstboot.service`: - -- `ConditionPathExists=/etc/blueforce/admin-authorized-key.pub` — zorunlu seed önkoşulu yoksa unit hiç başlamaz. -- `ConditionPathExists=!/var/lib/blueforce/firstboot-complete` — provision edilmiş cihazda atlanır (başarısız sayılmaz). -- `StandardInput=tty-force` + `TTYPath=/dev/tty1` — wizard sahada konsolda görünür. -- TTY kurulamazsa (konsolsuz/headless sistem) systemd unit'i başarısız sayar, **boot devam eder**; - script de TTY yoksa argüman/dosya yoluna düşer. -- `TimeoutStartSec=45min` — konsolda kimse yoksa job sonsuza kadar açık kalmaz. -- `Type=oneshot`, `RemainAfterExit=yes`, `WantedBy=multi-user.target` — boot'ta bir kez çalışır. -- `NoNewPrivileges=yes`, `PrivateTmp=yes` korunur. `ProtectSystem=strict` ve `ProtectHome=yes` - **bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve `/home/blueforce` altına yazar. - -Not: unit çıktısı konsol TTY'sine gider; aynı satırlar `logger -t bf-firstboot` ile journal'a da yazılır, -kurucu kendi logunu `/var/log/blueforce-install.log` içinde tutar. - -## 7. Test - -```bash -bash tests/test-firstboot-static.sh -``` - -Statik test, gerçek systemd/apt işlemi yapmadan şunları doğrular: bayi numarası regex'i, hatalı girdinin -reddi, wizard ekranı ve onay akışı (pty ile), marker idempotentliği, kimlik değişiminin kapalı olması, -public-key önkoşulu ve secret sızıntısı olmadığı, `--check` ve `BF_FIRSTBOOT_TEST_MODE` modlarının -mutasyonsuzluğu. Root gerektiren senaryolar `unshare -r` ile ayrı bir kullanıcı ad alanında koşar; -bu izin yoksa ilgili testler `SKIP` olarak raporlanır. +[Install] +WantedBy=multi-user.target @@ -84211,6 +70717,49 @@ repository: packages: [] + +#!/usr/bin/env bash +# Independently verify remote, network, and central evidence; never trusts READY state. +set -euo pipefail +STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}" +RUSTDESK_SYSTEM_CONFIG="${BF_RUSTDESK_SYSTEM_CONFIG:-/etc/rustdesk/RustDesk2.toml}" +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}" +MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}" +CHECK_MODE=0 +[[ "${1:-}" == --check && $# -eq 1 ]] && CHECK_MODE=1 +[[ $# -eq 0 || "$CHECK_MODE" == 1 ]] || { echo 'Usage: bf-remote-status [--check]' >&2; exit 2; } +[[ "$MAX_AGE" =~ ^[1-9][0-9]*$ ]] || { echo 'BF_WG_HANDSHAKE_MAX_AGE must be positive seconds' >&2; exit 2; } +valid_endpoint() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] && [[ "$1" != *example* && "$1" != *invalid* && "$1" != *'<'* && "$1" != *'>'* && "$1" != localhost* ]]; } +rustdesk_configured() { + local endpoint + endpoint="$(sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$RUSTDESK_SYSTEM_CONFIG" 2>/dev/null | head -1)" + valid_endpoint "$endpoint" && grep -Fqx "relay-server = '$endpoint'" "$RUSTDESK_SYSTEM_CONFIG" && grep -Fqx "rendezvous-server = '$endpoint'" "$RUSTDESK_USER_CONFIG" && grep -Fqx "relay-server = '$endpoint'" "$RUSTDESK_USER_CONFIG" +} +wg_fresh() { wg show wg0 latest-handshakes 2>/dev/null | awk -v now="$(date +%s)" -v max="$MAX_AGE" '$2 > 0 && now-$2 <= max {ok=1} END {exit !ok}'; } +central_verified() { python3 - "$STATE_FILE" <<'PY' +import json,sys +try: + c=json.load(open(sys.argv[1],encoding='utf-8'))['central_verification'] + ok=(c.get('status')=='verified' and bool(c.get('verification_id')) and all(isinstance(c.get(n),dict) and c[n].get('status')=='verified' and bool(c[n].get('verification_id')) for n in ('monitoring','remote','management'))) + raise SystemExit(0 if ok else 1) +except Exception: raise SystemExit(1) +PY +} +fail=0 +systemctl is-enabled --quiet xrdp 2>/dev/null && systemctl is-active --quiet xrdp 2>/dev/null && systemctl is-enabled --quiet xrdp-sesman 2>/dev/null && systemctl is-active --quiet xrdp-sesman 2>/dev/null && xrdp='enabled-active' || { xrdp='not-ready'; fail=1; } +dpkg-query -W -f='${Status}' rustdesk 2>/dev/null | grep -q 'install ok installed' && rustdesk_configured && rustdesk='configured' || { rustdesk='not-ready'; fail=1; } +wg_fresh && wireguard='fresh-handshake' || { wireguard='not-ready'; fail=1; } +if [[ -e "$MESH_CONFIG" ]]; then systemctl is-enabled --quiet meshagent 2>/dev/null && systemctl is-active --quiet meshagent 2>/dev/null && mesh='configured-active' || { mesh='not-ready'; fail=1; }; else mesh='not-configured'; fi +central_verified && central='verified' || { central='PENDING'; fail=1; } +printf 'xRDP: %s\nRustDesk: %s\nWireGuard: %s\nMesh: %s\nCentral verification: %s\n' "$xrdp" "$rustdesk" "$wireguard" "$mesh" "$central" +[[ "$fail" -eq 0 ]] + + #!/usr/bin/env bash # Pre-flight checks: root, Ubuntu release, RAM and disk. @@ -84268,7 +70817,11 @@ report_network() { } run_checks() { - [[ "$(id -u)" -eq 0 ]] || fail "must run as root" + if [[ "$CHECK_MODE" -eq 0 ]]; then + [[ "$(id -u)" -eq 0 ]] || fail "must run as root" + else + info "yönetici yetkisi: salt-okunur denetim; root gerekmiyor" + fi if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release @@ -84288,6 +70841,127 @@ run_checks() { run_checks + +#!/usr/bin/env bash +# Dealer-specific Blueforce user, restricted sudoers, mandatory administrator key. +set -euo pipefail +MODULE="03-user" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# 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_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; } +valid_key_file() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } +load_admin_key() { + [[ -z "$BF_ADMIN_PUBLIC_KEY" && -n "$BF_ADMIN_PUBLIC_KEY_FILE" && -r "$BF_ADMIN_PUBLIC_KEY_FILE" ]] || { log "FAIL: a seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required; inline keys are refused"; return 1; } + [[ "$(stat -c '%U:%a' "$BF_ADMIN_PUBLIC_KEY_FILE")" =~ ^root:6[04]0$ ]] || { log "FAIL: administrator key file must be root-owned and not group/world writable"; return 1; } + grep -qE '^(ssh-(ed25519|rsa|ecdsa)|sk-ssh-ed25519)' "$BF_ADMIN_PUBLIC_KEY_FILE" && ! grep -q 'PRIVATE KEY' "$BF_ADMIN_PUBLIC_KEY_FILE" || { log "FAIL: administrator key file must contain a public SSH key only"; return 1; } + cat "$BF_ADMIN_PUBLIC_KEY_FILE" +} +if [[ "$CHECK_MODE" -eq 1 ]]; then + id "$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; } + valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; } + log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0 +fi +key_data="$(load_admin_key)" || exit 1 +[[ -n "${key_data//[[:space:]]/}" ]] || { log "FAIL: administrator key is empty"; exit 1; } +tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT +printf '%s\n' "$key_data" > "$tmp_key" +ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; } +if id "$BF_USER" >/dev/null 2>&1; then + if id blueforce >/dev/null 2>&1 && [[ "$BF_USER" != blueforce ]]; then + log "FAIL: both legacy blueforce and target $BF_USER accounts exist; refusing automatic merge" + 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; } +cat > "/tmp/$BF_USER.sudoers" < + + +#!/usr/bin/env bash +# Set hostname bf- from validated dealer number. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="04-hostname" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../identity.sh +source "$SCRIPT_DIR/identity.sh" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [04-hostname] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +[[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; } +bf_valid_dealer_id "$DEALER_ID" || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; } +TARGET="$(bf_hostname "$DEALER_ID")" + +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; } + log "OK(check): hostname is $TARGET" + exit 0 +fi + +if [[ "$(hostname)" == "$TARGET" ]]; then + log "hostname already $TARGET, keeping" +else + hostnamectl set-hostname "$TARGET" + if grep -q "127.0.1.1" /etc/hosts; then + sed -i "s/^127.0.1.1.*/127.0.1.1\t$TARGET/" /etc/hosts + else + echo -e "127.0.1.1\t$TARGET" >> /etc/hosts + fi + log "hostname set to $TARGET" +fi +mkdir -p "$STATE_DIR" +echo "$DEALER_ID" > "$STATE_DIR/dealer-id" +echo "$TARGET" > "$STATE_DIR/hostname" +echo "$(bf_device_id "$DEALER_ID")" > "$STATE_DIR/device-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)" + + #!/usr/bin/env bash # Network report for the field device. It separates three things that used to be @@ -84338,6 +71012,203 @@ log 'OK: ağ raporu tamam; kurulum internetsiz tamamlanabilir' exit 0 + +#!/usr/bin/env bash +# Harden sshd only after a validated Blueforce administrator key is installed. +set -euo pipefail +MODULE="07-ssh" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +source "$(dirname "$0")/../identity.sh" +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"; } +key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; } +ADMIN_KEY="${BF_ADMIN_PUBLIC_KEY:-}" +ADMIN_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}" +install_admin_key() { + [[ -n "$ADMIN_KEY" || -n "$ADMIN_KEY_FILE" ]] || return 0 + [[ -z "$ADMIN_KEY" ]] || printf '%s\n' "$ADMIN_KEY" > /tmp/blueforce-admin-key + [[ -z "$ADMIN_KEY_FILE" ]] || { [[ -r "$ADMIN_KEY_FILE" ]] || { log "FAIL: BF_ADMIN_PUBLIC_KEY_FILE is unreadable"; exit 1; }; cp "$ADMIN_KEY_FILE" /tmp/blueforce-admin-key; } + ssh-keygen -l -f /tmp/blueforce-admin-key >/dev/null 2>&1 || { log "FAIL: BF_ADMIN_PUBLIC_KEY is not a valid public key"; exit 1; } + install -d -m 700 -o "$BF_USER" -g "$BF_USER" "$BF_HOME/.ssh" + install -m 600 -o "$BF_USER" -g "$BF_USER" /tmp/blueforce-admin-key "$AUTHORIZED_KEYS" +} +read -r -d '' WANT <<'EOF' || true +# Managed by blueforce-installer (07-ssh). Local edits will be overwritten. +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +ChallengeResponseAuthentication no +UsePAM yes +X11Forwarding no +ClientAliveInterval 300 +ClientAliveCountMax 2 +MaxAuthTries 3 +AllowUsers $BF_USER +EOF +if [[ "$CHECK_MODE" -eq 1 ]]; then + [[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; } + key_ready || { log "FAIL(check): no valid non-empty $BF_USER authorized_keys"; 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; } + log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0 +fi +export DEBIAN_FRONTEND=noninteractive +command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server +install_admin_key +key_ready || { log "FAIL: valid non-empty $AUTHORIZED_KEYS or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; } +printf '%s\n' "$WANT" > "/tmp/99-${BF_USER}.conf.bf" +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; } +if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi +systemctl enable "$SVC" >/dev/null 2>&1 || true +systemctl reload-or-restart "$SVC" +log "OK: hardened SSH installed after valid key verification; AllowUsers=$BF_USER" + + + +#!/usr/bin/env bash +# xRDP + GNOME session, terminal boot default, multi-user. +# Idempotent: safe to re-run. Supports --check for read-only verification. +# Exit codes: 0 = OK, 3 = SKIP (optional step, nothing to do). +set -euo pipefail + +MODULE="08-rdp" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +DEALER_ID="${DEALER_ID:-}" +BF_HOSTNAME="${BF_HOSTNAME:-}" +BF_DEVICE="${BF_DEVICE:-}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +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 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" + +log() { + local msg="$1" + mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [08-rdp] $msg" | tee -a "$LOG_FILE" +} + +CHECK_MODE=0 +if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s xrdp >/dev/null 2>&1 || { log "FAIL(check): xrdp not installed"; exit 1; } + systemctl is-enabled --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not enabled"; exit 1; } + systemctl is-active --quiet xrdp 2>/dev/null || { log "FAIL(check): xrdp not active"; exit 1; } + systemctl is-enabled --quiet xrdp-sesman 2>/dev/null || { log "FAIL(check): xrdp-sesman not enabled"; exit 1; } + systemctl is-active --quiet xrdp-sesman 2>/dev/null || { log "FAIL(check): xrdp-sesman not active"; exit 1; } + [[ "$(systemctl get-default)" == "multi-user.target" ]] || { log "FAIL(check): default target is not multi-user"; exit 1; } + log "OK(check): xrdp installed+enabled, terminal boot default" + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +dpkg -s xrdp >/dev/null 2>&1 || bf_apt_install xrdp +dpkg -s gnome-session >/dev/null 2>&1 || bf_apt_install gnome-session + +# Default GNOME session for RDP logins (per-user override still wins). +if [[ ! -f /etc/skel/.xsession ]]; then + echo "gnome-session" > /etc/skel/.xsession +fi +if id "$BF_USER" >/dev/null 2>&1 && [[ ! -f "$BF_HOME/.xsession" ]]; then + echo "gnome-session" > "$BF_HOME/.xsession" + chown "$BF_USER:$BF_USER" "$BF_HOME/.xsession" +fi + +# Module 13 starts xRDP only after the UFW WireGuard-only baseline succeeds. +systemctl set-default multi-user.target +log "OK: xrdp installed and configured; module 13 will enable it after firewall baseline" + + + +#!/usr/bin/env bash +# RustDesk is a required remote channel: only a pinned supplied package is accepted. +set -euo pipefail +MODULE="09-rustdesk" +LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" +STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" +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 +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh" +CHECK_MODE=0 +[[ "${1:-}" == "--check" ]] && CHECK_MODE=1 +RUSTDESK_SERVER="${RUSTDESK_SERVER:-}" +RUSTDESK_DEB="${RUSTDESK_DEB:-}" +SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml" +USER_CONFIG="$BF_HOME/.config/RustDesk/RustDesk2.toml" +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; } +valid_server() { + local endpoint="$1" host port + [[ -n "$endpoint" ]] || return 1 + [[ "$endpoint" != *example* && "$endpoint" != *invalid* && "$endpoint" != *REPLACE* && "$endpoint" != *replace* && "$endpoint" != *'<'* && "$endpoint" != *'>'* && "$endpoint" != localhost* ]] || return 1 + [[ "$endpoint" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[1-9][0-9]{0,4})?$ ]] || return 1 + host="${endpoint%%:*}" + [[ -n "$host" ]] || return 1 + if [[ "$endpoint" == *:* ]]; then + port="${endpoint##*:}" + ((10#$port <= 65535)) || return 1 + fi +} +config_endpoint() { + sed -n "s/^rendezvous-server = '\([^']*\)'$/\1/p" "$SYSTEM_CONFIG" 2>/dev/null | head -n 1 +} +configured() { + local endpoint + endpoint="$(config_endpoint)" + valid_server "$endpoint" || return 1 + grep -Fqx "rendezvous-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && + grep -Fqx "relay-server = '${endpoint}'" "$SYSTEM_CONFIG" >/dev/null 2>&1 && + grep -Fqx "rendezvous-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 && + grep -Fqx "relay-server = '${endpoint}'" "$USER_CONFIG" >/dev/null 2>&1 +} +if [[ "$CHECK_MODE" -eq 1 ]]; then + dpkg -s rustdesk >/dev/null 2>&1 || { log "FAIL(check): rustdesk remote channel is not installed"; exit 1; } + configured || { log "FAIL(check): RustDesk client config is missing, inconsistent, or uses a placeholder/non-self-hosted endpoint"; exit 1; } + rustdesk --get-id 2>/dev/null | grep -qE '^[0-9]+$' || { log "FAIL(check): RustDesk client cannot provide a device ID"; exit 1; } + log "OK(check): RustDesk remote channel is installed with a self-hosted client configuration" + exit 0 +fi +valid_server "$RUSTDESK_SERVER" || { log "FAIL: RUSTDESK_SERVER must be a non-placeholder self-hosted host or host:port"; exit 1; } +export DEBIAN_FRONTEND=noninteractive +if [[ "${BF_OFFLINE:-0}" == 1 ]]; then + dpkg -s rustdesk >/dev/null 2>&1 || bf_apt_install rustdesk +else + [[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; } + dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; } +fi +for cfgdir in /etc/rustdesk "$BF_HOME/.config/RustDesk"; do + install -d -m 700 "$cfgdir" + cat > "$cfgdir/RustDesk2.toml" </dev/null || true +configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; } +install -d -m 700 "$STATE_DIR" +rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true +[[ -s "$STATE_DIR/rustdesk-id" ]] || { log "FAIL: RustDesk client did not provide a device ID"; exit 1; } +log "OK: RustDesk pinned package installed and self-hosted channel configured" + + #!/usr/bin/env bash # Final lifecycle gate: offline completes locally; READY requires enrolled remote proof. @@ -85004,1106 +71875,4997 @@ fi exit "$exit_code_sum" - -#!/usr/bin/env python3 -"""bfos.ui_gui -- statik testler (stdlib unittest, pencere ACMAZ). + +#!/usr/bin/env bash +# Exercise the Field OS lifecycle, enrollment and readiness contracts with mocked local services. +# +# This host has no systemd, no apt and no internet, so every external dependency +# (systemctl, wg, ip, dpkg-query, curl) is a mock in $TMP/bin. Scenarios: +# +# 1 secret-safe enrollment request/header/response path +# 2 offline installer fails closed without a local repository +# 3 failed WireGuard activation rolls back and keeps the prior phase +# 4 ROLLBACK/READY: independent proof, fail-closed without central evidence +# 5 skeleton release manifest is never a production release +# 6 state machine: forward-only, no stage skipping, contract validation +# 7 bf-check-local passes without internet and fails closed on key hygiene +# 8 bf-check-enrollment requires central evidence, peer config and handshake +# 9 replay/backwards protection and atomic 0600 state writes +# 10 systemd attempt: PENDING without a token, bounded retry with one staged +# 11 --check dry run never mutates state or sends a request +# 12 enrollment schemas match the real client behaviour +# 13 boot unit cannot loop, carries no credential and stays bounded +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="$REPO_ROOT/scripts/diagnostics" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +pass() { printf 'OK: %s\n' "$*"; } -Bu testler GUI'yi baslatmaz ve hicbir Tk penceresi olusturmaz; yalnizca -tkinter'siz calisan yardimcilari ve backend sozlesmesini dogrular: +PUBLIC_KEY='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +SERVER_KEY='BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' +DEALER='12345678' +ENDPOINT='https://control.blueforce.test/enroll' - * bayi no dogrulamasi (^[0-9]{8}$) ve Turkce hata metinleri, - * komut onizleme metni (calistirilacak komut onizlemede GORUNUR), - * yikici islem siniflandirmasi (katalog + playbook ile capraz kontrol), - * headless (SSH) tespiti ve tkinter eksikligi mesaji, - * hedef/kapi kurallari: release -> tek cihaz yok, production -> token, - * --check kipi, salt-okunur rapor plani, - * komut calistirma: canli satir akisi + cikis kodu (gercek subprocess), - * tasarim sozlesmesi: tema token'lari web/static/tokens.css ile birebir ayni, - durum sozlugu (KURULDU/KAYITLI/HAZIR/HATA/YOK) ve yetki kapisi gorunurlugu. +make_mock_bin() { + local d="$1" + mkdir -p "$d" + cat > "$d/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$MOCK_CALLS" +case "${1:-}" in + is-active) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; + is-enabled) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; + enable|restart) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;; + get-default) printf 'multi-user.target\n' ;; + *) exit 0 ;; +esac +EOF + cat > "$d/curl" <<'EOF' +#!/usr/bin/env bash +# Mock curl: records argv, captures the request file and the header fd, then +# either writes the canned response or fails like a transport/protocol error. +set -euo pipefail +request='' output='' header='' code='' +while [[ $# -gt 0 ]]; do + case "$1" in + --data-binary) request="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + --write-out) code="$2"; shift 2 ;; + -H|--header) header="$2"; shift 2 ;; + *) printf 'argv=%s\n' "$1" >> "$MOCK_CURL_LOG"; shift ;; + esac +done +[[ "$request" == @* ]] || exit 91 +cat "${request#@}" > "$MOCK_REQUEST_CAPTURE" +[[ "$header" == @* ]] || exit 92 +cat "${header#@}" > "$MOCK_HEADER_CAPTURE" +if [[ "${MOCK_CURL_FAIL:-0}" == 1 ]]; then + printf '%s' "${MOCK_HTTP_CODE:-000}" + exit "${MOCK_CURL_RC:-7}" +fi +printf '%s' "$MOCK_RESPONSE" > "$output" +[[ -z "$code" ]] || printf '%s' "${MOCK_HTTP_CODE:-200}" +EOF + cat > "$d/wg" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + show) printf 'peer %s\n' "${MOCK_HANDSHAKE:-0}" ;; + pubkey) IFS= read -r _key || true; printf '%s\n' "${MOCK_PUBLIC_KEY:-}" ;; + *) exit 0 ;; +esac +EOF + cat > "$d/ip" <<'EOF' +#!/usr/bin/env bash +[[ "${MOCK_IP_FAIL:-0}" == 1 ]] && exit 1 +exit 0 +EOF + cat > "$d/dpkg-query" <<'EOF' +#!/usr/bin/env bash +printf 'install ok installed' +EOF + chmod +x "$d"/* +} -Calistirma: - python3 tests/test_bfos_gui_static.py -v +MOCK_BIN="$TMP/bin"; make_mock_bin "$MOCK_BIN" +export PATH="$MOCK_BIN:$PATH" MOCK_CALLS="$TMP/calls" MOCK_CURL_LOG="$TMP/curl-argv" +export MOCK_REQUEST_CAPTURE="$TMP/request" MOCK_HEADER_CAPTURE="$TMP/header" +export MOCK_PUBLIC_KEY="$PUBLIC_KEY" +STATE_DIR="$TMP/state"; WG_DIR="$TMP/wg"; mkdir -p "$STATE_DIR" "$WG_DIR" +printf '%s\n' "$DEALER" > "$STATE_DIR/dealer-id" +printf '%s\n' 'private-test-key' > "$WG_DIR/privatekey" +printf '%s\n' "$PUBLIC_KEY" > "$WG_DIR/publickey" +chmod 600 "$WG_DIR/privatekey" "$WG_DIR/publickey" "$STATE_DIR/dealer-id" +export STATE_DIR WG_DIR BF_STATE_FILE="$STATE_DIR/state.json" +IDEM="$(python3 -c "import hashlib,sys;print(hashlib.sha256(('p-1|BF-'+sys.argv[1]+'|'+sys.argv[2]).encode()).hexdigest())" "$DEALER" "$PUBLIC_KEY")" + +write_state() { printf '%s\n' "$1" > "$BF_STATE_FILE"; chmod 600 "$BF_STATE_FILE"; } +state_field() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2]))" "$BF_STATE_FILE" "$1"; } +central_pending='{"status":"pending","verification_id":"cv-1","monitoring":{"status":"pending","verification_id":"mon-1"},"remote":{"status":"pending","verification_id":"rem-1"},"management":{"status":"pending","verification_id":"mgmt-1"}}' +central_verified='{"status":"verified","verification_id":"cv-1","monitoring":{"status":"verified","verification_id":"mon-1"},"remote":{"status":"verified","verification_id":"rem-1"},"management":{"status":"verified","verification_id":"mgmt-1"}}' +pending_state="{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" +enrolled_state() { printf '{"phase":"ENROLLED","enrollment_status":"ENROLLED","fleet_status":"PENDING","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; } +ready_state() { printf '{"phase":"READY","enrollment_status":"ENROLLED","fleet_status":"READY","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; } +mocked_response() { printf '{"status":"accepted","device_id":"BF-%s","idempotency_key":"%s","enrollment_id":"enr-1","wireguard":{"address":"10.20.0.17/32","endpoint":"wg.blueforce.test:51820","server_public_key":"%s","allowed_ips":"10.20.0.0/24"},"central_verification":%s}' "$DEALER" "$IDEM" "$SERVER_KEY" "$1"; } + +# --- scenario 1: the secret-safe enrollment path ---------------------------- +write_state "$pending_state" +export MOCK_RESPONSE="$(mocked_response "$central_verified")" +printf '%s\n' 'super-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'successful enrollment must commit ENROLLED' +[[ -s "$WG_DIR/wg0.conf" ]] || fail 'successful enrollment must install wg0.conf' +[[ "$(stat -c '%a' "$WG_DIR/wg0.conf")" == '600' ]] || fail 'wg0.conf must be mode 600' +[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state.json must be mode 600' +grep -q "BF-$DEALER" "$MOCK_REQUEST_CAPTURE" || fail 'curl must consume the generated request file' +grep -Fqx 'Authorization: Bearer super-secret-token' "$MOCK_HEADER_CAPTURE" || fail 'curl must receive the exact supplied token through process substitution' +! grep -R -F 'super-secret-token' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'token persisted outside the transient curl fixture' +! grep -R -F 'super-secret-token' "$MOCK_CURL_LOG" "$STATE_DIR" 2>/dev/null || fail 'token leaked to curl argv, output, or state' +pass 'curl request/header/response path is secret-safe and functional' + +# --- scenario 12 (early check): request body carries public facts only ------- +python3 - "$MOCK_REQUEST_CAPTURE" "$PUBLIC_KEY" <<'PY' +import json, sys +request = json.load(open(sys.argv[1], encoding='utf-8')) +if set(request) != {'device_id', 'wireguard_public_key', 'idempotency_key', 'hardware', 'release'}: + raise SystemExit('unexpected request field set: %s' % sorted(request)) +if request['wireguard_public_key'] != sys.argv[2]: + raise SystemExit('request must carry the local public key') +blob = json.dumps(request).lower() +for banned in ('private', 'secret', 'password', 'passwd', 'bearer', 'authorization'): + if banned in blob: + raise SystemExit('request carries a secret-like field: ' + banned) +PY +! grep -q 'private-test-key' "$MOCK_REQUEST_CAPTURE" || fail 'the WireGuard private key must never be sent' +! grep -q 'super-secret-token' "$MOCK_REQUEST_CAPTURE" || fail 'the one-time token must never be sent in the request body' +pass 'request carries public facts only: no private key, no token, no credential' + +# --- scenario 2: offline installer fails closed ----------------------------- +cat > "$MOCK_BIN/apt-get" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$MOCK_APT_CALLS" +exit 90 +EOF +chmod +x "$MOCK_BIN/apt-get" +export MOCK_APT_CALLS="$TMP/apt-calls" +if STATE_DIR="$TMP/offline-state" LOG_FILE="$TMP/offline.log" BF_OFFLINE_REPO="$TMP/missing-offline-repo" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/offline.out" 2>&1; then fail 'offline installer must reject a missing local repository before modules'; fi +[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'offline repository rejection must not invoke apt-get' +! grep -Eqi '(curl|wget|https?://|apt[[:space:]]+download)' "$TMP/offline.out" "$TMP/offline.log" 2>/dev/null || fail 'offline rejection must not run or report network package acquisition' +pass 'offline installer fails closed before package modules when local repository is absent' + +# --- scenario 2b: a Git-LFS pointer is not a package ------------------------- +# The bundle pool travels through Git LFS. A checkout made with smudge disabled +# (which bf-bootstrap does deliberately, to avoid an 884 MB download) leaves +# small pointer texts where the .deb files belong. They satisfy every existence +# test, so the installer must recognise them by content and say what to run -- +# otherwise the run dies later inside `sha256sum -c` and reads as a corrupt +# bundle rather than unfetched payload. +STUB_REPO="$TMP/stub-offline-repo" +mkdir -p "$STUB_REPO/pool" +printf 'stub index\n' > "$STUB_REPO/Packages" +printf 'stub index\n' > "$STUB_REPO/Packages.gz" +printf 'FAKE-SHA256SUMS\n' > "$STUB_REPO/SHA256SUMS" +printf 'package\tversion\tarchitecture\tsha256\tsource\n' > "$STUB_REPO/packages.lock.tsv" +printf 'curl\t8.5.0-2ubuntu10\tamd64\t%s\tcurl_8.5.0-2ubuntu10_amd64.deb\n' \ + '0000000000000000000000000000000000000000000000000000000000000000' >> "$STUB_REPO/packages.lock.tsv" +cat > "$STUB_REPO/pool/curl_8.5.0-2ubuntu10_amd64.deb" <<'POINTER' +version https://git-lfs.github.com/spec/v1 +oid sha256:0000000000000000000000000000000000000000000000000000000000000000 +size 285342 +POINTER +rm -f "$MOCK_APT_CALLS" +if STATE_DIR="$TMP/stub-state" LOG_FILE="$TMP/stub.log" BF_OFFLINE_REPO="$STUB_REPO" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/stub.out" 2>&1; then fail 'offline installer must reject a bundle whose pool holds LFS pointers, not packages'; fi +grep -qi 'indirilmemiş\|işaretçi' "$TMP/stub.out" || fail 'the LFS-pointer refusal must name the cause (pointer text, not a .deb)' +grep -q 'git lfs pull' "$TMP/stub.out" || fail 'the LFS-pointer refusal must tell the operator the command that fixes it' +grep -qi 'checksum' "$TMP/stub.out" && fail 'an LFS pointer must not be reported as a checksum failure; that misdiagnoses unfetched payload as corruption' +[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'an LFS-pointer rejection must not invoke apt-get' +pass 'offline installer recognises a Git-LFS pointer and names the remedy' + +# --- scenario 3: activation failure rolls back, phase is preserved ---------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" +export MOCK_SYSTEMCTL_FAIL=1 +if printf '%s\n' 'another-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin; then fail 'activation failure must fail enrollment'; fi +[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'activation failure must not commit ENROLLED' +[[ "$(state_field enrollment_status)" == 'PENDING' ]] || fail 'activation failure must leave enrollment PENDING' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'activation failure must roll back a new config' +[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic state writes must leave no temporary files' +unset MOCK_SYSTEMCTL_FAIL +pass 'state transition rolls back on WireGuard activation failure and stays PENDING' + +# --- scenario 4: READY proof is independent and fails closed ---------------- +printf '%s\n' 'third-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +[[ -s "$WG_DIR/wg0.conf" ]] || fail 're-enrollment after a rolled-back attempt must install wg0.conf' +RUST_SYSTEM="$TMP/RustDesk-system.toml"; RUST_USER="$TMP/RustDesk-user.toml" +printf "rendezvous-server = 'rustdesk.blueforce.test'\nrelay-server = 'rustdesk.blueforce.test'\n" > "$RUST_SYSTEM" +cp "$RUST_SYSTEM" "$RUST_USER" +export MOCK_HANDSHAKE="$(date +%s)" BF_RUSTDESK_SYSTEM_CONFIG="$RUST_SYSTEM" BF_RUSTDESK_USER_CONFIG="$RUST_USER" BF_MESH_CONFIG="$TMP/no-mesh" +python3 - "$BF_STATE_FILE" "$central_verified" <<'PY' +import json, sys +path, verified = sys.argv[1], json.loads(sys.argv[2]) +state = json.load(open(path, encoding='utf-8')) +state['central_verification'] = verified +json.dump(state, open(path, 'w', encoding='utf-8')) +PY +"$BIN/bf-check-ready" >/dev/null || fail 'ENROLLED state with independent local and central evidence must become ready-check eligible' +python3 - "$BF_STATE_FILE" <<'PY' +import json, sys +path = sys.argv[1] +state = json.load(open(path, encoding='utf-8')) +del state['central_verification'] +json.dump(state, open(path, 'w', encoding='utf-8')) +PY +if "$BIN/bf-check-ready" > "$TMP/ready-failclosed.out" 2>&1; then fail 'missing central verification evidence must fail closed'; fi +if "$BIN/bf-check-enrollment" > "$TMP/enroll-failclosed.out" 2>&1; then fail 'the ENROLLED gate must fail closed without central evidence'; fi +grep -q 'central_verification is missing' "$TMP/enroll-failclosed.out" || fail 'the ENROLLED gate must name the missing central evidence' +pass 'READY proof is independent of persisted READY and fails closed without central evidence' + +# --- scenario 5: skeleton release manifest ---------------------------------- +RELEASE="$TMP/release.yaml" +printf 'release:\n name: blueforce-provisioning-skeleton\n version: 0.1.0\n status: skeleton\n commit: deadbeef\n' > "$RELEASE" +if BF_RELEASE_MANIFEST="$RELEASE" "$BIN/bf-release" > "$TMP/release.out"; then fail 'skeleton release must be nonzero'; fi +grep -q 'UNAPPROVED_SKELETON' "$TMP/release.out" || fail 'skeleton release must be labelled unapproved' +pass 'nested skeleton manifest is not represented as a production release' + +# --- scenario 6: state machine contract ------------------------------------- +write_state "$pending_state" +[[ "$("$BIN/bf-enrollment-status" --field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'canonical reader must report the provisioned phase' +write_state "$(enrolled_state "$central_pending")" +"$BIN/bf-enrollment-status" | grep -qx 'Enrollment: ENROLLED' || fail 'ENROLLED stage must be visible before READY' +write_state "$(ready_state "$central_verified")" +"$BIN/bf-enrollment-status" > "$TMP/ready-status.out" || fail 'READY with verified central evidence must be contract-valid' +grep -qx 'Lifecycle: READY' "$TMP/ready-status.out" || fail 'READY stage must be reported' +grep -qx 'Enrollment: COMPLETE' "$TMP/ready-status.out" || fail 'READY implies a complete enrollment' +grep -qx 'Fleet: READY' "$TMP/ready-status.out" || fail 'READY implies fleet READY' +write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" +if "$BIN/bf-enrollment-status" > "$TMP/skip.out" 2>/dev/null; then fail 'a skipped stage (PROVISIONED_OFFLINE -> READY) must be INVALID'; fi +grep -qx 'Lifecycle: INVALID' "$TMP/skip.out" || fail 'skipped stage must be reported as INVALID' +write_state "$(ready_state "$central_pending")" +if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'READY without verified channels must be INVALID'; fi +write_state "{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\"}" +if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'a state without provisioning_id must be INVALID'; fi +if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-enrollment-status" > "$TMP/absent.out" 2>/dev/null; then fail 'a missing state file must exit non-zero'; fi +grep -qx 'Lifecycle: PENDING' "$TMP/absent.out" || fail 'a missing state file must report PENDING' +pass 'state machine is forward-only, never skips a stage and validates its contract' + +# --- scenario 6b: bf-status lifecycle block --------------------------------- +write_state "$(enrolled_state "$central_pending")" +BF_STATE_FILE="$BF_STATE_FILE" "$BIN/bf-status" > "$TMP/status.out" 2>/dev/null || fail 'bf-status must always exit 0' +grep -qx 'Provisioning : COMPLETE' "$TMP/status.out" || fail 'bf-status must show Provisioning : COMPLETE once central accepted the device' +grep -qx 'Enrollment : ENROLLED' "$TMP/status.out" || fail 'bf-status must show Enrollment : ENROLLED' +grep -qx 'Fleet State : NOT READY' "$TMP/status.out" || fail 'bf-status must show Fleet State : NOT READY before READY' +write_state "$pending_state" +"$BIN/bf-status" > "$TMP/status-pending.out" 2>/dev/null +grep -qx 'Provisioning : PROVISIONED_OFFLINE' "$TMP/status-pending.out" || fail 'bf-status must show the offline provisioning stage' +grep -qx 'Enrollment : PENDING' "$TMP/status-pending.out" || fail 'bf-status must show a pending enrollment' +write_state "$(ready_state "$central_verified")" +"$BIN/bf-status" > "$TMP/status-ready.out" 2>/dev/null +grep -qx 'Fleet State : READY' "$TMP/status-ready.out" || fail 'bf-status must show Fleet State : READY' +write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}" +"$BIN/bf-status" > "$TMP/status-invalid.out" 2>/dev/null +grep -q 'lifecycle state invalid' "$TMP/status-invalid.out" || fail 'bf-status must raise a critical alert for an invalid lifecycle state' +pass 'bf-status prints the Provisioning/Enrollment/Fleet State lifecycle block' + +# --- scenario 7: local gate is offline-safe --------------------------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" +"$BIN/bf-check-local" > "$TMP/local.out" 2>&1 || fail 'bf-check-local must pass offline with no handshake and no wg0.conf' +grep -q 'local gates PASS' "$TMP/local.out" || fail 'bf-check-local must report a passing local gate' +! grep -q '^FAIL' "$TMP/local.out" || fail 'bf-check-local must not fail any local gate offline' +"$BIN/bf-check-local" --check >/dev/null 2>&1 || fail 'bf-check-local --check must behave like the default run' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'bf-check-local must not create enrollment artifacts' +! grep -Eq '(^|/)(curl|wget|apt|apt-get)( |$)' "$TMP/calls" 2>/dev/null || fail 'bf-check-local must not call network tools' +chmod 644 "$WG_DIR/privatekey" +if "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a world-readable WireGuard private key must fail the local gate'; fi +chmod 600 "$WG_DIR/privatekey" +MOCK_PUBLIC_KEY='CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=' "$BIN/bf-check-local" >/dev/null 2>&1 && fail 'a mismatched WireGuard keypair must fail the local gate' +if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a missing state file must fail the local gate'; fi +pass 'bf-check-local passes offline and fails closed on key hygiene' + +# --- scenario 8: ENROLLED gate needs evidence and a live tunnel ------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" +printf '%s\n' 'enrolled-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +export MOCK_HANDSHAKE="$(date +%s)" +[[ -s "$WG_DIR/wg0.conf" ]] || fail 'a successful enrollment must install the assigned peer configuration' +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'a successful enrollment must commit ENROLLED' +"$BIN/bf-check-enrollment" >/dev/null || fail 'ENROLLED gates must pass with peer config, live tunnel and a fresh handshake' +MOCK_HANDSHAKE=0 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing WireGuard handshake must fail the ENROLLED gate' +export MOCK_HANDSHAKE="$(date +%s)" +MOCK_IP_FAIL=1 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the ENROLLED gate' +MOCK_IP_FAIL=1 "$BIN/bf-check-ready" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the READY gate' +mv "$WG_DIR/wg0.conf" "$TMP/wg0.conf.keep" +"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing peer configuration must fail the ENROLLED gate' +mv "$TMP/wg0.conf.keep" "$WG_DIR/wg0.conf" +write_state "$pending_state" +"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'PROVISIONED_OFFLINE must never pass the ENROLLED gate' +write_state "$(enrolled_state "$central_pending")" +"$BIN/bf-check-enrollment" >/dev/null || fail 'a device with pending central channels is still ENROLLED' +if "$BIN/bf-check-ready" >/dev/null 2>&1; then fail 'pending central channels must never produce READY'; fi +pass 'bf-check-enrollment requires central evidence, peer configuration and a fresh handshake' + +# --- scenario 9: replay and backwards protection ---------------------------- +write_state "$pending_state" +printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null +REPLAY_BEFORE="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")" +if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin > "$TMP/replay.out" 2>&1; then fail 'a second enrollment without --reenroll must be refused'; fi +grep -q 'already ENROLLED' "$TMP/replay.out" || fail 'the replay refusal must explain the ENROLLED state' +REPLAY_AFTER="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")" +[[ "$REPLAY_BEFORE" == "$REPLAY_AFTER" ]] || fail 'a refused replay must not touch the state file' +printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll >/dev/null || fail '--reenroll must re-apply enrollment' +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail '--reenroll must never move the phase backwards' +write_state "$(ready_state "$central_verified")" +if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll > "$TMP/ready-refusal.out" 2>&1; then fail 'a READY device must refuse enrollment even with --reenroll'; fi +grep -q 'never move backwards' "$TMP/ready-refusal.out" || fail 'the READY refusal must explain the forward-only rule' +[[ "$(state_field phase)" == 'READY' ]] || fail 'the READY refusal must not change the phase' +[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state writes must stay at mode 600' +[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic writes must not leave temporary files' +pass 'replay and backwards transitions are refused, state writes are atomic and 0600' + +# --- scenario 10: systemd attempt and staged token -------------------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/pending.out" 2>&1 || fail 'the boot attempt must exit 0 while nothing is staged' +grep -q 'PENDING' "$TMP/pending.out" || fail 'the boot attempt must report PENDING' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a pending boot attempt must not install a config' +BF_STATE_FILE="$TMP/absent.json" BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/unprovisioned.out" 2>&1 || fail 'an unprovisioned device must exit 0 so the unit never loops' +grep -q 'PENDING' "$TMP/unprovisioned.out" || fail 'an unprovisioned device must report PENDING' +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 || fail 'a boot attempt without central configuration must exit 0' +printf '%s\n' 'staged-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null || fail '--stage must store the token' +[[ "$(stat -c '%a' "$STATE_DIR/enrollment.token")" == '600' ]] || fail 'the staged token must be mode 600' +MOCK_CURL_FAIL=1 MOCK_CURL_RC=7 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/offline-attempt.out" 2>&1 && fail 'an unreachable endpoint must fail the attempt so the bounded retry can run' +[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'a transient failure must keep the staged token for the bounded retry' +[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'a failed attempt must keep the lifecycle at PROVISIONED_OFFLINE' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a failed attempt must not leave a WireGuard config behind' +unset MOCK_CURL_FAIL MOCK_CURL_RC +MOCK_CURL_FAIL=1 MOCK_CURL_RC=22 MOCK_HTTP_CODE=403 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 && fail 'a definitive rejection must fail the attempt' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'a definitive rejection must discard the consumed token' +unset MOCK_CURL_FAIL MOCK_CURL_RC MOCK_HTTP_CODE +printf '%s\n' 'staged-secret-token-two' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null +touch -d '2 hours ago' "$STATE_DIR/enrollment.token" +BF_ENROLLMENT_TOKEN_MAX_AGE=1800 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'an expired staged token must exit 0 as PENDING' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'an expired staged token must be discarded' +printf '%s\n' 'staged-secret-token-three' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll-now" --stage >/dev/null || fail 'the technician wrapper must stage a token' +[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'bf-enroll-now --stage must leave the token staged' +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'the boot attempt must enroll when a valid token is staged and central is reachable' +[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'the boot attempt must commit ENROLLED' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'the staged token must be deleted after use' +! grep -R -F 'staged-secret-token-three' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'the staged token must not leak into state, logs or argv' +pass 'the boot attempt stays PENDING without a token and joins automatically with one' + +# --- scenario 11: --check dry run ------------------------------------------- +write_state "$pending_state" +rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token" +BEFORE="$(cat "$BF_STATE_FILE")" +BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --check > "$TMP/check.out" 2>&1 || fail '--check must succeed on a valid pre-enrollment device' +grep -q 'would enroll' "$TMP/check.out" || fail '--check must describe the planned transition' +[[ "$BEFORE" == "$(cat "$BF_STATE_FILE")" ]] || fail '--check must not touch the state file' +[[ ! -e "$WG_DIR/wg0.conf" ]] || fail '--check must not write a WireGuard config' +[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail '--check must not stage a token' +"$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1 && fail '--check must fail without an endpoint' +if BF_ENROLLMENT_ENDPOINT='https://example.invalid/enroll' "$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1; then fail '--check must reject placeholder endpoints'; fi +pass '--check validates without mutating or sending anything' + +# --- scenario 12: schemas match the client behaviour ------------------------ +python3 - "$REPO_ROOT" "$MOCK_REQUEST_CAPTURE" "$MOCK_RESPONSE" <<'PY' +import json, sys +root, request_capture, response_sample = sys.argv[1:4] +request_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-request.schema.json', encoding='utf-8')) +response_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-response.schema.json', encoding='utf-8')) +request = json.load(open(request_capture, encoding='utf-8')) +response = json.loads(response_sample) +if set(request_schema['properties']) != set(request_schema['required']): + raise SystemExit('request schema must close every property it requires') +if set(request) != set(request_schema['required']): + raise SystemExit('captured request does not match the request schema') +for section, keys in (('hardware', ('machine_id', 'product_uuid', 'sys_vendor')), ('release', ('os_release', 'kernel'))): + if set(request_schema['properties'][section]['required']) != set(keys): + raise SystemExit('%s schema fields drifted' % section) + if set(request[section]) != set(keys): + raise SystemExit('captured %s does not match the schema' % section) +required_response = set(response_schema['required']) +if set(response) - set(response_schema['properties']): + raise SystemExit('canned response carries fields the schema forbids') +if not required_response <= set(response): + raise SystemExit('canned response is missing required fields') +if set(response['wireguard']) != set(response_schema['properties']['wireguard']['required']): + raise SystemExit('WireGuard response fields drifted') +if set(response['central_verification']) != set(response_schema['properties']['central_verification']['required']): + raise SystemExit('central verification fields drifted') +blob = json.dumps(response_schema).lower() +for banned in ('private_key', 'privkey', 'password', 'bearer'): + if banned in blob: + raise SystemExit('response schema must not define a secret field: ' + banned) +PY +! grep -q 'BF-12345678' "$REPO_ROOT/provisioning/enrollment/README.md" || fail 'provisioning docs must not embed a device identity' +pass 'enrollment schemas match the implemented request and response contract' + +# --- scenario 13: boot unit cannot loop and carries no credential ----------- +UNIT="$REPO_ROOT/config/systemd/blueforce-enroll.service" +TIMER="$REPO_ROOT/config/systemd/blueforce-enroll.timer" +grep -q '^Restart=on-failure$' "$UNIT" || fail 'the boot unit must retry only failures' +grep -q '^Restart=always' "$UNIT" && fail 'the boot unit must never restart unconditionally' +grep -q '^RestartSec=' "$UNIT" || fail 'the boot unit must delay its bounded retries' +grep -q '^StartLimitBurst=' "$UNIT" || fail 'the boot unit must bound its retries' +grep -q '^StartLimitIntervalSec=' "$UNIT" || fail 'the boot unit must bound its retry window' +grep -q 'ExecStart=/usr/local/bin/bf-enroll --systemd' "$UNIT" || fail 'the boot unit must run the controlled attempt' +grep -q 'Unit=blueforce-enroll.service' "$TIMER" || fail 'the timer must trigger the enrollment attempt' +grep -q 'OnBootSec=' "$TIMER" || fail 'the timer must attempt shortly after boot' +! grep -Eqi '(token|password|secret|bearer)[[:space:]]*=' "$UNIT" "$TIMER" || fail 'the units must not embed a credential' +exec_lines="$(grep -E '^Exec[A-Za-z]*=' "$UNIT" || true)" +[[ -n "$exec_lines" ]] || fail 'the boot unit must define its attempt command' +! printf '%s\n' "$exec_lines" | grep -Eqi 'reboot|shutdown' || fail 'the boot unit must never reboot or shut down the device' +grep -q 'never reboots' "$UNIT" || fail 'the boot unit must document that it never reboots the device' +pass 'the enrollment units retry in a bounded way and carry no credential' + +printf 'test-field-os-lifecycle: all scenarios passed\n' + + + +#!/usr/bin/env bash +# Static and sandboxed behavior test for the first-boot terminal wizard. +# +# It never runs systemd, never touches apt, and never touches the real /var state: every +# scenario works inside a throwaway sandbox with a fake installer. Scenarios that require +# root run in a separate user namespace (unshare -r) and are reported as SKIP when the +# host does not permit that. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +BF="$REPO_ROOT/provisioning/firstboot/bf-firstboot" +UNIT="$REPO_ROOT/provisioning/firstboot/blueforce-firstboot.service" +README_FILE="$REPO_ROOT/provisioning/firstboot/README.md" + +WORK="$(mktemp -d)" +PASSED=0 +FAILED=0 +SKIPPED=0 + +trap 'rm -rf "$WORK"' EXIT + +ok() { + if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + printf 'PASS\t%s\n' "$*" >>"$BF_TEST_RESULTS" + else + printf 'OK %s\n' "$*" + PASSED=$((PASSED + 1)) + fi +} +bad() { + if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + printf 'FAIL\t%s\n' "$*" >>"$BF_TEST_RESULTS" + else + printf 'FAIL %s\n' "$*" >&2 + FAILED=$((FAILED + 1)) + fi +} +skip() { + if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + printf 'SKIP\t%s\n' "$*" >>"$BF_TEST_RESULTS" + else + printf 'SKIP %s\n' "$*" + SKIPPED=$((SKIPPED + 1)) + fi +} +section() { [[ -n "${BF_TEST_RESULTS:-}" ]] || printf '\n== %s ==\n' "$*"; } + +assert_rc() { + if [[ "$1" == "$2" ]]; then ok "$3"; else bad "$3 (expected exit $1, got $2)"; fi +} +assert_output() { + if [[ "$2" == *"$1"* ]]; then ok "$3"; else bad "$3 (output lacks: $1)"; fi +} +assert_no_output() { + if [[ "$2" != *"$1"* ]]; then ok "$3"; else bad "$3 (output unexpectedly contains: $1)"; fi +} +assert_file() { + if [[ -f "$1" ]]; then ok "$2"; else bad "$2 (missing: $1)"; fi +} +assert_no_file() { + if [[ ! -e "$1" ]]; then ok "$2"; else bad "$2 (unexpected path: $1)"; fi +} +assert_eq() { + if [[ "$1" == "$2" ]]; then ok "$3"; else bad "$3 (expected '$1', got '$2')"; fi +} +assert_grep() { + if grep -qF -- "$1" "$2" 2>/dev/null; then ok "$3"; else bad "$3 (pattern not found in $2)"; fi +} +assert_no_grep() { + if grep -qF -- "$1" "$2" 2>/dev/null; then bad "$3 (forbidden pattern found in $2)"; else ok "$3"; fi +} +assert_no_directive() { + if grep -qE "^[[:space:]]*$1=" "$2" 2>/dev/null; then bad "$3 (forbidden unit directive in $2)"; else ok "$3"; fi +} + +ENV_ARGS=() +SB='' +OUT='' +RC=0 + +new_sandbox() { + SB="$(mktemp -d "$WORK/sb.XXXXXX")" + { + printf '#!/usr/bin/env bash\n' + printf 'printf "%%s\\n" "$*" >> "%s/calls.log"\n' "$SB" + } >"$SB/fake-install.sh" + chmod 755 "$SB/fake-install.sh" + printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplePublicKeyForStaticFirstbootTest field@blueforce\n' >"$SB/key.pub" + chmod 600 "$SB/key.pub" + ENV_ARGS=( + "BF_FIRSTBOOT_MARKER=$SB/marker" + "STATE_DIR=$SB/state" + "BF_INSTALLER_PATH=$SB/fake-install.sh" + "BF_ADMIN_PUBLIC_KEY_FILE=$SB/key.pub" + "BF_REIDENTITY_AUDIT_LOG=$SB/audit.log" + "BF_FIRSTBOOT_INPUT_TIMEOUT=10" + ) +} + +installer_calls() { + if [[ -f "$SB/calls.log" ]]; then wc -l <"$SB/calls.log" | tr -d ' '; else printf '0'; fi +} +installer_args() { + if [[ -f "$SB/calls.log" ]]; then cat "$SB/calls.log"; else printf ''; fi +} +sandbox_listing() { + (cd "$SB" && ls -1A | sort | tr '\n' ',') +} +write_marker() { + printf '%s\n' "$1" >"$SB/marker" + chmod 600 "$SB/marker" +} +marker_content() { + if [[ -f "$SB/marker" ]]; then tr -d '[:space:]' <"$SB/marker"; else printf ''; fi +} +file_mode() { + if [[ -e "$1" ]]; then stat -c '%a' "$1"; else printf ''; fi +} + +# Non-interactive invocation: stdin is /dev/null, so the wizard can never prompt. +run_bf() { + set +e + OUT="$(timeout 60 env "${ENV_ARGS[@]}" "$@" 2>&1 &1)" + RC=$? + set -e +} + +# Same, but with no bytes written to the pseudo terminal at all (timeout path). +run_bf_tty_silent() { + local inner + inner="$(pty_command "$@")" + set +e + OUT="$(printf '' | env SHELL=/bin/bash "${ENV_ARGS[@]}" timeout 60 script -qec "$inner" /dev/null 2>&1)" + RC=$? + set -e +} + +# --------------------------------------------------------------------------- +# Source, unit and documentation checks (no execution). +# --------------------------------------------------------------------------- +check_sources() { + section 'static: script, unit, README' + + [[ -x "$BF" ]] && ok 'bf-firstboot is executable' || bad 'bf-firstboot is executable' + assert_eq '#!/usr/bin/env bash' "$(head -n 1 "$BF")" 'bf-firstboot has a bash shebang' + if bash -n "$BF" 2>/dev/null; then ok 'bf-firstboot parses (bash -n)'; else bad 'bf-firstboot parses (bash -n)'; fi + assert_grep 'set -euo pipefail' "$BF" 'bf-firstboot uses strict mode' + assert_grep '^[0-9]{8}$' "$BF" 'bf-firstboot validates the dealer number with the shared regex' + assert_grep '# BLUEFORCE FIELD OS' "$BF" 'bf-firstboot prints the field console banner' + assert_grep "wizard_write '> '" "$BF" 'bf-firstboot prompts with the wizard input marker' + assert_grep '/dev/tty' "$BF" 'bf-firstboot opens /dev/tty when stdin is not a terminal' + assert_grep '/etc/blueforce/dealer-id' "$BF" 'bf-firstboot supports the seeded dealer id file' + assert_grep '--offline' "$BF" 'bf-firstboot calls the installer offline-only' + assert_grep 'blueforce-install.sh' "$BF" 'bf-firstboot targets the local installer' + assert_grep 'BF_FIRSTBOOT_TEST_MODE' "$BF" 'bf-firstboot keeps the mutation-free test mode' + assert_grep '--resume' "$BF" 'bf-firstboot resumes a partial install' + assert_grep '--force-reidentity' "$BF" 'bf-firstboot exposes the admin re-identity path' + assert_grep 'BF_ALLOW_REIDENTITY' "$BF" 'the re-identity path is gated by an operator switch' + assert_grep 'already completed' "$BF" 'bf-firstboot short-circuits on the completion marker' + + assert_no_grep 'curl' "$BF" 'bf-firstboot fetches nothing from the network' + assert_no_grep 'wget' "$BF" 'bf-firstboot does not download via wget' + assert_no_grep 'set -x' "$BF" 'bf-firstboot never traces (no accidental key dump)' + if grep -nE '(^|[^A-Za-z_])(cat|tee|head|tail|awk|printf|echo|cp|scp)[[:space:]]+[^|]*\$KEY_FILE' "$BF" >/dev/null 2>&1; then + bad 'the administrator key file is never read out into a message or a copy' + else + ok 'the administrator key file is never read out into a message or a copy' + fi + + assert_file "$UNIT" 'the first-boot unit is present' + assert_grep 'ConditionPathExists=/etc/blueforce/admin-authorized-key.pub' "$UNIT" 'the unit requires the administrator public key' + assert_grep 'ConditionPathExists=!/var/lib/blueforce/firstboot-complete' "$UNIT" 'the unit is skipped once the marker exists' + assert_grep 'TimeoutStartSec=' "$UNIT" 'the unit bounds its runtime' + assert_grep 'StandardInput=tty-force' "$UNIT" 'the unit attaches the wizard to the console' + assert_grep 'Type=oneshot' "$UNIT" 'the unit is a one-shot service' + assert_grep 'RemainAfterExit=yes' "$UNIT" 'the unit records its result' + assert_grep 'WantedBy=multi-user.target' "$UNIT" 'the unit runs once at boot' + assert_no_directive 'ProtectSystem' "$UNIT" 'the unit does not sandbox paths the offline installer must write' + assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps the identity-derived home writable for the user module' + + assert_file "$README_FILE" 'the first-boot README is present' + assert_grep 'BLUEFORCE FIELD OS' "$README_FILE" 'the README documents the wizard screen' + assert_grep 'force-reidentity' "$README_FILE" 'the README documents the admin identity change procedure' + assert_grep 'BF_ALLOW_REIDENTITY' "$README_FILE" 'the README documents that identity change is off by default' + assert_grep 'public SSH key' "$README_FILE" 'the README states the administrator key rule' + assert_grep '/var/log/blueforce-reidentity.log' "$README_FILE" 'the README documents the identity change audit record' + assert_grep 'ConditionPathExists' "$README_FILE" 'the README explains the unit conditions' +} + +# --------------------------------------------------------------------------- +# Command line behavior: check mode, validation, automation path, no terminal. +# --------------------------------------------------------------------------- +check_cli() { + section 'cli: validation, check mode, no-terminal fallback' + + new_sandbox + run_bf bash "$BF" --check --dealer-id 12010193 + assert_rc 0 "$RC" 'check mode accepts a valid dealer number' + assert_output 'no changes were made' "$OUT" 'check mode reports that it made no changes' + assert_no_file "$SB/marker" 'check mode writes no marker' + assert_no_file "$SB/state" 'check mode creates no state directory' + assert_eq '0' "$(installer_calls)" 'check mode never runs the installer' + assert_eq 'fake-install.sh,key.pub,' "$(sandbox_listing)" 'check mode leaves the sandbox untouched' + + for candidate in 1234567 123456789 1234abcd '' '12 34 5678'; do + new_sandbox + run_bf bash "$BF" --check --dealer-id "$candidate" + assert_rc 2 "$RC" "invalid dealer number is rejected: '${candidate:-}'" + assert_output '8 digits' "$OUT" "rejection of '${candidate:-}' explains the expected format" + assert_no_file "$SB/marker" "rejection of '${candidate:-}' writes no marker" + done + + new_sandbox + run_bf bash "$BF" --check + assert_rc 2 "$RC" 'check mode without any dealer number fails with a clear error' + assert_no_file "$SB/marker" 'a failed check mode writes no marker' + + new_sandbox + run_bf bash "$BF" --not-a-flag + assert_rc 2 "$RC" 'an unknown argument is rejected' + assert_output 'unknown argument' "$OUT" 'an unknown argument is reported explicitly' + + new_sandbox + run_bf bash "$BF" --dealer-id 1234567 + assert_rc 2 "$RC" 'a 7-digit dealer number never reaches the installer' + assert_no_file "$SB/marker" 'a rejected dealer number writes no marker' + assert_eq '0' "$(installer_calls)" 'a rejected dealer number never runs the installer' + + # Non-interactive mode with no seeded file and no flag must fail closed, without hanging. + new_sandbox + run_bf bash "$BF" --non-interactive + assert_rc 2 "$RC" 'non-interactive mode without any dealer number fails' + if [[ "$OUT" == *'dealer id file is missing or unreadable'* || "$OUT" == *'invalid dealer number'* ]]; then + ok 'non-interactive mode names the unusable dealer id input' + else + bad 'non-interactive mode names the unusable dealer id input' + fi + assert_no_file "$SB/marker" 'the no-input path writes no marker' + + # Explicit file path (automation) is honoured and never opens the wizard. + new_sandbox + printf '12010193\n' >"$SB/dealer-id" + run_bf bash "$BF" --dealer-id-file "$SB/dealer-id" + assert_no_output '# BLUEFORCE FIELD OS' "$OUT" 'the automation path never opens the wizard' + assert_no_output '8 Haneli Bayi Numarası' "$OUT" 'the automation path asks nothing at the console' + assert_eq '0' "$(installer_calls)" 'the automation path runs no installer when it cannot install' + if [[ "$(id -u)" -eq 0 ]]; then + assert_rc 0 "$RC" 'the dealer id file path completes when the test runs as root' + else + assert_rc 1 "$RC" 'the dealer id file path fails closed without root' + assert_output 'must run as root' "$OUT" 'the automation path reports the missing privilege' + assert_no_file "$SB/marker" 'the automation path writes no marker when it cannot install' + fi + + new_sandbox + printf 'nope\n' >"$SB/dealer-id" + run_bf bash "$BF" --dealer-id-file "$SB/dealer-id" + assert_rc 2 "$RC" 'an invalid value in the dealer id file is rejected' + assert_output 'invalid dealer number' "$OUT" 'an invalid file value names the format rule' + + new_sandbox + printf '12010193\n' >"$SB/dealer-id" + run_bf BF_FIRSTBOOT_NONINTERACTIVE=1 bash "$BF" --dealer-id-file "$SB/dealer-id" + assert_no_output '# BLUEFORCE FIELD OS' "$OUT" 'explicit non-interactive mode never opens the wizard' + assert_eq '0' "$(installer_calls)" 'explicit non-interactive mode runs no installer when it cannot install' +} + +# --------------------------------------------------------------------------- +# Wizard behavior through a pseudo terminal. +# --------------------------------------------------------------------------- +check_wizard() { + section 'wizard: pty input, confirmation, attempt limit' + + new_sandbox + run_bf_tty '1234567 +abcd +nope' bash "$BF" + assert_rc 2 "$RC" 'three malformed entries abort the wizard' + assert_output '# BLUEFORCE FIELD OS' "$OUT" 'the wizard shows the field console banner' + assert_output '8 Haneli Bayi Numarası' "$OUT" 'the wizard shows the Turkish dealer number prompt' + assert_output 'deneme 3/3' "$OUT" 'the wizard counts attempts and never accepts malformed input' + assert_output 'not accepted after 3 attempts' "$OUT" 'the wizard aborts with an explicit error after the limit' + assert_no_file "$SB/marker" 'the aborted wizard writes no marker' + assert_eq '0' "$(installer_calls)" 'the aborted wizard never runs the installer' + + new_sandbox + run_bf_tty '1201 0193 +12010193 +e' bash "$BF" + assert_output 'Geçersiz giriş' "$OUT" 'the wizard rejects a malformed entry that contains a space' + assert_output 'deneme 1/3' "$OUT" 'the wizard re-asks once a malformed entry is rejected' + assert_output 'BF-12010193' "$OUT" 'the wizard accepts the corrected entry after a rejection' + + new_sandbox + run_bf_tty '12010193 +e' bash "$BF" + assert_output 'BF-12010193' "$OUT" 'the confirmed wizard shows Device ID BF-' + assert_output 'bf-12010193' "$OUT" 'the confirmed wizard shows hostname bf-' + assert_eq '0' "$(installer_calls)" 'the wizard run does not reach the installer without root' + if [[ "$(id -u)" -eq 0 ]]; then + assert_rc 0 "$RC" 'the confirmed wizard completes when the test runs as root' + else + assert_rc 1 "$RC" 'the confirmed wizard stops at the privilege gate without root' + assert_output 'must run as root' "$OUT" 'the confirmed wizard proceeds past identity validation' + fi + + new_sandbox + run_bf_tty '12010193 +n +12010193 +n +12010193 +n' bash "$BF" + assert_rc 2 "$RC" 'refusing the confirmation three times aborts the wizard' + assert_output 'Onay verilmedi' "$OUT" 'the wizard re-asks when the identity is not confirmed' + assert_eq '0' "$(installer_calls)" 'a refused confirmation never runs the installer' + + new_sandbox + run_bf_tty_silent bash "$BF" + assert_rc 2 "$RC" 'a silent console aborts the wizard instead of waiting forever' + assert_output 'timed out' "$OUT" 'the silent console path explains the timeout' + assert_eq '0' "$(installer_calls)" 'the silent console path never runs the installer' +} + +# --------------------------------------------------------------------------- +# Root-only behavior in a separate user namespace. +# --------------------------------------------------------------------------- +check_root_paths() { + section 'root (namespace): install run, marker, key gate, re-identity' + + new_sandbox + run_bf BF_FIRSTBOOT_TEST_MODE=1 bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'test mode completes successfully as root' + assert_output "TEST: would run $SB/fake-install.sh --offline --dealer-id 12010193 --yes" "$OUT" 'test mode builds the exact offline installer command' + assert_no_file "$SB/marker" 'test mode is mutation-free (no marker)' + assert_eq '0' "$(installer_calls)" 'test mode never runs the installer' + + new_sandbox + mkdir -p "$SB/state" + printf '01-precheck=OK\n' >"$SB/state/install-state" + run_bf BF_FIRSTBOOT_TEST_MODE=1 bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'a partial install state is accepted' + assert_output '--resume' "$OUT" 'a partial install is resumed instead of restarted' + + new_sandbox + run_bf bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'first boot completes with the (fake) installer' + assert_file "$SB/marker" 'the completion marker is written' + assert_eq '600' "$(file_mode "$SB/marker")" 'the marker is mode 600' + assert_eq '12010193' "$(marker_content)" 'the marker records the provisioned dealer number' + assert_eq '--offline --dealer-id 12010193 --yes' "$(installer_args)" 'the installer is called offline with the validated dealer number' + + run_bf bash "$BF" --dealer-id 12010193 + assert_rc 0 "$RC" 'a second run exits successfully' + assert_output 'already completed' "$OUT" 'a provisioned device reports that first boot is done' + assert_eq '1' "$(installer_calls)" 'a provisioned device never runs the installer again' + + run_bf bash "$BF" --dealer-id 87654321 + assert_rc 0 "$RC" 'a different dealer number does not fail the run' + assert_output 'identity changes use the admin procedure' "$OUT" 'a different dealer number points at the admin procedure' + assert_eq '12010193' "$(marker_content)" 'the provisioned identity is unchanged' + assert_eq '1' "$(installer_calls)" 'the installer is not re-run for a different dealer number' + + run_bf_tty '87654321 +e' bash "$BF" + assert_eq '12010193' "$(marker_content)" 'the wizard cannot change a provisioned identity' + assert_eq '1' "$(installer_calls)" 'the wizard does not re-run the installer on a provisioned device' + + new_sandbox + printf -- '-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAB3NzaC1yc2EFIRSTBOOTSECRETBODY\n-----END OPENSSH PRIVATE KEY-----\n' >"$SB/priv.key" + chmod 600 "$SB/priv.key" + run_bf BF_ADMIN_PUBLIC_KEY_FILE="$SB/priv.key" bash "$BF" --dealer-id 12010193 + assert_rc 1 "$RC" 'private key material is refused as an administrator key' + assert_output 'must contain only a public SSH key' "$OUT" 'the refusal states the public-key rule' + assert_no_output 'FIRSTBOOTSECRETBODY' "$OUT" 'no key material is echoed in the error output' + assert_no_output 'PRIVATE KEY' "$OUT" 'the private key header is never echoed' + assert_no_file "$SB/marker" 'a refused key writes no marker' + assert_eq '0' "$(installer_calls)" 'a refused key never runs the installer' + + new_sandbox + chmod 644 "$SB/key.pub" + run_bf bash "$BF" --dealer-id 12010193 + assert_rc 1 "$RC" 'a group/world readable administrator key file is refused' + assert_output 'root-owned' "$OUT" 'the refusal states the ownership and mode rule' + assert_no_file "$SB/marker" 'a refused key mode writes no marker' + + new_sandbox + run_bf BF_ADMIN_PUBLIC_KEY_FILE="$SB/missing.pub" bash "$BF" --dealer-id 12010193 + assert_rc 1 "$RC" 'a missing administrator key file blocks the offline install' + assert_output 'OFFLINE BLOCKED' "$OUT" 'a missing administrator key is reported as an offline block' + + new_sandbox + run_bf bash "$BF" --check --dealer-id 12010193 + assert_rc 0 "$RC" 'check mode succeeds as root' + assert_no_file "$SB/marker" 'check mode writes no marker as root' + assert_no_file "$SB/state" 'check mode creates no state directory as root' + assert_eq '0' "$(installer_calls)" 'check mode runs no installer as root' + assert_eq 'fake-install.sh,key.pub,' "$(sandbox_listing)" 'check mode leaves the sandbox untouched as root' + + new_sandbox + write_marker 12010193 + run_bf bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 1 "$RC" 're-identity is refused without the operator gate' + assert_output 'disabled by default' "$OUT" 'the refusal explains that identity change is off by default' + assert_eq '12010193' "$(marker_content)" 'a refused re-identity leaves the marker unchanged' + assert_eq '0' "$(installer_calls)" 'a refused re-identity runs no installer' + assert_no_file "$SB/audit.log" 'a refused re-identity writes no audit record' + + new_sandbox + write_marker 12010193 + run_bf_tty '99999999' BF_ALLOW_REIDENTITY=1 bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 2 "$RC" 'a mismatching confirmation aborts the identity change' + assert_output 'confirmation did not match' "$OUT" 'the aborted identity change says why' + assert_eq '12010193' "$(marker_content)" 'the aborted identity change leaves the marker unchanged' + assert_eq '0' "$(installer_calls)" 'the aborted identity change runs no installer' + + new_sandbox + write_marker 12010193 + printf 'not a directory\n' >"$SB/blocker" + run_bf_tty '55555555' BF_ALLOW_REIDENTITY=1 BF_REIDENTITY_AUDIT_LOG="$SB/blocker/audit.log" bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 1 "$RC" 'an unwritable audit log fails the identity change closed' + assert_output 'audit log is not writable' "$OUT" 'the failure names the unwritable audit log' + assert_eq '12010193' "$(marker_content)" 'a failed audit leaves the marker unchanged' + assert_eq '0' "$(installer_calls)" 'a failed audit runs no installer' + + new_sandbox + write_marker 12010193 + run_bf_tty '55555555' BF_ALLOW_REIDENTITY=1 bash "$BF" --force-reidentity --dealer-id 55555555 + assert_rc 0 "$RC" 'an admitted identity change completes' + assert_eq '55555555' "$(marker_content)" 'the marker carries the new dealer number' + assert_eq '--offline --dealer-id 55555555 --yes' "$(installer_args)" 'the installer is re-run with the new dealer number' + if compgen -G "$SB/marker.reidentified-*" >/dev/null; then + ok 'the previous identity marker is archived' + else + bad 'the previous identity marker is archived' + fi + assert_output 'AUDIT: identity-change applied' "$OUT" 'the identity change is announced in the run log' + assert_file "$SB/audit.log" 'the identity change writes an audit record' + assert_eq '600' "$(file_mode "$SB/audit.log")" 'the audit record is mode 600' + assert_grep 'identity-change applied old=12010193 new=BF-55555555' "$SB/audit.log" 'the audit record names the old and the new identity' + + new_sandbox + run_bf_tty '12010193 +e' bash "$BF" + assert_rc 0 "$RC" 'the wizard completes the first boot as root' + assert_output '# BLUEFORCE FIELD OS' "$OUT" 'the interactive run shows the banner' + assert_output 'BF-12010193' "$OUT" 'the interactive run shows the Device ID' + assert_eq '12010193' "$(marker_content)" 'the interactive run writes the marker for the confirmed identity' + assert_eq '--offline --dealer-id 12010193 --yes' "$(installer_args)" 'the interactive run calls the installer offline' +} + +if [[ -n "${BF_TEST_RESULTS:-}" ]]; then + # Child mode: only the root-required scenarios run; results go to the parent's file. + cd "$REPO_ROOT" + check_root_paths + exit 0 +fi + +check_sources +check_cli +check_wizard + +section 'root (namespace): install run, marker, key gate, re-identity' +RESULTS_FILE="$WORK/root-results.tsv" +: >"$RESULTS_FILE" +if unshare -r true >/dev/null 2>&1 && [[ "$(unshare -r id -u 2>/dev/null)" == 0 ]]; then + CHILD_LOG="$WORK/child.log" + if unshare -r env BF_TEST_RESULTS="$RESULTS_FILE" bash "$TEST_SCRIPT" >"$CHILD_LOG" 2>&1; then + : + else + printf 'FAIL the privileged (namespace) section did not complete; see the log below\n' >&2 + sed -n '1,20p' "$CHILD_LOG" >&2 + FAILED=$((FAILED + 1)) + fi + if [[ -s "$RESULTS_FILE" ]]; then + while IFS=$'\t' read -r status name; do + case "$status" in + PASS) ok "$name";; + SKIP) skip "$name";; + *) bad "$name";; + esac + done <"$RESULTS_FILE" + else + skip 'the privileged (namespace) section produced no results' + fi +else + skip 'unshare -r is unavailable here: root-only first-boot scenarios were not run' +fi + +printf '\ntest-firstboot-static: %s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED" +if [[ "$FAILED" -ne 0 ]]; then + printf 'test-firstboot-static: FAILED\n' >&2 + exit 1 +fi +printf 'test-firstboot-static: all passed\n' + + + +"""Command line interface and operator flows for the bfos console. + +Responsibilities: + + * argument parsing (``--full``, ``--dry-run``, ``--check``, ``--repo``, + ``--inventory``, ``--log-file``, ``--version``, ``--lang``, ``--list``, + ``--list-targets``, ``--no-color``), + * repository discovery and the startup checks (inventory, Ansible, audit log), + * the two presentations: the simple default view and ``--full`` detailed mode, + * every operator flow: local install, fleet operations with their gates, the + central preparation screens, the tools and the read-only information. + +Everything that decides *what may run* lives in :mod:`bfos.operations`, and +everything that *runs* lives in :mod:`bfos.runner`; this module only wires the +two together and talks to the operator. """ from __future__ import annotations -import contextlib -import importlib.util -import io -import json +import argparse import os -import re -import shutil import sys -import tempfile -import textwrap -import unittest -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -UI_GUI_PATH = REPO_ROOT / "bfos" / "ui_gui.py" - - -def _load_ui_gui(): - """ui_gui.py'yi paket __init__'ini calistirmadan yukler (izole import).""" - if not UI_GUI_PATH.is_file(): - raise unittest.SkipTest(f"bulunamadi: {UI_GUI_PATH}") - if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - name = "bfos_ui_gui_static_test" - spec = importlib.util.spec_from_file_location(name, UI_GUI_PATH) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - # dataclasses kayitli bir modul bekler; bu kayit zorunludur. - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -ui = _load_ui_gui() - -INVENTORY_YAML = textwrap.dedent( - """ - # gecici test envanteri (gercek cihaz degil) - all: - children: - blueforce: - children: - lab: - hosts: - bf-12010101: - ansible_host: 10.42.0.11 - pilot_1: - hosts: - bf-12010193: - ansible_host: 10.42.1.11 - production: - hosts: - bf-12010999: - ansible_host: 10.42.9.11 - """ -).strip() + "\n" - - -class StaticCase(unittest.TestCase): - """Tum testler icin ortak kurulum: gecici envanter + backend.""" - - tmpdir: tempfile.TemporaryDirectory - - @classmethod - def setUpClass(cls) -> None: - cls.tmpdir = tempfile.TemporaryDirectory(prefix="bfos-gui-test-") - cls.inventory = Path(cls.tmpdir.name) / "hosts.yml" - cls.inventory.write_text(INVENTORY_YAML, encoding="utf-8") - cls.ctx = ui.GuiContext.detect(inventory=str(cls.inventory)) - cls.backend, cls.backend_note = ui.load_backend(cls.ctx) - - @classmethod - def tearDownClass(cls) -> None: - cls.tmpdir.cleanup() - - # -- kucuk yardimcilar ------------------------------------------------- - def operation(self, operation_id: str): - for op in self.backend.list_operations(): - if op.id == operation_id: - return op - self.fail(f"katalogda islem yok: {operation_id}") - - def available_fleet_operation(self, scope: str = "simple"): - """Playbook'u DISKTE olan bir filo islemi (depo es zamanli degisebilir).""" - for op in self.backend.list_operations(): - if op.is_fleet and op.scope == scope and op.playbook and \ - ui.playbook_path(REPO_ROOT, op.playbook).is_file(): - return op - self.skipTest(f"kullanilabilir {scope} kapsamli filo islemi yok") - - SAMPLE_VALUES = { - "script": "/opt/blueforce/bin/deneme.sh", - "packages": "docker-ce=5:27.0.3-1~ubuntu.24.04~noble", - "service": "docker", - "only": "01-precheck", - "grace": "300", - "log_lines": "500", - } - - def valid_options(self, operation) -> dict: - """Zorunlu secenekler icin gecerli ornek degerlerle sozluk uretir.""" - options: dict = {} - for spec in operation.options: - value = self.SAMPLE_VALUES.get(spec.name, spec.default) - if spec.required and not value: - self.skipTest(f"{operation.id}: '{spec.name}' icin ornek deger yok") - options[spec.name] = value - return options - - def dealer_target(self, dealer: str = "12010193"): - return ui.Target("dealer", dealer) - - def group_target(self, group: str = "pilot_1"): - return ui.Target("group", group) - - -class TestDealerValidation(StaticCase): - def test_valid_dealer_numbers(self) -> None: - for value in ("12010193", "00000000", "99999999"): - self.assertTrue(ui.validate_dealer(value), value) - self.assertIsNone(ui.dealer_error(value), value) - - def test_invalid_dealer_numbers(self) -> None: - for value in ("", "1201019", "120101933", "1201019a", "1201 0193", - " 12010193", "12010193 ", "bf-12010193", "12345678"): - self.assertFalse(ui.validate_dealer(value), repr(value)) - self.assertIsNotNone(ui.dealer_error(value), repr(value)) - - def test_error_message_is_turkish_and_actionable(self) -> None: - message = ui.dealer_error("1201019") - self.assertIn("8 hane", message) - self.assertIn("1201019", message) - self.assertIn("12010193", message) # ornek deger - self.assertIn("rakam", ui.dealer_error("abcdefgh")) - - def test_dealer_derived_names(self) -> None: - self.assertEqual(ui.dealer_to_host("12010193"), "bf-12010193") - self.assertEqual(ui.dealer_to_device_id("12010193"), "BF-12010193") - self.assertEqual(ui.host_to_dealer("bf-12010193"), "12010193") - self.assertIsNone(ui.host_to_dealer("sunucu-1")) - - def test_dealer_host_matches_repo_conventions(self) -> None: - # docs/02: hostname bf-<8 hane>, insan kimligi BF-<8 hane> - self.assertEqual(ui.HOST_PATTERN.pattern, r"^bf-[0-9]{8}$") - self.assertEqual(ui.DEALER_PATTERN.pattern, r"^[0-9]{8}$") - - -class TestHeadlessAndTkinter(StaticCase): - def test_detects_headless_without_display(self) -> None: - message = ui.headless_error({}) - self.assertIsNotNone(message) - self.assertIn("DISPLAY", message) - self.assertIn("SSH", message) - # Grafik arayüz açılamadığında önerilen yol aynı uygulamanın terminal - # konsoludur; bash menü artık yedek (bkz. TERMINAL_MENU_HINT). - self.assertIn("bf --tui", message) - self.assertIn("python3 -m bfos.ui_gui", message) - - def test_display_and_wayland_are_accepted(self) -> None: - self.assertIsNone(ui.headless_error({"DISPLAY": ":0"})) - self.assertIsNone(ui.headless_error({"WAYLAND_DISPLAY": "wayland-0"})) - self.assertIsNone(ui.headless_error({"DISPLAY": ":99", - "WAYLAND_DISPLAY": "wayland-9"})) - - def test_tkinter_missing_message_mentions_package(self) -> None: - message = ui.tkinter_missing_message("No module named '_tkinter'") - self.assertIn("python3-tk", message) - self.assertIn("apt", message) - self.assertIn("bf --tui", message) - self.assertIn("_tkinter", message) - - def test_window_start_error_is_turkish(self) -> None: - message = ui.window_start_error(RuntimeError("no display name and no $DISPLAY")) - self.assertIn("Pencere açılamadı", message) - self.assertIn("ssh -X", message) - self.assertIn("bf --tui", message) - - def test_import_does_not_create_a_window(self) -> None: - # tkinter varsa bile modul import'u bir Tk koku olusturmamalidir: - # BfGui yalnizca acikca verilen bir root ile kurulabilir. - self.assertIsInstance(ui.HAVE_TKINTER, bool) - import inspect - - signature = inspect.signature(ui.BfGui.__init__) - self.assertIn("root", signature.parameters) - # Yardimcilar tkinter olmadan da cagrilabilir. - self.assertIsNone(ui.dealer_error("12010193")) - - def test_main_list_prints_catalogue_without_a_window(self) -> None: - buffer = io.StringIO() - with contextlib.redirect_stdout(buffer): - code = ui.main(["--list", "--inventory", str(self.inventory)]) - self.assertEqual(code, 0) - output = buffer.getvalue() - self.assertIn("işlem kataloğu", output) - for category in ui.CATEGORIES: - self.assertIn(category, output) - - def test_main_reports_headless_with_exit_3(self) -> None: - env = {k: v for k, v in os.environ.items() - if k not in ("DISPLAY", "WAYLAND_DISPLAY")} - stderr = io.StringIO() - import unittest.mock as mock - - with mock.patch.dict(os.environ, env, clear=True), \ - contextlib.redirect_stderr(stderr): - code = ui.main([]) - self.assertEqual(code, 3) - # Aynı uygulamanın terminal konsoluna yönlendirmeli (bf --tui). - self.assertIn("bf --tui", stderr.getvalue()) - - -class TestRootGate(StaticCase): - """Tam yetkili GUI: yazma işlemleri root ister, raporlar her zaman açık. - - Katalogun kendi `destructive` işareti ölçüt alınır (bkz. ui_gui - _normalize_operation); bu yüzden testler katalogdan örnek seçer, sabit - isimlere bağlanmaz. - """ - - def test_is_root_reflects_the_effective_uid(self) -> None: - original = os.geteuid - try: - os.geteuid = lambda: 0 - self.assertTrue(ui.is_root()) - self.assertTrue(ui.write_operations_enabled()) - os.geteuid = lambda: 1000 - self.assertFalse(ui.is_root()) - self.assertFalse(ui.write_operations_enabled()) - finally: - os.geteuid = original - - def test_destructive_operations_require_root(self) -> None: - writes = [op for op in self.backend.list_operations() if op.destructive] - self.assertTrue(writes, "katalogda yazma işlemi yok; kapı test edilemez") - for op in writes: - self.assertTrue(op.needs_root, - f"{op.id} yıkıcı ama needs_root değil") - - def test_read_only_operations_do_not_require_root(self) -> None: - reports = [op for op in self.backend.list_operations() if not op.destructive] - self.assertTrue(reports, "katalogda salt-okunur işlem yok") - frozen = [op.id for op in reports if op.needs_root] - self.assertEqual([], frozen, - "salt-okunur işlemler root istememeli: " + ", ".join(frozen)) - - def test_notice_names_the_command_to_get_privileges(self) -> None: - self.assertIn("sudo bf", ui.ROOT_REQUIRED_NOTICE) - self.assertIn("sudo bf", ui.NON_ROOT_DISABLED_HINT) - - def test_run_plan_refuses_a_root_plan_without_root(self) -> None: - """Son savunma hattı: çalıştırma tek noktadan geçer.""" - plan = ui.CommandPlan( - commands=(("true",),), - destructive=True, - requires_root=True, - operation_id="install-local", - ) - original = os.geteuid - try: - os.geteuid = lambda: 1000 - result = ui.run_plan(plan) - finally: - os.geteuid = original - self.assertFalse(result.ok) - self.assertEqual(2, result.exit_code) - self.assertIn("yönetici yetkisi", result.output.lower()) - - def test_run_plan_allows_a_root_plan_when_root(self) -> None: - plan = ui.CommandPlan( - commands=(("true",),), - destructive=True, - requires_root=True, - operation_id="install-local", - ) - original = os.geteuid - try: - os.geteuid = lambda: 0 - result = ui.run_plan(plan) - finally: - os.geteuid = original - self.assertTrue(result.ok, result.output) - - -class TestSimpleModeLayout(StaticCase): - def test_three_big_buttons_in_simple_mode(self) -> None: - self.assertEqual(ui.SIMPLE_MODE_BUTTONS, ("Kur", "Durum", "Çıkış")) - self.assertEqual(ui.SIMPLE_MODE_LABEL, "Detaylı mod") - - def test_window_title_is_turkish(self) -> None: - self.assertEqual(ui.WINDOW_TITLE, "Blueforce Field OS — Yönetim") - - def test_five_category_tabs(self) -> None: - self.assertEqual( - ui.CATEGORIES, - ("Kurulum", "Cihaz işlemleri", "Merkez hazırlığı", "Araçlar", "Cihaz bilgisi"), - ) - - -class TestCatalogue(StaticCase): - def test_operations_have_unique_ids_and_known_categories(self) -> None: - operations = self.backend.list_operations() - self.assertGreaterEqual(len(operations), 21) - ids = [op.id for op in operations] - self.assertEqual(len(ids), len(set(ids)), "islem id'leri tekil olmali") - for op in operations: - self.assertIn(op.category, ui.CATEGORIES, op.id) - self.assertTrue(op.label.strip(), op.id) - - def test_every_category_has_at_least_one_operation(self) -> None: - for category in ui.CATEGORIES: - with self.subTest(category=category): - self.assertTrue( - [op for op in self.backend.list_operations() if op.category == category], - f"{category} kategorisi bos", - ) - - def test_fleet_operation_playbooks_match_declared_scope(self) -> None: - """Konsol tablosu playbook'la uyusmali; playbook yoksa CALISTIRMA reddedilir. - - (bf_playbook_check + fail-closed kuralinin statik karsiligi.) - """ - checked = 0 - missing: list[str] = [] - for op in self.backend.list_operations(): - if not op.is_fleet: - continue - book = ui.playbook_path(REPO_ROOT, op.playbook) - if not book.is_file(): - # Eksik playbook SESSIZ gecilemez: komut uretimi reddedilmeli. - missing.append(f"{op.id} ({op.playbook})") - with self.subTest(operation=op.id): - with self.assertRaises(ui.CommandError): - self.backend.build_command(op, self.group_target()) - continue - with self.subTest(operation=op.id): - self.assertEqual( - ui.playbook_scope(book), op.scope, - f"{op.playbook}: tablo '{op.scope}' derken playbook " - f"'{ui.playbook_scope(book)}' beyan ediyor", - ) - checked += 1 - self.assertGreaterEqual(checked, 15) - if missing: - print(f"\n[UYARI] bu checkout'ta playbook dosyası eksik: {', '.join(missing)}" - " -> ilgili işlemler fail-closed olarak reddediliyor") - - def test_every_ansible_playbook_is_covered_or_known(self) -> None: - """Katalog disinda kalan playbook varsa gorunur olmali (sessiz kapsam disi yok).""" - catalogue = {op.playbook for op in self.backend.list_operations() if op.is_fleet} - on_disk = {p.name for p in (REPO_ROOT / "ansible" / "playbooks").glob("bf-*.yml")} - self.assertEqual(on_disk - catalogue, set()) - - def test_operation_ids_match_admin_bf_menu_catalogue(self) -> None: - """id'ler admin/bf-menu tablosuyla ayni olmali (iki giris noktasi ayrisamaz).""" - menu = (REPO_ROOT / "admin" / "bf-menu").read_text(encoding="utf-8") - for op in self.backend.list_operations(): - if op.is_fleet: - with self.subTest(operation=op.id): - self.assertIn(f'"{op.id}|', menu) - - -class TestDestructiveClassification(StaticCase): - YIKICI = ("rdp-restart", "rustdesk-restart", "wireguard-restart", "service-restart", - "gui-off", "gui-on", "run-script", "reboot", "deploy-update", - "install-local", "install-resume", "install-module", "tools-git-pull") - SALT_OKUNUR = ("ping", "uptime", "disk", "docker", "packages", "security-updates", - "provisioning-status", "enrollment-status", "fieldos-version", - "remote-channels", "offline-ready", "collect-logs", "install-preflight", - "install-state", "install-log", "central-targets", "info-summary", - "tools-release", "tools-support-bundle") - - def test_destructive_ids_are_flagged(self) -> None: - flagged = set(ui.destructive_operations(self.backend.list_operations())) - for operation_id in self.YIKICI: - with self.subTest(operation=operation_id): - self.assertIn(operation_id, flagged) - self.assertTrue(ui.operation_is_destructive(operation_id)) - - def test_read_only_ids_are_not_flagged(self) -> None: - flagged = set(ui.destructive_operations(self.backend.list_operations())) - for operation_id in self.SALT_OKUNUR: - with self.subTest(operation=operation_id): - self.assertNotIn(operation_id, flagged) - self.assertFalse(ui.operation_is_destructive(operation_id)) - - def test_destructive_flag_reaches_the_plan(self) -> None: - plan = self.backend.build_command(self.operation("reboot"), self.group_target(), - {"grace": "300"}) - self.assertTrue(plan.destructive) - plan = self.backend.build_command(self.operation("ping"), self.group_target()) - self.assertFalse(plan.destructive) - - def test_unknown_operation_is_not_destructive(self) -> None: - self.assertFalse(ui.operation_is_destructive("boyle-bir-islem-yok")) - - -class TestCommandPreview(StaticCase): - def test_fleet_preview_shows_the_exact_command(self) -> None: - operation = self.available_fleet_operation() - target = self.dealer_target() - plan = self.backend.build_command(operation, target, self.valid_options(operation)) - preview = ui.preview_text(plan, operation=operation, target=target) - self.assertTrue(plan.lines, "en az bir komut satiri uretilmeli") - for line in plan.lines: - self.assertIn(line, preview) - self.assertIn("ansible-playbook", preview) - self.assertIn("--limit", preview) - self.assertIn("bf-12010193", preview) - self.assertIn(operation.playbook, preview) - self.assertIn("Çalıştırılacak komut", preview) - - def test_preview_marks_destructive_operations(self) -> None: - operation = self.operation("deploy-update") - plan = self.backend.build_command( - operation, self.group_target(), - {"packages": "docker-ce=5:27.0.3-1~ubuntu.24.04~noble"}, - ) - preview = ui.preview_text(plan, operation=operation) - self.assertIn("UYARI", preview) - self.assertIn("YIKICI", preview) - self.assertIn("update_packages=", preview) - - def test_preview_lists_target_and_scope_in_turkish(self) -> None: - operation = self.operation("provisioning-status") - target = self.group_target() - plan = self.backend.build_command(operation, target) - preview = ui.preview_text(plan, operation=operation, target=target) - self.assertIn("dalga", preview.lower()) - self.assertIn("dalga kapılı", preview) - - def test_preview_of_report_only_plan_says_nothing_runs(self) -> None: - operation = self.operation("central-targets") - plan = self.backend.build_command(operation, None) - self.assertTrue(plan.is_report_only) - preview = ui.preview_text(plan, operation=operation) - self.assertIn("komut çalıştırmaz", preview) - self.assertNotIn("Çalıştırılacak komut(lar)", preview) - - def test_check_mode_is_visible_in_command_and_preview(self) -> None: - operation = self.available_fleet_operation() - target = self.group_target() - plan = self.backend.build_command( - operation, target, self.valid_options(operation), check_mode=True - ) - self.assertTrue(plan.check_mode) - self.assertIn("--check", " ".join(plan.lines)) - preview = ui.preview_text(plan, operation=operation, target=target) - self.assertIn("--check", preview) - self.assertIn("değişiklik uygulanmaz", preview) - - def test_options_change_the_previewed_command(self) -> None: - operation = self.operation("disk") - options = {spec.name: "75" for spec in operation.options if "disk" in spec.name} - if not options: - self.skipTest("disk isleminin esik secenegi yok") - plan = self.backend.build_command(operation, self.group_target(), options) - self.assertIn("disk_warn_pct=75", " ".join(plan.lines)) - - def test_invalid_option_is_refused_with_turkish_message(self) -> None: - operation = self.operation("service-restart") - name = "service" if any(s.name == "service" for s in operation.options) else "service_name" - with self.assertRaises(ui.CommandError) as ctx: - self.backend.build_command(operation, self.group_target(), {name: "kritik-servis"}) - self.assertIn("izinli", str(ctx.exception).lower()) - - def test_missing_required_option_is_refused(self) -> None: - operation = self.operation("deploy-update") - name = "packages" if any(s.name == "packages" for s in operation.options) \ - else "update_packages" - with self.assertRaises(ui.CommandError): - self.backend.build_command(operation, self.group_target(), {name: ""}) - - -class TestGatesAndTargets(StaticCase): - def test_wave_operation_requires_wave_token(self) -> None: - # A wave-scoped READ-ONLY report asks for no gate token -- the bash - # console behaves the same way (see bfos/operations.confirmation_plan: - # only release-gated runs and the production wave ask for a written - # token). What must hold is that the built command carries the wave - # scope and the confirmation flag, so the playbook's own gate accepts it. - steps = self.backend.gate_steps(self.operation("provisioning-status"), - self.group_target()) - tokens = [s.token for s in steps if s.kind == "token"] - self.assertEqual([], tokens, - "a read-only wave report must not ask for a gate token") - plan = self.backend.build_command(self.operation("provisioning-status"), - self.group_target(), {}, {}) - argv = list(plan.commands[0]) - self.assertIn("operation_wave=pilot_1", argv) - self.assertIn("operation_confirmed=true", argv) - - def test_release_gated_operation_requires_wave_token(self) -> None: - # The gate that must never disappear: reboot/deploy-update are - # release-gated, so the wave name has to be typed before anything runs. - steps = self.backend.gate_steps(self.operation("reboot"), - self.group_target()) - tokens = [s.token for s in steps if s.kind == "token"] - self.assertIn("pilot_1", tokens) - - def test_destructive_operation_requires_second_approval(self) -> None: - steps = self.backend.gate_steps(self.operation("gui-off"), self.group_target()) - self.assertTrue([s for s in steps if s.kind == "approval"], steps) - - def test_production_wave_requires_production_token(self) -> None: - steps = self.backend.gate_steps(self.operation("gui-off"), - self.group_target("production")) - tokens = [s.token for s in steps if s.kind == "token"] - self.assertIn("production", tokens) - unlocks = {name for step in steps for name in step.unlocks} - self.assertIn("production_override", unlocks) - - def test_release_scope_refuses_single_device_and_fleet(self) -> None: - operation = self.operation("reboot") - with self.assertRaises(ui.CommandError) as ctx: - self.backend.build_command(operation, self.dealer_target(), {"grace": "300"}) - self.assertIn("dalga", str(ctx.exception).lower()) - with self.assertRaises(ui.CommandError): - self.backend.build_command(operation, ui.Target("all", "all"), {"grace": "300"}) - - def test_wave_scope_refuses_a_non_wave_group(self) -> None: - operation = self.operation("gui-on") - with self.assertRaises(ui.CommandError): - self.backend.build_command(operation, self.group_target("blueforce")) - - def test_wave_scope_on_a_dealer_resolves_the_wave(self) -> None: - plan = self.backend.build_command(self.operation("gui-off"), self.dealer_target()) - line = " ".join(plan.lines) - self.assertIn("operation_wave=pilot_1", line) - self.assertIn("operation_confirmed=true", line) - self.assertIn("bf-12010193", line) - - def test_unknown_dealer_outside_inventory_is_refused(self) -> None: - operation = self.available_fleet_operation() - with self.assertRaises(ui.CommandError) as ctx: - self.backend.build_command(operation, self.dealer_target("12010000")) - self.assertIn("12010000", str(ctx.exception)) - - def test_invalid_dealer_never_builds_a_command(self) -> None: - operation = self.available_fleet_operation() - with self.assertRaises(ui.CommandError): - self.backend.build_command(operation, self.dealer_target("123")) - - def test_grants_change_the_gated_command(self) -> None: - operation = self.operation("reboot") - target = self.group_target("production") - options = {"grace": "120"} - if not any(s.name == "grace" for s in operation.options): - options = {"reboot_grace_seconds": "120"} - draft = self.backend.build_command(operation, target, options) - final = self.backend.build_command( - operation, target, options, - grants={"wave_gate_confirmed": True, "production_override": True, - "allow_production_reboot": True}, - ) - joined = " ".join(final.lines) - self.assertIn("allow_production_reboot=true", joined) - self.assertNotIn("allow_production_reboot=true", " ".join(draft.lines)) - self.assertNotEqual(draft.lines, final.lines) - - def test_no_gate_flag_is_ever_added_silently(self) -> None: - """Onaysiz hicbir komut kapi bayragi tasimamali (fail-closed).""" - checked = 0 - for operation in self.backend.list_operations(): - if not operation.is_fleet or operation.scope == "simple": - continue - target = self.group_target("production" if operation.scope == "release" - else "pilot_1") - options = self.valid_options(operation) - try: - plan = self.backend.build_command(operation, target, options) - except ui.CommandError: - # Playbook eksik/kapi reddi: komut URETILMEDI, bu da guvenli. - continue - with self.subTest(operation=operation.id): - joined = " ".join(plan.lines) - for flag in ("wave_gate_confirmed=true", "production_override=true", - "allow_production_reboot=true"): - self.assertNotIn(flag, joined, - f"{operation.id}: onaysiz kapi bayragi uretildi") - checked += 1 - self.assertGreaterEqual(checked, 1) - - -class TestRunnerStreaming(StaticCase): - def test_run_command_streams_lines_and_returns_exit_code(self) -> None: - lines: list[str] = [] - result = ui.run_command( - [sys.executable, "-c", "print('merhaba'); print('dunya')"], - on_line=lines.append, - ) - self.assertEqual(result.exit_code, 0) - self.assertTrue(result.ok) - self.assertEqual(lines, ["merhaba", "dunya"]) - self.assertIn("merhaba", result.output) - - def test_run_command_reports_nonzero_exit_code(self) -> None: - result = ui.run_command([sys.executable, "-c", "import sys; sys.exit(3)"]) - self.assertEqual(result.exit_code, 3) - self.assertFalse(result.ok) - - def test_missing_command_gives_turkish_message(self) -> None: - result = ui.run_command(["boyle-bir-komut-yok-12345"]) - self.assertEqual(result.exit_code, 127) - self.assertFalse(result.ok) - self.assertIn("bulunamadı", result.output) - self.assertIn("bf-bootstrap", result.output) - - def test_run_plan_stops_at_the_first_failure(self) -> None: - plan = ui.CommandPlan( - commands=( - (sys.executable, "-c", "import sys; print('ilk'); sys.exit(4)"), - (sys.executable, "-c", "print('IKINCI-CALISMAMALI')"), - ) - ) - lines: list[str] = [] - result = ui.run_plan(plan, on_line=lines.append) - self.assertEqual(result.exit_code, 4) - self.assertFalse(result.ok) - self.assertIn("ilk", lines) - self.assertNotIn("IKINCI-CALISMAMALI", lines) - - def test_run_plan_executes_nothing_for_a_report_plan(self) -> None: - plan = ui.CommandPlan(report_lines=("satır 1", "satır 2")) - lines: list[str] = [] - result = ui.run_plan(plan, on_line=lines.append) - self.assertTrue(result.ok) - self.assertEqual(lines, ["satır 1", "satır 2"]) - self.assertEqual(result.exit_code, 0) - - def test_dry_run_plan_does_not_execute(self) -> None: - plan = ui.CommandPlan( - commands=((sys.executable, "-c", "print('CALISMAMALI')"),), - dry_run=True, - ) - lines: list[str] = [] - result = ui.run_plan(plan, on_line=lines.append) - self.assertTrue(result.ok) - # Yalnizca "[deneme] " satiri yazilir; komutun CIKTISI yazilmaz. - self.assertTrue(lines) - self.assertTrue(all(line.startswith("[deneme] ") for line in lines), lines) - self.assertNotIn("CALISMAMALI", result.output) - self.assertIn("[deneme]", result.output) - - -class TestInventoryHelpers(StaticCase): - def test_parses_groups_and_hosts(self) -> None: - groups = ui.parse_inventory(self.inventory) - self.assertEqual(groups.get("pilot_1"), ["bf-12010193"]) - self.assertEqual(groups.get("lab"), ["bf-12010101"]) - self.assertEqual(groups.get("production"), ["bf-12010999"]) - - def test_wave_list_follows_rollout_order(self) -> None: - groups = ui.parse_inventory(self.inventory) - self.assertEqual(ui.waves_in_inventory(groups), ("lab", "pilot_1", "production")) - self.assertEqual(ui.WAVES, - ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production")) - - def test_device_wave_lookup(self) -> None: - groups = ui.parse_inventory(self.inventory) - self.assertEqual(ui.device_wave("bf-12010193", groups), "pilot_1") - self.assertIsNone(ui.device_wave("bf-99999999", groups)) - - def test_missing_inventory_does_not_crash(self) -> None: - self.assertEqual(ui.parse_inventory(Path("/yok/boyle/bir/dosya.yml")), {}) - - def test_missing_machine_inventory_is_reported_in_turkish(self) -> None: - backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None)) - note = backend.inventory_note() - self.assertIn("envanter", note.lower()) - self.assertIn("hosts.yml", note) - - def test_inventory_note_survives_and_warns_without_ansible(self) -> None: - """Not uretimi hicbir durumda patlamamali (eksik import regresyonu).""" - for backend in ( - ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, - inventory=self.inventory)), - self.backend, - ): - with self.subTest(backend=type(backend).__name__): - note = backend.inventory_note() - self.assertIn("Envanter", note) - if shutil.which("ansible-playbook") is None: - self.assertIn("ansible-playbook bulunamadı", note) - - def test_dealer_wave_gate_requires_inventory(self) -> None: - backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None)) - problem = backend.validate_target( - ui.operation_by_id("gui-off") or ui.OPERATIONS[0], self.dealer_target() - ) - self.assertIsNotNone(problem) - - -class TestCatalogueBackendFallback(StaticCase): - """Gercek moduller yoksa da ayni kurallar gecerli olmali (ayna backend).""" - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.fallback = ui.CatalogueBackend(cls.ctx) - - def test_fallback_has_the_same_fleet_operations(self) -> None: - fleet = {op.id for op in self.fallback.list_operations() if op.is_fleet} - self.assertGreaterEqual(len(fleet), 21) - self.assertIn("ping", fleet) - self.assertIn("deploy-update", fleet) - - def test_fallback_builds_the_same_core_arguments(self) -> None: - operation = None - for candidate in self.fallback.list_operations(): - if candidate.is_fleet and candidate.scope == "simple" and candidate.playbook \ - and ui.playbook_path(REPO_ROOT, candidate.playbook).is_file(): - operation = candidate - break - if operation is None: - self.skipTest("kullanilabilir basit kapsamli filo islemi yok") - options = {spec.name: self.SAMPLE_VALUES[spec.name] - for spec in operation.options if spec.name in self.SAMPLE_VALUES} - plan = self.fallback.build_command(operation, self.dealer_target(), options) - joined = " ".join(plan.lines) - self.assertIn("ansible-playbook", joined) - self.assertIn(f"-i {self.ctx.inventory}", joined) - self.assertIn("--limit bf-12010193", joined) - - def test_fallback_uses_the_same_destructive_table(self) -> None: - destructive = set(ui.destructive_operations(self.fallback.list_operations())) - self.assertEqual(destructive, set(ui.destructive_operations())) - - def test_fallback_refuses_missing_inventory(self) -> None: - backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None)) - with self.assertRaises(ui.CommandError) as ctx: - backend.build_command(ui.operation_by_id("uptime"), self.dealer_target()) - self.assertIn("envanter", str(ctx.exception).lower()) - - def test_fallback_refuses_scope_mismatch_with_the_playbook(self) -> None: - """Tablo playbook'la celisirse komut URETILMEZ (bf_playbook_check kurali).""" - wrong = ui.Operation( - id="uptime", label="Yanlış kapsam", category="Cihaz işlemleri", - destructive=False, scope="wave", playbook="bf-uptime.yml", - ) - with self.assertRaises(ui.CommandError) as ctx: - self.fallback.build_command(wrong, self.group_target()) - self.assertIn("bf-uptime.yml", str(ctx.exception)) - self.assertIn("kapsam", str(ctx.exception).lower()) - - def test_fallback_installs_only_with_a_valid_dealer(self) -> None: - installer = REPO_ROOT / ui.INSTALLER_REL - if not installer.is_file(): - self.skipTest("kurulum betigi bu checkout'ta yok") - plan = self.fallback.build_command( - ui.operation_by_id("install-preflight"), self.dealer_target() - ) - joined = " ".join(plan.lines) - self.assertIn("--dealer-id 12010193", joined) - self.assertIn("--check", joined) - self.assertFalse(plan.destructive) +from typing import Dict, List, Optional, Sequence, Tuple + +from . import config, operations, runner, tui +from .operations import ( + GateStep, + GatesGranted, + Inventory, + Operation, + Refused, + Repository, + Target, +) + + +class Quit(Exception): + """The operator asked to leave the console.""" # --------------------------------------------------------------------------- -# Tasarim sozlesmesi (docs/DESIGN.md + web/static/tokens.css) +# Argument parsing # --------------------------------------------------------------------------- -def _srgb_channel(value: float) -> float: - return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4 - - -def relative_luminance(colour: str) -> float: - """WCAG bagil parlaklik (hex token rengi).""" - r, g, b = (int(colour[index:index + 2], 16) / 255 for index in (1, 3, 5)) - return 0.2126 * _srgb_channel(r) + 0.7152 * _srgb_channel(g) + 0.0722 * _srgb_channel(b) - - -def contrast_ratio(first: str, second: str) -> float: - """Iki token rengi arasindaki WCAG kontrast orani.""" - a, b = relative_luminance(first), relative_luminance(second) - lighter, darker = max(a, b), min(a, b) - return (lighter + 0.05) / (darker + 0.05) - - -class TestThemeMirrorsTokensCss(StaticCase): - """bfos/ui_theme.py, web/static/tokens.css'in BIREBIR aynasi olmali. - - Iki yuzey (cihaz konsolu + merkez panel) ayni sozlugu konusur; bir tarafta - degistirilip digerinde unutulan bir token sessiz bir ayrisma olurdu. Bu - 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 - #: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te - #: kalirlar cunku merkez panel kullanir. - NOT_MIRRORED = ( - "--font-ui", "--font-data", - "--leading-tight", "--leading-normal", - "--shadow-sm", "--shadow-md", - "--radius", "--radius-lg", +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=config.PROGRAM_NAME, + description="{0} — {1}".format(config.PROGRAM_DESCRIPTION, config.VERSION), + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False, + epilog=( + "Örnekler:\n" + " sudo bfos basit mod: 1) Kur 2) Durum 3) Çıkış\n" + " sudo bfos --full detaylı mod: 21 playbook, merkez hazırlığı, araçlar\n" + " bfos --dry-run --full komutları yalnız göster, hiçbir şey çalıştırma\n" + " bfos --list işlem kataloğu (etkileşimsiz)\n" + " bfos --list-targets envanterdeki dalgalar, gruplar ve cihazlar\n" + "\n" + "Güvenlik: her komut önce ekranda gösterilir ve onay ister; production dalgası\n" + "ayrı onay (+ production_override=true), sürüm kapılı işlemler yazılı dalga onayı,\n" + "yıkıcı işlemler ikinci onay ister. Denetim kaydı: {log} (mod 600).\n" + ).format(log=config.DEFAULT_LOG_FILE), ) + parser.add_argument("-h", "--help", action="help", help="bu yardı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", + 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", + help="terminal konsolunu aç (grafik arayüz açılamıyorsa yedek)") + parser.add_argument("--full", action="store_true", + help="detaylı mod: tüm kategoriler ve 21 playbook görünür") + parser.add_argument("--dry-run", action="store_true", + help="komutları yaz, hiçbir şey çalıştırma") + parser.add_argument("--check", action="store_true", + help="komutları --check ile üret (salt okunur; değişiklik uygulanmaz)") + parser.add_argument("--repo", metavar="YOL", default="", + help="Blueforce deposu (varsayılan: konsolun bulunduğu depo)") + parser.add_argument("--inventory", metavar="YOL", default="", + help="Ansible envanteri (varsayılan: /ansible/inventory/hosts.yml)") + parser.add_argument("--log-file", metavar="YOL", default=config.DEFAULT_LOG_FILE, + help="denetim kaydı (varsayılan: {0}, mod 600)".format(config.DEFAULT_LOG_FILE)) + parser.add_argument("--lang", metavar="DİL", default=config.DEFAULT_LANG, + help="arayüz dili (yalnız: {0})".format(", ".join(config.SUPPORTED_LANGS))) + parser.add_argument("--no-color", action="store_true", help="ANSI renklerini kapat (NO_COLOR da geçerli)") + parser.add_argument("--list", action="store_true", + help="işlem kataloğunu yaz ve çık (etkileşimsiz)") + parser.add_argument("--list-targets", action="store_true", + help="envanter hedeflerini yaz ve çık (etkileşimsiz)") + return parser - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.theme = ui.theme - cls.css = cls._parse_tokens(cls.CSS_PATH.read_text(encoding="utf-8")) + +def resolve_repo(explicit: str = "") -> Repository: + """Locate the Blueforce checkout. + + An explicit ``--repo`` (or ``BF_REPO``) is a commitment, not a hint: when it + does not look like the repository the console refuses to run instead of + silently falling back to another checkout. + """ + for candidate in (explicit, os.environ.get("BF_REPO", "")): + if not candidate: + continue + repo = Repository(candidate) + if repo.looks_valid(): + return repo + raise Refused(config.MSG_REPO_INVALID.format(path=candidate)) + package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for candidate in (package_parent, os.getcwd()): + repo = Repository(candidate) + if repo.looks_valid(): + return repo + raise Refused(config.MSG_REPO_INVALID.format(path=package_parent)) + + +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- +class App: + """The operator flows. All I/O goes through :class:`bfos.tui.Console`.""" + + def __init__( + self, + repo: Repository, + console: tui.Console, + runner_: runner.Runner, + inventory: Inventory, + full_mode: bool = False, + check_mode: bool = False, + inventory_error: str = "", + inventory_arg: str = "", + ) -> None: + self.repo = repo + self.console = console + self.runner = runner_ + self.inventory = inventory + self.full_mode = full_mode + self.check_mode = check_mode + self.inventory_error = inventory_error + self.inventory_arg = inventory_arg + + # -- infrastructure ---------------------------------------------------- + def require_inventory(self) -> bool: + """Fleet operations need a readable inventory: explain, never guess.""" + try: + path = self.repo.find_inventory(self.inventory_arg) + except Refused as exc: + self.console.exception(exc) + return False + if path != self.inventory.path: + self.inventory = Inventory.load(path) + return True + + def require_ansible(self) -> bool: + if self.runner.have_ansible(): + return True + self.console.error(config.MSG_ANSIBLE_MISSING) + return False + + def require_root(self) -> bool: + if self.runner.is_root(): + return True + self.console.error(config.MSG_ROOT_REQUIRED) + return False + + def ask_menu(self, screen: tui.Screen) -> Optional[str]: + """Render a screen until a valid key is chosen. + + Returns the chosen key, or ``None`` when the operator goes back. + Raises :class:`Quit` when the operator quits. + """ + while True: + self.console.screen(screen) + outcome, payload = self.console.ask_menu_choice(screen) + if outcome == tui.CHOICE_QUIT: + raise Quit() + if outcome == tui.CHOICE_BACK: + return None + if outcome == tui.CHOICE_ITEM: + return payload + + def pause(self) -> None: + self.console.pause() + + # -- entry point ------------------------------------------------------- + def run(self) -> int: + try: + if self.full_mode: + self.console.note(config.MSG_FULL_MODE_HINT) + self.detailed_loop() + else: + self.simple_loop() + except Quit: + self.console.note(config.MSG_QUIT) + return config.EXIT_OK + + # ------------------------------------------------------------------ + # Simple mode + # ------------------------------------------------------------------ + def simple_loop(self) -> None: + while True: + key = self.ask_menu(tui.simple_menu()) + if key is None: + self.console.note(config.MSG_AT_TOP) + continue + if key == "1": + self.simple_install() + elif key == "2": + self.simple_status() + elif key == "3": + raise Quit() + elif key == "d": + self.full_mode = True + self.console.note(config.MSG_FULL_MODE_HINT) + self.detailed_loop() + else: # pragma: no cover - ask_menu only returns known keys + self.console.warn(config.MSG_UNKNOWN_CHOICE.format(choice=key)) + + def simple_install(self) -> None: + """Guided local install: dealer number, then the installer.""" + self.console.section("Kurulum — bu cihaz") + self.console.note("Bu cihaz Field OS standardına getirilir. Yalnız bu cihaz; filo cihazlarına dokunulmaz.") + dealer = self.ask_dealer() + if dealer is None: + return + try: + installer = self.repo.installer_path() + except Refused as exc: + self.console.exception(exc) + self.pause() + return + offline = self.console.confirm("Çevrimdışı kurulum (yerel paket deposu) kullanılsın mı?") + if offline is None: + raise Quit() + if not self.require_root(): + self.pause() + return + argv = operations.build_install_command( + installer, dealer, offline=offline, check_mode=self.check_mode + ) + label = "kurulum: {0}".format(operations.dealer_to_device_id(dealer)) + self.console.info("Hedef cihaz: {0} ({1})".format( + operations.dealer_to_device_id(dealer), operations.dealer_to_host(dealer))) + self.runner.confirm_and_run(argv, label=label, destructive=True) + self.pause() + + def simple_status(self) -> None: + """Read-only status: identity, provisioning state, health tool.""" + self.console.section("Durum — bu cihaz") + self.console.show_lines(operations.identity_lines()) + self.console.write("") + self.console.show_lines(operations.state_lines()) + self.console.write("") + result = self.run_tool("bf-status", title="bf-status (salt okunur)") + if result is None: + self.console.note("bf-status kurulu değil; yukarıdaki özet gösterildi.") + self.console.note("Bu ekran hiçbir şeyi değiştirmez.") + self.pause() + + def detailed_loop(self) -> None: + while True: + key = self.ask_menu(tui.detailed_menu()) + if key is None: + self.console.note(config.MSG_AT_TOP) + continue + if key == "1": + self.category_install() + elif key == "2": + self.category_devices() + elif key == "3": + self.category_central() + elif key == "4": + self.category_tools() + elif key == "5": + self.category_info() + elif key == "s": + self.full_mode = False + self.console.note(config.MSG_SIMPLE_MODE_HINT) + self.simple_loop() + return + + # ------------------------------------------------------------------ + # Shared input helpers + # ------------------------------------------------------------------ + def ask_dealer(self) -> Optional[str]: + """Ask for a dealer number until it matches ^[0-9]{8}$ (or back/quit).""" + while True: + value = self.console.read(config.MSG_PROMPT_DEALER) + if value is None: + return None + value = value.strip() + if value == "" or value == "0": + return None + if operations.dealer_is_valid(value): + return value + self.console.warn(config.MSG_TARGET_DEALER_INVALID.format(value=value)) + + def ask_extra_vars(self, operation: Operation) -> Optional[List[Tuple[str, str]]]: + """Collect and validate the extra -e variables for one operation.""" + prompt = operation.prompt + if prompt == operations.PROMPT_NONE: + return [] + answers: Dict[str, str] = {} + if prompt == operations.PROMPT_PACKAGES: + self.console.note(config.MSG_PKG_HINT) + value = self.console.read(config.MSG_PROMPT_PACKAGES) + answers["packages"] = "" if value is None else value + elif prompt == operations.PROMPT_SERVICE: + self.console.note(config.MSG_SERVICE_HINT.format(allowlist=" ".join(config.SERVICE_ALLOWLIST))) + value = self.console.read(config.MSG_PROMPT_SERVICE) + answers["service"] = "" if value is None else value + elif prompt == operations.PROMPT_SCRIPT: + self.console.note("İzinli dizinler: {0}".format(" ".join(config.SCRIPT_DIRS))) + path = self.console.read(config.MSG_PROMPT_SCRIPT) + answers["script"] = "" if path is None else path + args = self.console.read(config.MSG_PROMPT_SCRIPT_ARGS) + answers["script_args"] = "" if args is None else args + elif prompt == operations.PROMPT_GRACE: + value = self.console.ask_with_default( + config.MSG_PROMPT_GRACE.format(default=config.DEFAULT_REBOOT_GRACE), + str(config.DEFAULT_REBOOT_GRACE), + ) + answers["grace"] = "" if value is None else value + elif prompt == operations.PROMPT_DISK_PCT: + value = self.console.read(config.MSG_PROMPT_DISK) + answers["disk_pct"] = "" if value is None else value + elif prompt == operations.PROMPT_LOG_LINES: + value = self.console.ask_with_default( + config.MSG_PROMPT_LOG_LINES.format(default=config.DEFAULT_LOG_LINES), + str(config.DEFAULT_LOG_LINES), + ) + answers["log_lines"] = "" if value is None else value + elif prompt == operations.PROMPT_VERSION: + value = self.console.read(config.MSG_PROMPT_VERSION) + answers["expected_version"] = "" if value is None else value + try: + return operations.build_extra_vars(prompt, answers) + except Refused as exc: + self.console.exception(exc) + self.pause() + return None + + # ------------------------------------------------------------------ + # Target selection + # ------------------------------------------------------------------ + def select_target(self, mode: str) -> Optional[Target]: + """Interactive target picker (mirrors bf_target_select).""" + while True: + screen = tui.target_menu() + if mode == config.MODE_RELEASE: + screen = tui.Screen( + title=screen.title, + subtitle="Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez", + entries=screen.entries, + notes=("Yalnız onaylı dalga grupları kabul edilir; filo geneli reddedilir.",), + ) + self.console.screen(screen) + value = self.console.read(config.MSG_PROMPT_TARGET) + if value is None: + return None + choice = value.strip() + if choice == "" or choice == "0": + return None + if choice.lower() == "q": + raise Quit() + if choice == "1": + dealer = self.ask_dealer() + if dealer is None: + continue + word = dealer + elif choice == "2": + word = self.pick_group(mode) + if word is None: + continue + elif choice == "3": + word = "all" + elif choice == "4": + typed = self.console.read("Hedef (bayi no, grup ya da 'all'): ") + if typed is None: + return None + word = typed.strip() + else: + # Anything else is treated as a directly typed target, exactly + # like the bash prompt's "type the target" option. + word = choice + try: + return operations.resolve_target(word, mode, self.inventory) + except Refused as exc: + self.console.exception(exc) + self.pause() + + def pick_group(self, mode: str) -> Optional[str]: + if not self.inventory.resolved: + self.console.warn(config.MSG_INVENTORY_MISSING.format( + dir=self.repo.path(config.INVENTORY_DIR_REL))) + return None + groups = self.inventory.group_names() if mode == config.MODE_ANY else self.inventory.waves_present() + if not groups: + self.console.warn("Envanterde kullanılabilir grup yok.") + return None + self.console.section("Envanterdeki gruplar") + for index, group in enumerate(groups, start=1): + count = len(self.inventory.hosts_of(group)) + suffix = "onaylı dalga" if operations.is_wave(group) else "grup" + self.console.write(tui.render_item(str(index), group, "{0} cihaz, {1}".format(count, suffix), + self.console.palette)) + value = self.console.read("Grup numarası > ") + if value is None: + return None + value = value.strip() + if value.isdigit() and 1 <= int(value) <= len(groups): + return groups[int(value) - 1] + self.console.warn(config.MSG_MENU_RANGE.format(max=len(groups))) + return None + + # ------------------------------------------------------------------ + # Fleet operations + # ------------------------------------------------------------------ + def run_operation(self, operation: Operation) -> None: + """Full flow for one operation: checks, target, gates, execution.""" + try: + scope = self.repo.verify_scope(operation) + except Refused as exc: + self.console.exception(exc) + self.pause() + return + if not self.require_ansible(): + self.pause() + return + if not self.require_inventory(): + self.pause() + return + + self.console.section(operation.label) + self.console.note(operation.description) + if operation.declared_scope == config.SCOPE_RELEASE: + self.console.note("Sürüm kapılı işlem: yalnız onaylı dalga, yazılı dalga onayı zorunlu.") + target = self.select_target(operation.target_mode) + if target is None: + self.console.note(config.MSG_TARGET_NONE) + return + extra_vars = self.ask_extra_vars(operation) + if extra_vars is None: + return + + if scope == config.SCOPE_WAVE and target.kind == operations.KIND_ALL: + self.run_fleet_waves(operation, scope, target, extra_vars) + return + wave = target.wave + if scope in (config.SCOPE_WAVE, config.SCOPE_RELEASE) and not wave: + self.console.error(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=target.limit)) + self.pause() + return + self.gated_run(operation, scope, target, wave, extra_vars) + self.pause() + + def run_fleet_waves( + self, + operation: Operation, + scope: str, + target: Target, + extra_vars: Sequence[Tuple[str, str]], + ) -> None: + """Wave-scoped operation on the whole fleet: one wave at a time. + + The sequence stops at the first wave that is refused or fails, so no + later wave is ever touched by accident. + """ + sequence = operations.wave_sequence(self.inventory) + if not sequence: + self.console.error("Envanterde onaylı dalga yok; filo geneli işlem yapılamaz.") + self.pause() + return + self.console.section("Filo geneli — dalga dalga") + self.console.warn(config.MSG_GATE_WAVE_BY_WAVE.format(order=config.MSG_GATE_ORDER)) + if not self.console.confirm(config.MSG_GATE_START_FIRST): + self.console.note(config.MSG_CANCELLED) + return + for wave, count in sequence: + self.console.section("Dalga '{0}' — {1} cihaz".format(wave, count)) + answer = self.console.read(config.MSG_GATE_WAVE_LOOP_ASK.format(wave=wave, count=count)) + if answer is None: + return + if answer.strip() != wave: + self.console.info(config.MSG_GATE_WAVE_SKIPPED.format(wave=wave)) + self.runner.audit.write(config.AUDIT_CANCEL, "dalga atlandı: {0}".format(wave)) + continue + wave_target = Target( + kind=operations.KIND_GROUP, limit=wave, wave=wave, count=count, + label="dalga '{0}' ({1} cihaz)".format(wave, count), + ) + result = self.gated_run(operation, scope, wave_target, wave, extra_vars, + pause=False, wave_token_already_given=True) + if result is None or not result.ok: + self.console.error(config.MSG_GATE_WAVE_FAILED.format( + wave=wave, rc=(result.rc if result is not None else "-"))) + self.runner.audit.write(config.AUDIT_STOP, "{0} dalgasında durdu: {1}".format( + operation.ident, wave)) + self.pause() + return + self.pause() + + def gated_run( + self, + operation: Operation, + scope: str, + target: Target, + wave: str, + extra_vars: Sequence[Tuple[str, str]], + pause: bool = True, + wave_token_already_given: bool = False, + ): + """Apply the confirmation plan, build the command and run it.""" + try: + wave_gate_supported = self.repo.playbook_uses( + operation.playbook, operations.VAR_WAVE_GATE_CONFIRMED + ) + except Refused as exc: + self.console.exception(exc) + return None + + plan = operations.confirmation_plan( + operation, scope, target, wave=wave, wave_gate_supported=wave_gate_supported + ) + if wave_token_already_given: + # The fleet-wide loop already made the operator type this wave's + # name: it is never asked twice for the same wave. + plan = [step for step in plan if step.key != "wave_token"] + gates = GatesGranted() + if plan: + self.console.section(config.MSG_GATE_PLAN_TITLE) + for step in plan: + granted = self.apply_gate_step(step) + if granted is None: + return None + gates = gates.grant(step) + + self.console.section("İşlem özeti") + self.console.kv("İşlem", operation.label) + self.console.kv("Playbook", "{0}/{1}".format(config.PLAYBOOK_DIR_REL, operation.playbook)) + self.console.kv("Kapsam", scope) + self.console.kv("Envanter", self.inventory.labels()) + self.console.kv("Hedef", target.label) + if wave: + self.console.kv("Dalga", wave) + + try: + argv = operations.build_ansible_command( + self.repo, + self.inventory.path or "", + operation, + scope, + target, + wave=wave, + extra_vars=list(extra_vars), + gates=gates, + check_mode=self.check_mode, + ) + except Refused as exc: + self.console.exception(exc) + if pause: + self.pause() + return None + + self.runner.audit.write( + config.AUDIT_TARGET, + "{0} hedef={1} tür={2} dalga={3}".format( + operation.ident, target.limit, target.kind, wave or "yok"), + ) + result = self.runner.confirm_and_run( + argv, label="{0} — {1}".format(operation.label, target.label), + destructive=operation.destructive, + ) + if pause: + self.pause() + return result + + def apply_gate_step(self, step: GateStep) -> Optional[bool]: + """Ask one confirmation. Returns None when the operator does not grant it.""" + if step.kind == operations.STEP_TOKEN: + granted = self.console.confirm_token(step.message, step.token, step.prompt) + if granted is None: + return None + if not granted: + self.console.warn(self._token_refusal(step)) + self.pause() + return None + return True + granted = self.console.confirm(step.prompt) + if granted is None: + return None + if not granted: + self.console.warn(self._approval_refusal(step)) + self.pause() + return None + return True @staticmethod - def _parse_tokens(text: str) -> dict: - """`--ad: deger;` ciftlerini toplar, var(--x) referanslarini cozer.""" - raw = { - match.group(1): match.group(2).strip() - for match in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", text) - } - resolved = {} - for name, value in raw.items(): - reference = re.fullmatch(r"var\((--[a-z0-9-]+)\)", value) - resolved[name] = raw.get(reference.group(1), value) if reference else value - return resolved + def _token_refusal(step: GateStep) -> str: + if step.key == "production_token": + return config.MSG_GATE_PRODUCTION_REFUSED + if step.key == "fleet_token": + return config.MSG_GATE_FLEET_REFUSED + return config.MSG_GATE_WAVE_REFUSED - def test_the_mirror_source_exists(self) -> None: - self.assertTrue(self.CSS_PATH.is_file(), str(self.CSS_PATH)) - self.assertTrue(self.css, "tokens.css icinde token bulunamadi") + @staticmethod + def _approval_refusal(step: GateStep) -> str: + if step.key == "production_approval": + return config.MSG_GATE_PRODUCTION_REFUSED + return config.MSG_CANCELLED_RUN - def test_every_mirrored_value_equals_the_css(self) -> None: - for name, value in self.theme.ALL_TOKENS.items(): - with self.subTest(token=name): - self.assertIn(name, self.css, f"{name} tokens.css'te yok") - css_value = self.css[name] - if css_value.endswith("px"): - css_value = css_value[:-2] - self.assertEqual(value, css_value.strip(), - f"{name}: ui_theme.py ile tokens.css ayrismis") + # ------------------------------------------------------------------ + # Category: Kurulum (this device) + # ------------------------------------------------------------------ + def category_install(self) -> None: + while True: + key = self.ask_menu(tui.install_menu(self.install_items())) + if key is None: + return + if key == "1": + self.install_preflight() + elif key == "2": + self.install_apply(resume=False) + elif key == "3": + self.install_apply(resume=True) + elif key == "4": + self.install_single_module() + elif key == "5": + self.show_file(config.INSTALL_STATE_FILE, "Kurulum modül durumu") + self.pause() + elif key == "6": + self.tail_file(config.INSTALL_LOG_FILE, "Kurulum kaydı (son 40 satır)") + self.pause() - def test_the_colour_palette_is_fully_mirrored(self) -> None: - """CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata).""" - css_colours = {name for name, value in self.css.items() if value.startswith("#")} - python_colours = {name for name, value in self.theme.TOKENS.items() - if value.startswith("#")} - self.assertEqual(css_colours, python_colours) - self.assertTrue(css_colours) - - def test_state_and_severity_pairs_have_readable_contrast(self) -> None: - """Durum renkleri gunesli atolyede okunmali; rozet sozcugu de yaninda.""" - self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_APP), 7.0) - self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_PANEL), 7.0) - self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_MUTED, - self.theme.BG_PANEL), 4.5) - self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_FAINT, - self.theme.BG_PANEL), 3.0) - self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_DARK, - self.theme.BG_HEADER), 7.0) - self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_ACCENT, - self.theme.ACCENT), 4.5) - # En dusuk cift "yesil zemin + yesil yazi" (4.42:1); esik bunun altina inmez. - for key, word in self.theme.STATE_WORDS.items(): - with self.subTest(state=word): - self.assertGreaterEqual( - contrast_ratio(self.theme.STATE_TEXT_COLOUR[key], - self.theme.STATE_FILL[key]), 4.4) - for severity in self.theme.SEVERITIES: - with self.subTest(severity=severity): - self.assertGreaterEqual( - contrast_ratio(self.theme.SEVERITY_TEXT[severity], - self.theme.SEVERITY_FILL[severity]), 4.4) - - def test_fonts_resolve_to_platform_families_without_downloads(self) -> None: - """Webfont yok: aday listesi platformda VAR OLAN bir yuzeye duser.""" - self.assertTrue(self.theme.FONT_UI_CANDIDATES) - self.assertTrue(self.theme.FONT_DATA_CANDIDATES) - self.assertTrue(self.theme.FONT_UI_FALLBACK) - self.assertTrue(self.theme.FONT_DATA_FALLBACK) - for family in (list(self.theme.FONT_UI_CANDIDATES) - + list(self.theme.FONT_DATA_CANDIDATES)): - with self.subTest(family=family): - self.assertNotIn("/", family) - self.assertNotIn(".ttf", family.lower()) - self.assertNotIn("http", family.lower()) - - def test_px_tokens_convert_to_tk_point_sizes(self) -> None: - sizes = (self.theme.SIZE_XS, self.theme.SIZE_SM, self.theme.SIZE_MD, - self.theme.SIZE_LG, self.theme.SIZE_XL, self.theme.SIZE_2XL) - for size in sizes: - with self.subTest(size=size): - self.assertLess(self.theme.point_size(size), size) - self.assertGreaterEqual(self.theme.point_size(size), 7) - # Siralama korunur: buyuk token daha buyuk puan uretir (esitlik yok). - points = [self.theme.point_size(size) for size in sizes] - self.assertEqual(points, sorted(points)) - self.assertEqual(len(set(points)), len(points)) - - def test_the_space_rhythm_is_the_committed_one(self) -> None: - self.assertEqual( - [self.theme.SP_1, self.theme.SP_2, self.theme.SP_3, self.theme.SP_4, - self.theme.SP_5, self.theme.SP_6, self.theme.SP_7], - [4, 8, 12, 16, 24, 32, 48], + @staticmethod + def install_items() -> Tuple[Tuple[str, str], ...]: + return ( + ("Ön kontrol (--check)", "kurulum betiği --check; hiçbir şey yazmaz"), + ("Bu cihazı kur", "tam kurulum; bayi numarası sorulur"), + ("Başarısız kurulumu sürdür", "kurulum betiği --resume"), + ("Tek modül çalıştır", "kurulum betiği --only"), + ("Modül durumunu göster", config.INSTALL_STATE_FILE), + ("Kurulum kaydını göster", "{0} son 40 satır".format(config.INSTALL_LOG_FILE)), ) - self.assertEqual(self.theme.ROW_H, 44) - self.assertEqual(self.theme.HEADER_H, 56) + def install_preflight(self) -> None: + installer = self.require_installer() + if installer is None: + return + dealer = self.ask_dealer() + if dealer is None: + return + self.console.note("Salt okunur ön kontrol: --check modunda kayıt, durum ve dizin oluşturulmaz.") + argv = operations.build_install_command(installer, dealer, check_mode=True) + self.runner.run_readonly(argv, title="Ön kontrol: {0}".format( + operations.dealer_to_device_id(dealer))) + self.pause() -class TestStateVocabulary(StaticCase): - """Durum sozlugu: TEK sozcuk, renk tek basina anlam tasimaz.""" - - def test_the_five_words_are_exactly_the_committed_ones(self) -> None: - self.assertEqual(ui.DEVICE_STATES, - ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK")) - self.assertEqual(set(ui.DEVICE_STATES), set(ui.theme.STATE_WORDS.values())) - - def test_every_word_has_its_own_badge_style(self) -> None: - styles = [ui.state_badge_style(word) for word in ui.DEVICE_STATES] - self.assertEqual(len(set(styles)), len(styles), styles) - for style in styles: - self.assertTrue(style.endswith(".Badge.TLabel"), style) - for key, style in ui.theme.STATE_BADGE_STYLE.items(): - with self.subTest(state=key): - self.assertEqual(style, - ui.state_badge_style(ui.theme.STATE_WORDS[key])) - - def test_every_word_has_its_own_text_colour(self) -> None: - self.assertEqual(len(set(ui.theme.STATE_TEXT_COLOUR.values())), - len(ui.DEVICE_STATES)) - - def test_badge_fills_come_from_the_token_set(self) -> None: - """Dolgu renkleri token kumesinden gelir; uydurulmus renk yok.""" - allowed = set(ui.theme.TOKENS.values()) | set(ui.theme.SEVERITY_FILL.values()) - for key, fill in ui.theme.STATE_FILL.items(): - with self.subTest(state=key): - self.assertIn(fill, allowed) - self.assertIn(ui.theme.STATE_TEXT_COLOUR[key], allowed) - - def test_provisioned_is_not_the_action_accent(self) -> None: - """KURULDU ile secili satir ayni maviye boyanamaz (tokens.css).""" - self.assertNotEqual(ui.theme.STATE_TEXT_COLOUR["provisioned"], - ui.theme.ACCENT) - self.assertNotEqual(ui.theme.STATE_FILL["provisioned"], - ui.theme.ACCENT_SOFT) - - def test_unknown_word_falls_back_to_a_neutral_badge(self) -> None: - self.assertEqual(ui.state_badge_style("BOYLE-BIR-DURUM-YOK"), - ui.theme.STATE_BADGE_STYLE["absent"]) - - def test_the_state_machine_maps_to_one_word(self) -> None: - cases = ( - ({}, "YOK"), - ({"phase": "PROVISIONED_OFFLINE"}, "KURULDU"), - ({"phase": "PROVISIONED_OFFLINE", - "enrollment_status": "ENROLLED"}, "KAYITLI"), - ({"fleet_status": "READY"}, "HAZIR"), - ({"phase": "FAILED"}, "HATA"), - ({"enrollment_status": "NOT_ENROLLED"}, "KURULDU"), + def install_apply(self, resume: bool) -> None: + installer = self.require_installer() + if installer is None: + return + dealer = self.ask_dealer() + if dealer is None: + return + if not self.require_root(): + self.pause() + return + offline = False + if not resume: + answer = self.console.confirm("Çevrimdışı kurulum (yerel paket deposu) kullanılsın mı?") + if answer is None: + raise Quit() + offline = answer + resume_answer = self.console.confirm("Daha önce OK olan modüller atlanılsın mı (--resume)?") + if resume_answer is None: + raise Quit() + resume = resume_answer + argv = operations.build_install_command( + installer, dealer, offline=offline, resume=resume, check_mode=self.check_mode ) - for values, expected in cases: - with self.subTest(values=values): - self.assertEqual(ui.state_word_from_values(values), expected) + self.console.info("Hedef cihaz: {0} ({1})".format( + operations.dealer_to_device_id(dealer), operations.dealer_to_host(dealer))) + self.runner.confirm_and_run( + argv, label="kurulum: {0}".format(operations.dealer_to_device_id(dealer)), destructive=True + ) + self.pause() - def test_missing_or_broken_state_file_is_the_absent_state(self) -> None: - self.assertEqual(ui.device_state_word("/yok/boyle/bir/state.json"), "YOK") - self.assertEqual(ui.read_state_values("/yok/boyle/bir/state.json"), {}) - with tempfile.TemporaryDirectory() as tmp: - broken = Path(tmp) / "state.json" - broken.write_text("{bu json degil", encoding="utf-8") - self.assertEqual(ui.device_state_word(str(broken)), "YOK") + def install_single_module(self) -> None: + installer = self.require_installer() + if installer is None: + return + modules = operations.module_names(self.repo) + if not modules: + self.console.warn("Kurulum modülü bulunamadı: scripts/install/installer/modules/") + self.pause() + return + self.console.section("Kurulum modülleri") + for index, name in enumerate(modules, start=1): + self.console.write(tui.render_item(str(index), name, "", self.console.palette)) + value = self.console.read("Modül numarası > ") + if value is None: + return + value = value.strip() + if not value.isdigit() or not (1 <= int(value) <= len(modules)): + self.console.warn(config.MSG_MENU_RANGE.format(max=len(modules))) + self.pause() + return + module = modules[int(value) - 1] + dealer = self.ask_dealer() + if dealer is None: + return + if not self.require_root(): + self.pause() + return + argv = operations.build_install_command( + installer, dealer, only=module, check_mode=self.check_mode + ) + self.runner.confirm_and_run( + argv, label="tek modül ({0}): {1}".format(module, operations.dealer_to_device_id(dealer)), + destructive=True, + ) + self.pause() - def test_state_json_is_read_through_a_whitelist(self) -> None: - """Jeton ve anahtar alanlari ekrana/loga ASLA okunmaz (K-14).""" - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "state.json" - path.write_text(json.dumps({ - "phase": "READY", - "device_id": "BF-12010193", - "enrollment_token": "SEKRET-JETON", - "private_key": "-----BEGIN PRIVATE KEY-----", - }), encoding="utf-8") - values = ui.read_state_values(str(path)) - self.assertEqual(ui.device_state_word(str(path)), "HAZIR") - self.assertIn("device_id", values) - self.assertNotIn("enrollment_token", values) - self.assertNotIn("private_key", values) - self.assertNotIn("SEKRET-JETON", json.dumps(values)) - self.assertNotIn("BEGIN PRIVATE KEY", json.dumps(values)) - - -class TestPrivilegeIsVisible(StaticCase): - """Yetki bir durumdur: yazma islemleri gizlenmez, KAPATILIR ve nedeni yazar.""" - - def test_privilege_levels_have_wording_and_a_badge(self) -> None: - self.assertEqual(ui.privilege_level(True), ui.PRIVILEGE_FULL) - self.assertEqual(ui.privilege_level(False), ui.PRIVILEGE_READ_ONLY) - self.assertEqual("SALT-OKUNUR", ui.privilege_word(ui.PRIVILEGE_READ_ONLY)) - self.assertEqual("TAM YETKİ", ui.privilege_word(ui.PRIVILEGE_FULL)) - self.assertEqual(set(ui.theme.PRIVILEGE_BADGE_STYLE), - {ui.PRIVILEGE_FULL, ui.PRIVILEGE_READ_ONLY}) - - def test_unprivileged_writes_stay_in_the_list_with_a_reason(self) -> None: - writes = [op for op in self.backend.list_operations() if op.needs_root] - reports = [op for op in self.backend.list_operations() if not op.needs_root] - self.assertTrue(writes, "katalogda yazma islemi yok; kapi test edilemez") - original = os.geteuid + def require_installer(self) -> Optional[str]: try: - os.geteuid = lambda: 1000 - for op in writes: - with self.subTest(operation=op.id): - self.assertEqual(ui.write_blocked_reason(op), - ui.NON_ROOT_DISABLED_HINT) - self.assertTrue(ui.operation_is_write(op)) - for op in reports: - with self.subTest(operation=op.id): - self.assertIsNone(ui.write_blocked_reason(op)) - self.assertFalse(ui.operation_is_write(op)) - os.geteuid = lambda: 0 - for op in writes: - with self.subTest(operation=op.id): - self.assertIsNone(ui.write_blocked_reason(op)) - finally: - os.geteuid = original + return self.repo.installer_path() + except Refused as exc: + self.console.exception(exc) + self.pause() + return None - def test_the_blocked_reason_names_the_command_to_get_privileges(self) -> None: - self.assertIn("sudo bf", ui.NON_ROOT_DISABLED_HINT) - self.assertIn("yönetici", ui.NON_ROOT_DISABLED_HINT.lower()) + # ------------------------------------------------------------------ + # Category: Cihaz işlemleri (fleet) + # ------------------------------------------------------------------ + def category_devices(self) -> None: + while True: + key = self.ask_menu(tui.devices_menu( + len(operations.REPORT_OPERATIONS), len(operations.MAINTENANCE_OPERATIONS))) + if key is None: + return + if key == "1": + self.operation_list(operations.REPORT_OPERATIONS, + "Salt okunur filo raporları", + "Bu listedeki hiçbir playbook cihazı değiştirmez") + elif key == "2": + self.operation_list(operations.MAINTENANCE_OPERATIONS, + "Filo bakım işlemleri", + "Yıkıcı işlemler iki kez onay ister") + elif key == "3": + if self.require_inventory(): + self.console.show_lines(operations.targets_lines(self.inventory)) + self.pause() + + def operation_list(self, ops: Sequence[Operation], title: str, subtitle: str) -> None: + screen = tui.Screen( + title=title, + subtitle=subtitle, + entries=tuple(tui.item(op.label, op.description) for op in ops), + ) + key = self.ask_menu(screen) + if key is None: + return + index = int(key) - 1 + if 0 <= index < len(ops): + self.run_operation(ops[index]) + + # ------------------------------------------------------------------ + # Category: Merkez hazırlığı + # ------------------------------------------------------------------ + def category_central(self) -> None: + while True: + key = self.ask_menu(tui.central_menu()) + if key is None: + return + if key == "1": + self.console.show_lines(operations.central_readiness_lines( + self.repo, self.inventory, self.inventory_error)) + elif key == "2": + self.console.show_lines(operations.central_endpoint_lines(self.repo)) + elif key == "3": + self.console.show_lines(operations.central_probe_lines(self.repo)) + elif key == "4": + self.console.show_lines(operations.wave_gate_state_lines(self.inventory)) + elif key == "5": + self.console.show_lines(operations.central_checklist_lines()) + self.pause() + + # ------------------------------------------------------------------ + # Category: Araçlar + # ------------------------------------------------------------------ + def category_tools(self) -> None: + while True: + key = self.ask_menu(tui.tools_menu()) + if key is None: + return + if key == "1": + self.tool_git_pull() + elif key == "2": + self.console.show_lines(operations.environment_lines( + self.repo, self.runner.audit.path or self.runner.audit.requested, + self.inventory, self.runner.dry_run, self.runner.check_mode)) + self.console.kv("Denetim kaydı modu", operations.file_mode( + self.runner.audit.path or "")) + self.pause() + elif key == "3": + self.console.show_lines(catalogue_lines()) + self.pause() + elif key == "4": + self.run_tool("bf-check-local", args=("--check",)) + self.pause() + elif key == "5": + self.run_tool("bf-check-enrollment", args=("--check",)) + self.pause() + elif key == "6": + self.run_tool("bf-check-ready", args=("--check",)) + self.pause() + elif key == "7": + if self.run_tool("bf-diagnostics") is None: + self.run_tool("bf-status") + self.pause() + elif key == "8": + self.run_tool(config.TOOL_LIVE_HW_CHECK) + self.pause() + elif key == "9": + self.run_tool("bf-remote-status", args=("--check",)) + self.pause() + elif key == "10": + self.run_tool("bf-release") + self.pause() + elif key == "11": + self.tool_hardware_inventory() + elif key == "12": + self.tool_support_bundle() + elif key == "13": + self.tail_file(self.runner.audit.path or config.DEFAULT_LOG_FILE, + "Konsol denetim kaydı") + self.pause() + + def run_tool(self, tool: str, args: Sequence[str] = (), title: str = ""): + """Run a repository diagnostics tool (read-only screen).""" + try: + path = self.repo.tool_path(tool) + except Refused as exc: + self.console.exception(exc) + return None + argv = [path] + list(args) + return self.runner.run_readonly(argv, title=title or tool) + + def tool_git_pull(self) -> None: + self.runner.confirm_and_run( + ["git", "-C", self.repo.root, "pull", "--ff-only"], + label="depoyu güncelle ({0})".format(self.repo.root), destructive=True, + ) + self.console.note("Depo değişmiş olabilir; konsolu yeniden başlatın.") + self.pause() + + def tool_hardware_inventory(self) -> None: + try: + path = self.repo.tool_path("bf-hardware-inventory.sh") + except Refused as exc: + self.console.exception(exc) + self.pause() + return + dealer = self.ask_dealer() + if dealer is None: + return + out_dir = "/var/lib/blueforce/inventory" if self.runner.is_root() \ + else os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), + "blueforce", "inventory") + argv = [path, "--dealer-id", dealer, "--out-dir", out_dir] + self.runner.run_readonly(argv, title="Donanım envanteri: {0}".format( + operations.dealer_to_device_id(dealer))) + self.pause() + + def tool_support_bundle(self) -> None: + self.run_tool("bf-support-bundle", args=("--out-dir", config.SUPPORT_BUNDLE_OUT_DIR)) + self.pause() + + # ------------------------------------------------------------------ + # Category: Cihaz bilgisi (read-only) + # ------------------------------------------------------------------ + def category_info(self) -> None: + while True: + key = self.ask_menu(tui.info_menu()) + if key is None: + return + if key == "1": + self.console.show_lines(operations.identity_lines()) + elif key == "2": + self.run_tool("bf-status") + elif key == "3": + self.console.show_lines(operations.state_lines()) + elif key == "4": + self.info_network() + elif key == "5": + self.info_resources() + elif key == "6": + self.info_packages() + elif key == "7": + self.info_services() + elif key == "8": + self.info_journal() + elif key == "9": + if self.require_inventory(): + self.console.show_lines(operations.targets_lines(self.inventory)) + elif key == "10": + self.info_resolve_target() + continue + self.pause() + + def _run_readonly(self, argv: Sequence[str], title: str) -> None: + self.runner.run_readonly(argv, title=title) + + def info_network(self) -> None: + self._run_readonly(["ip", "-brief", "address"], "Ağ arayüzleri") + self._run_readonly(["ip", "route", "show", "default"], "Varsayılan rotalar") + if os.path.isfile("/etc/resolv.conf"): + self._run_readonly(["awk", "/^nameserver/{print}", "/etc/resolv.conf"], + "DNS sunucuları (yalnız nameserver satırları)") + self._run_readonly(["wg", "show", "wg0", "listen-port"], "WireGuard dinleme portu") + self._run_readonly(["sh", "-c", "wg show wg0 peers | wc -l"], "WireGuard eş sayısı") + self._run_readonly(["wg", "show", "wg0", "latest-handshakes"], + "WireGuard el sıkışma zamanları (anahtar yok)") + self.console.note("WireGuard özel ve ön paylaşımlı anahtarları asla istenmez, gösterilmez ve kaydedilmez.") + + def info_resources(self) -> None: + self._run_readonly(["df", "-h"], "Dosya sistemleri") + self._run_readonly(["free", "-h"], "Bellek") + self._run_readonly(["uptime"], "Yük ortalaması ve açık kalma süresi") + + def info_packages(self) -> None: + self._run_readonly( + ["dpkg-query", "-W", "-f=${Package} ${Version} ${Status}\n", + "docker-ce", "wireguard", "rustdesk", "xrdp", "openssh-server"], + "Kurulu sabit paketler", + ) + + def info_services(self) -> None: + self._run_readonly( + ["systemctl", "is-active", "ssh", "sshd", "xrdp", "rustdesk", "docker", + "wg-quick@wg0", "meshagent", "cron"], + "Servis durumu (salt okunur)", + ) + + def info_journal(self) -> None: + self._run_readonly(["journalctl", "-p", "err", "-n", "20", "--no-pager"], + "Son journal hataları (20)") + + def info_resolve_target(self) -> None: + self.console.section("Hedefi çözümle") + self.console.note("Bayi numarası, grup adı, 'all' ya da bf- ana makine adı kabul edilir.") + value = self.console.read(config.MSG_PROMPT_TARGET) + if value is None or not value.strip(): + return + if not self.require_inventory(): + self.pause() + return + mode = config.MODE_ANY + answer = self.console.confirm("Bu hedef dalga kapsamlı bir işlem için mi?") + if answer: + mode = config.MODE_WAVE + try: + target = operations.resolve_target(value, mode, self.inventory) + except Refused as exc: + self.console.exception(exc) + self.pause() + return + self.console.kv("Tür", target.kind) + self.console.kv("Ansible --limit", target.limit) + self.console.kv("Dalga", target.wave or "yok") + self.console.kv("Cihaz sayısı", str(target.count)) + self.console.note("Örnek: ansible-playbook -i {0} {1}/bf-ping.yml --limit {2}".format( + self.inventory.labels(), config.PLAYBOOK_DIR_REL, target.limit)) + self.pause() + + # ------------------------------------------------------------------ + # Local helpers + # ------------------------------------------------------------------ + def show_file(self, path: str, title: str) -> None: + self.console.section(title) + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except OSError: + self.console.warn("{0} okunamadı veya yok.".format(path)) + return + for line in lines: + self.console.write(" " + line) + + def tail_file(self, path: str, title: str, lines: int = 40) -> None: + if not os.path.isfile(path): + self.console.warn("{0} yok.".format(path)) + return + self.runner.run_readonly(["tail", "-n", str(lines), path], title=title) -class TestConsoleLayoutContract(StaticCase): - """Olculen sorunlar geri gelmesin: kaynak duzeyinde baglayici kurallar.""" - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.source = UI_GUI_PATH.read_text(encoding="utf-8") - - def test_the_view_has_no_hardcoded_colour(self) -> None: - """Renk token'dir, literal degil (docs/DESIGN.md kural 1).""" - self.assertEqual(re.findall(r"#[0-9a-fA-F]{3,8}\b", self.source), []) - - def test_there_is_exactly_one_exit_control(self) -> None: - """'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil.""" - self.assertEqual(self.source.count('text="Çıkış"'), 1) - self.assertIn("Çıkış", ui.SIMPLE_MODE_BUTTONS) - for needle in ("self.exit_button.pack(side=\"right\")", - "self.exit_button.pack_forget()"): - with self.subTest(needle=needle): - self.assertIn(needle, self.source) - - def test_the_header_carries_the_privilege_element(self) -> None: - """Yetki gomulu bir cumle degil, basliktaki gorunur bir rozet.""" - for needle in ("PRIVILEGE_BADGE_STYLE", "self.privilege_badge", - 'text="Yetki"', "ROOT_REQUIRED_NOTICE", - 'style=self.styles["warn_band"]'): - with self.subTest(needle=needle): - self.assertIn(needle, self.source) - - def test_the_header_carries_the_device_state_and_target(self) -> None: - for needle in ("self.state_badge", "self.header_target", - "device_state_word()", "state_badge_style("): - with self.subTest(needle=needle): - self.assertIn(needle, self.source) - - def test_write_operations_are_disabled_not_hidden(self) -> None: - for needle in ('state="disabled"', "BLOCKED_LABEL", "AVAILABLE_LABEL", - "write_blocked_reason"): - with self.subTest(needle=needle): - self.assertIn(needle, self.source) - - def test_the_status_strip_is_a_state_pill_not_a_plain_label(self) -> None: - self.assertIn("theme.severity_style", self.source) - self.assertNotIn('text="Hazır."', self.source) - - def test_the_output_area_is_a_read_only_well(self) -> None: - for needle in ('state="disabled"', "BG_SUNKEN", "data_font", - 'cursor="arrow"'): - with self.subTest(needle=needle): - self.assertIn(needle, self.source) - - def test_the_view_reads_its_values_from_the_theme_module(self) -> None: - self.assertIn("ui_theme as theme", self.source) - self.assertTrue((REPO_ROOT / "bfos" / "ui_theme.py").is_file()) +# --------------------------------------------------------------------------- +# Non-interactive listings +# --------------------------------------------------------------------------- +def catalogue_lines() -> List[str]: + lines: List[str] = [] + for title, ops in ((config.LIST_TITLE_REPORTS, operations.REPORT_OPERATIONS), + (config.LIST_TITLE_MAINTENANCE, operations.MAINTENANCE_OPERATIONS)): + lines.append("") + lines.append(title) + for index, op in enumerate(ops, start=1): + lines.append(" {0:>2}) {1:<34} {2:<36} kapsam={3:<8} yıkıcı={4}".format( + index, op.ident, op.playbook, op.declared_scope, "evet" if op.destructive else "hayır")) + lines.append("") + lines.append(config.LIST_NOTE_WAVE) + lines.append(config.LIST_NOTE_RELEASE) + lines.append(config.LIST_NOTE_PLAYBOOK) + return lines -if __name__ == "__main__": - unittest.main(verbosity=2) +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def _wants_tui(args: argparse.Namespace) -> bool: + """Whether this invocation belongs to the terminal console. + + The GUI is the default. A few flags exist only in the terminal console -- + they print text and exit, or tune terminal rendering -- so an invocation + carrying one of them must stay there instead of being swallowed by the + router. ``--tui`` forces it explicitly. + """ + if args.tui: + return True + if args.gui: + return False + # --list prints a text catalogue (the TUI's report format) and exits; it is + # a report, not an interactive surface, so it stays in the terminal console. + return bool(args.list or args.check or args.list_targets or args.no_color + or args.log_file != config.DEFAULT_LOG_FILE + or args.lang != config.DEFAULT_LANG) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.tui and (args.gui or args.install or args.portal): + 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 + + if args.version: + sys.stdout.write("{0} {1}\n".format(config.PROGRAM_NAME, config.VERSION)) + return config.EXIT_OK + + if args.lang not in config.SUPPORTED_LANGS: + sys.stderr.write(config.MSG_LANG_UNSUPPORTED.format( + value=args.lang, supported=", ".join(config.SUPPORTED_LANGS)) + "\n") + return config.EXIT_USAGE + + if not _wants_tui(args): + # Imported lazily: the GUI pulls in tkinter, and the terminal console + # must keep working on a device where that import is unavailable. + from . import ui_gui + gui_argv: List[str] = [] + if args.full: + gui_argv.append("--full") + if args.install: + gui_argv.append("--install") + if args.portal: + gui_argv.append("--portal") + if args.dry_run: + gui_argv.append("--dry-run") + if args.repo: + gui_argv.extend(["--repo", args.repo]) + if args.inventory: + gui_argv.extend(["--inventory", args.inventory]) + if args.list: + gui_argv.append("--list") + return ui_gui.main(gui_argv) + + try: + repo = resolve_repo(args.repo) + except Refused as exc: + sys.stderr.write(exc.message + "\n") + if exc.detail: + sys.stderr.write(exc.detail + "\n") + return config.EXIT_USAGE + + palette = config.Palette(tui.color_enabled( + no_color=args.no_color, mode="auto", is_tty=tui.stream_is_tty(sys.stdout))) + console = tui.Console(palette=palette) + + audit = runner.AuditLog(requested=args.log_file) + audit.initialise() + if audit.warning: + console.warn(audit.warning) + + inventory_error = "" + try: + inventory_path = repo.find_inventory(args.inventory) + inventory = Inventory.load(inventory_path) + except Refused as exc: + inventory = Inventory(path=None, groups={}) + inventory_error = exc.message + ("\n" + exc.detail if exc.detail else "") + except (OSError, ValueError) as exc: + inventory = Inventory(path=None, groups={}) + inventory_error = config.MSG_INVENTORY_UNREADABLE.format(path=args.inventory) + "\n" + str(exc) + + # Non-interactive listings first: they need no terminal and no Ansible. + if args.list: + console.show_lines(catalogue_lines()) + return config.EXIT_OK + if args.list_targets: + console.show_lines(operations.targets_lines(inventory)) + return config.EXIT_OK + + console.note("{0} {1} — {2}".format(config.PROGRAM_NAME, config.VERSION, config.SCREEN_SUBTITLE)) + console.kv("Depo", repo.root) + console.kv("Envanter", inventory.labels()) + console.kv("Denetim kaydı", audit.path or "yok") + if args.dry_run: + console.warn(config.MSG_DRY_RUN) + if args.check: + console.info(config.MSG_CHECK_MODE) + if not audit.ready: + console.warn(config.MSG_LOG_NONE) + if not inventory.resolved: + console.warn(inventory_error or config.MSG_INVENTORY_MISSING.format( + dir=repo.path(config.INVENTORY_DIR_REL))) + console.note("Filo kategorileri çalışmayacak; yerel kurulum ve bilgi ekranları kullanılabilir.") + if not runner.Runner.have_ansible(): + console.warn(config.MSG_ANSIBLE_MISSING) + + audit.write(config.AUDIT_SESSION, ( + "{0} {1} başlangıç uid={2} repo={3} dry_run={4} check={5} full={6}" + ).format(config.PROGRAM_NAME, config.VERSION, os.getuid(), repo.root, + args.dry_run, args.check, args.full)) + + app = App( + repo=repo, + console=console, + runner_=runner.Runner(console, audit=audit, dry_run=args.dry_run, check_mode=args.check), + inventory=inventory, + full_mode=args.full, + check_mode=args.check, + inventory_error=inventory_error, + inventory_arg=args.inventory, + ) + try: + code = app.run() + except KeyboardInterrupt: + console.write("") + console.note("Kullanıcı tarafından kesildi (Ctrl+C).") + return 130 + return code + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) + + + +"""Static configuration for the bfos operator console. + +Everything an operator can read lives here, in Turkish, so the wording can be +reviewed and changed in one place. Identifiers and comments stay English. + +Rules kept in this module: + * standard library only (Python 3.8+), no third-party import, + * no side effect at import time (no directory is created, nothing is read), + * thresholds and allowlists are shared with the Ansible playbooks/docs: the + canonical wave order and the dealer-number pattern come from docs/02 and + docs/10 exactly as admin/lib/targets.sh defines them. +""" + +# --------------------------------------------------------------------------- +# Identity and version +# --------------------------------------------------------------------------- +PROGRAM_NAME = "bfos" +VERSION = "1.0.0" +SCREEN_TITLE = "BLUEFORCE FIELD OS" +SCREEN_SUBTITLE = "Saha cihazı operatör konsolu" +PROGRAM_DESCRIPTION = "Blueforce saha cihazı operatör konsolu (Türkçe)" + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +DEFAULT_LOG_FILE = "/var/log/blueforce-console.log" +LOG_FALLBACK_SUBDIR = ("blueforce", "blueforce-console.log") +LOG_FILE_MODE = 0o600 +LOG_DIR_MODE = 0o700 + +INSTALLER_REL = "scripts/install/blueforce-install.sh" +PLAYBOOK_DIR_REL = "ansible/playbooks" +DIAGNOSTICS_DIR_REL = "scripts/diagnostics" +GROUP_VARS_REL = "ansible/inventory/group_vars/all.yml" +INVENTORY_DIR_REL = "ansible/inventory" +INVENTORY_EXAMPLE_REL = "ansible/inventory/hosts.example.yml" +INVENTORY_CANDIDATES = ("hosts.yml", "hosts.yaml", "hosts", "hosts.ini") + +STATE_FILE = "/var/lib/blueforce/state.json" # nosec - read-only whitelist +INSTALL_STATE_FILE = "/var/lib/blueforce/install-state" +INSTALL_LOG_FILE = "/var/log/blueforce-install.log" +TOOL_BIN_DIR = "/usr/local/bin" +ADMIN_KEY_FILE = "~/.ssh/blueforce_id_ed25519" +SUPPORT_BUNDLE_OUT_DIR = "/var/tmp" + +# --------------------------------------------------------------------------- +# Rules, allowlists and thresholds (mirrors admin/lib/targets.sh + docs/10) +# --------------------------------------------------------------------------- +DEALER_PATTERN = r"^[0-9]{8}$" +HOST_PATTERN = r"^bf-[0-9]{8}$" + +# Canonical rollout order. One place, so the menu, the gate checks and the +# fleet-wide loop can never disagree. +WAVES = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production") +PRODUCTION_WAVE = "production" + +# Target selection modes. +MODE_ANY = "any" +MODE_WAVE = "wave" +MODE_RELEASE = "release" + +# Scope names derived from the playbook file itself. +SCOPE_SIMPLE = "simple" +SCOPE_WAVE = "wave" +SCOPE_RELEASE = "release" + +SERVICE_ALLOWLIST = ("blueforce-agent", "docker", "ssh", "sshd", "systemd-journald", "cron") +SCRIPT_DIRS = ("/opt/blueforce/bin", "/usr/local/sbin") + +DISK_WARN_MIN = 1 +DISK_WARN_MAX = 100 +DEFAULT_REBOOT_GRACE = 300 +DEFAULT_LOG_LINES = 500 + +# Non-secret identity/lifecycle keys of state.json. +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 = ( + "wg_subnet", + "wg_interface", + "wg_hub_endpoint", + "monitoring_endpoint", + "monitoring_job", + "update_repo_url", + "update_repo_suite", + "meg_image_tag", + "support_bundle_dir", +) + +# Local diagnostics tools the console can drive (resolved in /usr/local/bin +# first, then in the checkout under scripts/diagnostics/). +DIAGNOSTIC_TOOLS = ( + "bf-status", + "bf-diagnostics", + "bf-check-local", + "bf-check-enrollment", + "bf-check-ready", + "bf-release", + "bf-remote-status", + "bf-support-bundle", + "bf-hardware-inventory.sh", +) + +TOOL_ENROLL_STATUS = "bf-enrollment-status" +TOOL_LIVE_HW_CHECK = "bf-live-hw-check" + +# Language: this console is Turkish-only on purpose (user decision). The option +# exists so a future translation has a single switch to grow into. +SUPPORTED_LANGS = ("tr",) +DEFAULT_LANG = "tr" + +# Layout and exit codes (the bash console uses the same contract). +CONSOLE_WIDTH = 78 +EXIT_OK = 0 +EXIT_FAIL = 1 +EXIT_USAGE = 2 + +# Minimal Python version this console supports. +MIN_PYTHON = (3, 8) + +# --------------------------------------------------------------------------- +# ANSI palette +# --------------------------------------------------------------------------- +ANSI = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", +} + + +class Palette: + """ANSI codes, or empty strings when colour is switched off.""" + + __slots__ = ( + "enabled", + "reset", + "bold", + "dim", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + ) + + def __init__(self, enabled: bool) -> None: + self.enabled = enabled + for name, code in ANSI.items(): + setattr(self, name, code if enabled else "") + + def paint(self, text: str, *names: str) -> str: + """Wrap TEXT in the given colour names (ignored when disabled).""" + if not self.enabled or not names: + return text + prefix = "".join(getattr(self, name) for name in names) + return "{0}{1}{2}".format(prefix, text, self.reset) + + +# --------------------------------------------------------------------------- +# Turkish messages +# --------------------------------------------------------------------------- +# Generic messages +MSG_QUIT = "Konsoldan çıkılıyor." +MSG_BACK = "Geri" +MSG_QUIT_ITEM = "Çıkış" +MSG_AT_TOP = "Zaten ana menüdesiniz." +MSG_UNKNOWN_CHOICE = "Geçersiz seçim: '{choice}'. Lütfen menüdeki numaralardan birini yazın." +MSG_MENU_RANGE = "Lütfen 1 ile {max} arasında bir numara yazın." +MSG_PRESS_ENTER = "Devam etmek için Enter'a basın" +MSG_CANCELLED = "İptal edildi; hiçbir şey çalıştırılmadı." +MSG_CANCELLED_RUN = "İşlem iptal edildi, komut çalıştırılmadı." +MSG_INVALID_INPUT = "Girdi anlaşılamadı: {value}" +MSG_REQUIRED = "Bu alan boş bırakılamaz." +MSG_NOT_A_TTY = "Bu ekran etkileşimli bir uçbirim gerektiriyor; çıkılıyor." + +# Interpreter / environment +MSG_PYTHON_MISSING = ( + "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir.\n" + "Python konsolu için sudo bf kullanın." +) +MSG_PYTHON_OLD = ( + "HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n" + "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_REPO_INVALID = ( + "HATA: {path} bir Blueforce deposu gibi görünmüyor (ansible/ ve scripts/ bulunamadı).\n" + "Depoyu klonlayıp konsolu oradan çalıştırın veya --repo YOL ile yolu belirtin." +) +MSG_ANSIBLE_MISSING = ( + "ansible-playbook bu makinede kurulu değil; filo işlemleri çalıştırılamaz.\n" + "Merkez makinaya kurun: sudo apt install ansible (veya: pipx install ansible-core)" +) +MSG_INVENTORY_MISSING = ( + "Ansible envanteri bulunamadı: {dir}\n" + "Gerçek envanteri oluşturun: cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml " + "ve bf-<8 haneli bayi no> cihazları doldurun." +) +MSG_INVENTORY_EXAMPLE_ONLY = ( + "Yalnızca örnek envanter var (örnek cihaz kimlikleri içerir): {path}\n" + "Gerçek cihazlara karşı kullanmayın; kopyalayıp gerçek kimlikleri yazın." +) +MSG_INVENTORY_UNREADABLE = "Envanter okunamıyor: {path}" +MSG_ROOT_REQUIRED = ( + "Bu işlem BU cihazda root yetkisi gerektirir.\n" + "Konsolu tekrar sudo ile başlatın: sudo admin/bf" +) +MSG_TOOL_MISSING = ( + "{tool} bulunamadı (/usr/local/bin altında ve depo içinde yok).\n" + "Kurulacak komut: sudo install -m 0755 scripts/diagnostics/{tool} /usr/local/bin/{tool}" +) +MSG_INSTALLER_MISSING = ( + "Kurulum betiği bulunamadı: {path}\n" + "--repo ile doğru depo yolunu gösterin." +) +MSG_PLAYBOOK_MISSING = ( + "Playbook bulunamadı: {path}\n" + "Konsolu deponun içinden çalıştırın (--repo YOL)." +) + +# Target selection +MSG_TARGET_NONE = "Hedef seçilmedi; komut üretilmedi." +MSG_TARGET_EMPTY = "Cihaz seçilmedi." +MSG_TARGET_DEALER_INVALID = ( + "Geçersiz bayi numarası: '{value}'. 8 hane olmalı, boşluk ve harf olmamalı (örn. 12010101)." +) +MSG_TARGET_UNKNOWN = "'{value}' bir bayi numarası (8 hane), grup ya da 'all' değil." +MSG_TARGET_DEVICE_UNKNOWN = ( + "{host} envanterde yok ({inventory}).\n" + "Cihazı önce ilgili dalga grubuna ekleyin." +) +MSG_TARGET_DEVICE_NO_WAVE = ( + "{host} onaylı bir dalgada değil. Dalga kapsamlı işlemler böyle bir cihazı reddeder (fail-closed)." +) +MSG_TARGET_GROUP_UNKNOWN = "'{group}' grubu envanterde yok. Bilinen gruplar: {groups}" +MSG_TARGET_GROUP_NOT_WAVE = ( + "'{group}' onaylı bir dalga değil. Dalga kapsamlı işlemler için: {waves}" +) +MSG_TARGET_GROUP_EMPTY = "'{group}' grubu boş; boş hedef reddedildi." +MSG_TARGET_ALL_EMPTY = "Envanterde hiç cihaz yok; boş hedef reddedildi." +MSG_TARGET_RELEASE_DEVICE = ( + "Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez, dalga grubu seçin." +) +MSG_TARGET_RELEASE_ALL = ( + "Sürüm kapılı işlemler için filo geneli reddedildi: güncelleme dalga dalga yapılır." +) +MSG_TARGET_KNOWN_GROUPS = "Bilinen gruplar: {groups}" + +# Gates +MSG_GATE_PLAN_TITLE = "Güvenlik kapıları" +MSG_GATE_ALREADY = "Bu işlem için ek kapı onayı gerekmiyor." +MSG_GATE_WAVE_TOKEN = ( + "Dalga onayı istendi: '{wave}'. Sürüm kapılı işlemler yalnızca yazılı dalga onayıyla koşar." +) +MSG_GATE_WAVE_TOKEN_ASK = "Onay için dalga adını aynen yazın ({wave}): " +MSG_GATE_WAVE_REFUSED = "Dalga onayı verilmedi; işlem yapılmadı." +MSG_GATE_GATE_FLAG = "Onayla birlikte playbook'a -e wave_gate_confirmed=true iletilecek." +MSG_GATE_PRODUCTION = ( + "DALGA 'production' CANLI FİLODUR. Playbook, operation/üretim onayı olmadan koşmayı reddeder." +) +MSG_GATE_PRODUCTION_ASK = "Canlı filo onayı için 'production' yazın: " +MSG_GATE_PRODUCTION_APPROVAL = ( + "ÜRETİM ONAYI: bu komut canlı filoyu değiştirir ve -e production_override=true iletilecek. Onaylıyor musunuz?" +) +MSG_GATE_PRODUCTION_REFUSED = "Üretim onayı verilmedi; işlem yapılmadı." +MSG_GATE_FLEET_TOKEN = ( + "FİLO GENELİ: komut envanterdeki tüm cihazları ({count}) hedefliyor. Bu en geniş kapsamdır." +) +MSG_GATE_FLEET_TOKEN_ASK = "Filo geneli onayı için 'all' yazın: " +MSG_GATE_FLEET_REFUSED = "Filo geneli onayı verilmedi; işlem yapılmadı." +MSG_GATE_WAVE_BY_WAVE = ( + "Bu playbook dalga kapsamlı: filo, dalga dalga işlenir ve ilk hatada DURUR.\n" + "Sıra: {order}" +) +MSG_GATE_WAVE_LOOP_ASK = "Dalga '{wave}' ({count} cihaz) için onay vermek üzere dalga adını yazın: " +MSG_GATE_WAVE_SKIPPED = "'{wave}' dalgası operatör tarafından atlandı." +MSG_GATE_WAVE_FAILED = "'{wave}' dalgası başarısız oldu (çıkış kodu {rc}); sıra DURDURULDU, sonraki dalgaya dokunulmadı." +MSG_GATE_WAVE_NOT_RUN = "'{wave}' dalgası tamamlanmadı; sıra DURDURULDU." +MSG_GATE_START_FIRST = "İlk dalga ile başlansın mı?" +MSG_GATE_ORDER = "lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production" +MSG_GATE_SCOPE_MISMATCH = ( + "RED: {playbook} için konsol kapsamı '{declared}' bekliyor, playbook '{actual}' diyor.\n" + "Playbook kendi kapılarının kaynağıdır; konsol tablosu güncellenmeden bu playbook çalıştırılmaz." +) +MSG_GATE_NO_TARGET = "Hedef seçilmedi; komut üretilmedi (kapı kuralı)." + +# Prompts / extra variables +MSG_PROMPT_DEALER = "Bayi numarası (8 hane): " +MSG_PROMPT_TARGET = "Hedef (bayi no, grup ya da 'all'): " +MSG_PROMPT_CHOICE = "Seçim > " +MSG_PROMPT_PACKAGES = "Paketler (virgülle ayrılmış, paket=sürüm): " +MSG_PROMPT_SERVICE = "Servis adı: " +MSG_PROMPT_SCRIPT = "Betik yolu (mutlak): " +MSG_PROMPT_SCRIPT_ARGS = "Betik argümanları (isteğe bağlı): " +MSG_PROMPT_GRACE = "Yeniden başlatma için bekleme süresi (saniye) [{default}]: " +MSG_PROMPT_DISK = "Uyarı eşiği yüzdesi (boş = playbook varsayılanı 80): " +MSG_PROMPT_LOG_LINES = "Cihaz başına log satırı [{default}]: " +MSG_PROMPT_VERSION = "Beklenen Field OS sürümü (boş = sapma denetimi yok): " + +MSG_PKG_HINT = "Yalnız kesin sürüm: paket=sürüm (aralık, 'latest' ve boş liste reddedilir)." +MSG_PKG_REQUIRED = "En az bir sabitlenmiş paket gerekir." +MSG_PKG_INVALID = "Geçersiz pin: '{value}'. Beklenen biçim: paket=sürüm (örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)." +MSG_PKG_LATEST = ( + "Reddedildi: '{value}' kesin bir sürüm değil. 'latest' politikası yoktur; " + "pinlenmiş sürüm yazın (docs/10)." +) +MSG_SERVICE_HINT = "İzinli servisler: {allowlist}" +MSG_SERVICE_INVALID = "Reddedildi: '{value}' izinli servis listesinde değil (playbook da reddederdi)." +MSG_SCRIPT_ABSOLUTE = "Betik yolu mutlak olmalı (/ ile başlamalı)." +MSG_SCRIPT_OUTSIDE = "Reddedildi: '{value}' izinli dizinlerin dışında ({dirs})." +MSG_GRACE_INVALID = "Bekleme süresi tam sayı saniye olmalı." +MSG_PCT_INVALID = "Eşik {min} ile {max} arasında olmalı." +MSG_LINES_INVALID = "Satır sayısı pozitif bir tam sayı olmalı." + +# Command / run flow +MSG_COMMAND_TITLE = "Çalıştırılacak komut" +MSG_RUN_ASK = "Bu komut çalıştırılsın mı? [y=çalıştır / c=--check ile çalıştır / N=iptal]: " +MSG_DESTRUCTIVE_WARN = "DURUM DEĞİŞTİREN İŞLEM: {label}" +MSG_DESTRUCTIVE_SUB = "Onaylamadan önce yukarıdaki komutu okuyun; seçilen cihazları değiştirir." +MSG_ROOT_REQUIRED = ( + "Bu işlem BU cihazda root yetkisi gerektirir.\n" + "Konsolu tekrar sudo ile başlatın: sudo bf" +) +MSG_DESTRUCTIVE_GATE = ( + "Yıkıcı işlem kapısı: {label} seçilen cihazları değiştirir. " + "Bu onaydan sonra komut üretilir ve çalıştırmadan önce ayrıca sorulur." +) +MSG_ASK_YES_NO = "{question} [e/H]: " +MSG_CHECK_MODE = "Check modu: komut --check ile çalışacak, hiçbir değişiklik uygulanmayacak." +MSG_DRY_RUN = "DENEME (dry-run) MODU: komutlar yazılır, asla çalıştırılmaz." +MSG_DRY_RUN_WARN = "dry-run: yukarıdaki komut ÇALIŞTIRILMADI." +MSG_RUN_OK = "Komut başarıyla tamamlandı (çıkış kodu 0)." +MSG_RUN_FAIL = "Komut {rc} çıkış koduyla başarısız oldu." +MSG_RUN_ABORTED = "Komut {signal} ile kesildi." +MSG_LOG_FALLBACK = "{requested} yazılamıyor; denetim kaydı {fallback} dosyasına yazılıyor (mod 600)." +MSG_LOG_NONE = "Yazılabilir denetim kaydı bulunamadı; bu oturum kaydedilmeyecek." + +# Simple (default) menu +SIMPLE_MENU_TITLE = SCREEN_TITLE +SIMPLE_MENU_SUBTITLE = "Bu cihazı kurmak ve durumunu görmek için 1 veya 2 yazın" +SIMPLE_ITEM_INSTALL_LABEL = "Kur" +SIMPLE_ITEM_INSTALL_DESC = "Bu cihazı Field OS standardına getir (kurulum ekranı)" +SIMPLE_ITEM_STATUS_LABEL = "Durum" +SIMPLE_ITEM_STATUS_DESC = "Kurulum ve cihaz durumunu göster (salt okunur)" +SIMPLE_ITEM_EXIT_LABEL = "Çıkış" +SIMPLE_ITEM_EXIT_DESC = "Konsoldan çık" +SIMPLE_ADVANCED_LABEL = "Detaylı mod" +SIMPLE_ADVANCED_DESC = "Filo işlemleri, merkez hazırlığı, araçlar ve 21 playbook" + +# Full (detailed) menu +FULL_MENU_TITLE = "Detaylı mod" +FULL_MENU_SUBTITLE = "Tüm operasyonlar; güvenlik kapıları her işlemde geçerlidir" +CAT_INSTALL = "Kurulum" +CAT_DEVICES = "Cihaz işlemleri" +CAT_CENTRAL = "Merkez hazırlığı" +CAT_TOOLS = "Araçlar" +CAT_INFO = "Cihaz bilgisi" + +MSG_FULL_MODE_HINT = "Detaylı mod açık: filo işlemleri ve araçlar görünür." +MSG_SIMPLE_MODE_HINT = "Basit mod: 1) Kur, 2) Durum, 3) Çıkış. Detaylı özellikler için 'd' yazın ya da --full ile başlatın." + +# Audit log +AUDIT_SESSION = "SESSION" +AUDIT_TARGET = "TARGET" +AUDIT_RUN = "RUN" +AUDIT_DRYRUN = "DRYRUN" +AUDIT_OK = "OK" +AUDIT_FAIL = "FAIL" +AUDIT_CANCEL = "CANCEL" +AUDIT_STOP = "STOP" + +# Non-interactive listings +LIST_TITLE_REPORTS = "Salt okunur filo raporları (cihazlara dokunmaz)" +LIST_TITLE_MAINTENANCE = "Filo bakım işlemleri (yıkıcı olanlar iki kez onay ister)" +LIST_NOTE_WAVE = "scope=wave -> -e operation_wave= -e operation_confirmed=true" +LIST_NOTE_RELEASE = "scope=release -> -e release_wave= + yazılı dalga onayı" +LIST_NOTE_PLAYBOOK = "Kapsam her playbook dosyasından okunur; uyuşmazsa çalıştırılmaz." + + + +"""Command execution and the audit log for the bfos console. + +Separation of duties: + + * :mod:`bfos.operations` decides *what* may run and produces an argv list, + * this module decides *whether* it runs (confirmation, dry-run, check mode), + executes it with :mod:`subprocess`, reports the exit code, and writes the + audit record. + +Guarantees shared by the Python GUI and TUI: + + * the command is always printed **before** it is confirmed and run, + * ``--dry-run`` executes nothing at all, + * the audit log is mode 600 (directories 700) and falls back to + ``$XDG_STATE_HOME/blueforce/`` when ``/var/log`` is not writable, + * no secret reaches the log: a single sanitizer redacts + ``password=``/``token=``/``secret=``/``api_key=``/``private_key=``/``psk=`` + style values and whole PEM blocks, + * raw command output is shown on screen and never copied into the log. +""" + +from __future__ import annotations + +import os +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Sequence + +from . import config + +# --------------------------------------------------------------------------- +# Secret redaction -- the single choke point before anything is logged +# --------------------------------------------------------------------------- +_SECRET_KEY = r"(?:password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)" +_SECRET_ASSIGN_RE = re.compile( + r"((?:" + _SECRET_KEY + r")[A-Za-z0-9_.-]*\s*[=:])\s*(\S+)", + re.IGNORECASE, +) +_PEM_LINE_RE = re.compile(r"BEGIN [A-Z0-9 ]*PRIVATE KEY") +_PEM_BLOCK_RE = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", + re.DOTALL, +) + +REDACTED = "" + + +def redact(text: str) -> str: + """Return TEXT with secret-looking values and PEM blocks removed. + + Deliberately blunt: log records are written for every command, so the + sanitizer must be safe for text that was never meant to be a secret. A PEM + header without its matching footer (truncated line, interrupted paste) is + treated as the start of a secret: everything from the header on is dropped, + because a half-copied key is still a key. + """ + if not text: + return text + cleaned = _PEM_BLOCK_RE.sub(REDACTED, text) + header = _PEM_LINE_RE.search(cleaned) + if header: + cleaned = cleaned[:header.start()] + REDACTED + cleaned = _SECRET_ASSIGN_RE.sub(lambda match: match.group(1) + REDACTED, cleaned) + return cleaned + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- +def fallback_log_path(state_home: Optional[str] = None, home: Optional[str] = None) -> str: + base = state_home or os.environ.get("XDG_STATE_HOME") or os.path.join( + home or os.path.expanduser("~"), ".local", "state" + ) + return os.path.join(base, *config.LOG_FALLBACK_SUBDIR) + + +def _prepare_log_file(path: str) -> bool: + """Create or tighten PATH to mode 600; True when it is usable.""" + directory = os.path.dirname(path) or "." + try: + if os.path.exists(path): + if not os.path.isfile(path) or not os.access(path, os.W_OK): + return False + os.chmod(path, config.LOG_FILE_MODE) + else: + if not os.path.isdir(directory): + os.makedirs(directory, mode=config.LOG_DIR_MODE, exist_ok=True) + if not os.access(directory, os.W_OK): + return False + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, config.LOG_FILE_MODE) + os.close(fd) + os.chmod(path, config.LOG_FILE_MODE) + except OSError: + return False + return _is_private(path) + + +def _is_private(path: str) -> bool: + """True when only the owner can read the log (never log into a world-readable file).""" + try: + mode = os.stat(path).st_mode + except OSError: + return False + return (mode & 0o077) == 0 + + +@dataclass +class AuditLog: + """Append-only audit trail. Never receives raw command output.""" + + requested: str = config.DEFAULT_LOG_FILE + state_home: Optional[str] = None + home: Optional[str] = None + path: Optional[str] = None + ready: bool = False + warning: str = "" + + def initialise(self) -> None: + for candidate in (self.requested, fallback_log_path(self.state_home, self.home)): + if not candidate: + continue + if _prepare_log_file(candidate): + self.path = candidate + self.ready = True + if candidate != self.requested: + self.warning = config.MSG_LOG_FALLBACK.format( + requested=self.requested, fallback=candidate + ) + return + self.ready = False + self.warning = config.MSG_LOG_NONE + + def write(self, level: str, message: str, timestamp: Optional[str] = None) -> None: + if not self.ready or not self.path: + return + stamp = timestamp or _utc_now() + record = "{0} [{1}] {2}\n".format(stamp, level, redact(message)) + try: + with open(self.path, "a", encoding="utf-8") as handle: + handle.write(record) + except OSError: + # A failing log must never break an operator's session. + self.ready = False + + +def _utc_now() -> str: + import datetime + + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- +RUN_MODE_RUN = "run" +RUN_MODE_CHECK = "check" +RUN_MODE_CANCEL = "cancel" + + +@dataclass +class RunResult: + argv: List[str] = field(default_factory=list) + executed: bool = False + dry_run: bool = False + check: bool = False + cancelled: bool = False + rc: Optional[int] = None + signal_name: str = "" + + @property + def ok(self) -> bool: + return self.executed and self.rc == 0 + + +def command_string(argv: Sequence[str]) -> str: + """A copy/pasteable shell line (like ``printf %q`` in the bash console).""" + return " ".join(shlex.quote(part) for part in argv) + + +def default_spawn(argv: Sequence[str]) -> int: + """Run ARGV with inherited stdio and return its exit code.""" + try: + completed = subprocess.run(list(argv), check=False) + return completed.returncode + except FileNotFoundError: + return 127 + except PermissionError: + return 126 + except KeyboardInterrupt: + return 130 + + +class Runner: + """Show, confirm and execute commands; record every decision.""" + + def __init__( + self, + console, + audit: Optional[AuditLog] = None, + dry_run: bool = False, + check_mode: bool = False, + spawn: Optional[Callable[[Sequence[str]], int]] = None, + ) -> None: + self.console = console + self.audit = audit or AuditLog() + self.dry_run = dry_run + self.check_mode = check_mode + self._spawn = spawn or default_spawn + + # -- helpers ----------------------------------------------------------- + def show_command(self, argv: Sequence[str], title: str = config.MSG_COMMAND_TITLE) -> str: + printable = command_string(argv) + self.console.section(title) + self.console.write(" " + printable) + self.console.write("") + return printable + + def _check_flag(self, argv: List[str]) -> List[str]: + if "--check" not in argv: + return argv + ["--check"] + return argv + + def _spawn_exit_code(self, argv: Sequence[str]) -> RunResult: + try: + rc = self._spawn(argv) + except KeyboardInterrupt: + return RunResult(argv=list(argv), executed=True, rc=130, signal_name="SIGINT") + return RunResult(argv=list(argv), executed=True, rc=rc) + + # -- public API -------------------------------------------------------- + def run_readonly(self, argv: Sequence[str], title: str) -> RunResult: + """Run a read-only tool: printed, not confirmed (mirrors bf_show_local_cmd).""" + self.show_command(argv, title=title) + if self.dry_run: + self.console.warn(config.MSG_DRY_RUN_WARN) + self.audit.write(config.AUDIT_DRYRUN, command_string(argv)) + return RunResult(argv=list(argv), dry_run=True) + self.audit.write(config.AUDIT_RUN, command_string(argv)) + result = self._spawn_exit_code(argv) + if result.rc == 0: + self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(argv)) + else: + self.console.warn(config.MSG_RUN_FAIL.format(rc=result.rc)) + self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(argv))) + return result + + def confirm_and_run( + self, + argv: Sequence[str], + label: str, + destructive: bool = False, + check_mode: Optional[bool] = None, + ) -> RunResult: + """Print the command, ask, then run it (or cancel / dry-run).""" + self.show_command(argv) + if self.dry_run: + self.console.warn(config.MSG_DRY_RUN_WARN) + self.audit.write(config.AUDIT_DRYRUN, command_string(argv)) + return RunResult(argv=list(argv), dry_run=True) + + if destructive: + self.console.warn(config.MSG_DESTRUCTIVE_WARN.format(label=label)) + self.console.warn(config.MSG_DESTRUCTIVE_SUB) + + mode = self.console.ask_run(config.MSG_RUN_ASK) + if mode == RUN_MODE_RUN: + final = list(argv) + elif mode == RUN_MODE_CHECK: + final = self._check_flag(list(argv)) + self.console.info(config.MSG_CHECK_MODE) + else: + self.console.info(config.MSG_CANCELLED_RUN) + self.audit.write(config.AUDIT_CANCEL, "operatör iptal etti :: " + command_string(argv)) + return RunResult(argv=list(argv), cancelled=True) + + # NOTE: this method does not enforce root. The terminal console gates a + # destructive operation earlier (App.require_root, config.MSG_ROOT_REQUIRED) + # and the GUI gates it at its execution choke point (ui_gui.run_plan). + # Enforcing here as well double-guarded the same rule and fired before + # the console's own message, so the operator saw the wrong explanation. + self.audit.write(config.AUDIT_RUN, command_string(final)) + result = self._spawn_exit_code(final) + result.check = "--check" in final + if result.rc == 0: + self.console.ok(config.MSG_RUN_OK) + self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(final)) + else: + self.console.error(config.MSG_RUN_FAIL.format(rc=result.rc)) + self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(final))) + return result + + # -- checks ------------------------------------------------------------ + @staticmethod + def have_ansible() -> bool: + return _which("ansible-playbook") is not None + + @staticmethod + def have_python() -> bool: + return sys.version_info >= config.MIN_PYTHON + + @staticmethod + def is_root() -> bool: + return os.geteuid() == 0 + + +def _which(name: str) -> Optional[str]: + for directory in os.environ.get("PATH", "").split(os.pathsep): + candidate = os.path.join(directory, name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + + +# 19 — Sorun Giderme (Troubleshooting) + +> Kısa özet: Sahada ve merkezde en sık görülen arızaların belirti → neden → komut → çözüm tablosu. 17-RECOVERY "hangi seviye" sorusunu, bu doküman "o seviyede hangi komut" sorusunu cevaplar. + +- Dosya: `docs/19-TROUBLESHOOTING.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-03` (xRDP/GUI), `#K-04` (erişim kanalları), `#K-05` (WireGuard), `#K-10` (Docker), `#K-14` (`bf-status`, support-bundle) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Tekrar eden arızalarda teşhisi hızlandırmak: belirtiyi bulan teknisyen/admin, tabloya bakıp ilk komutu 30 saniyede çalıştırır. Her girdide "ne zaman üst seviyeye çık" notu vardır. + +## 2. Kapsam + +- Kapsam içi: boot, ağ/VPN, SSH, RDP/GUI, RustDesk, MeshCentral, Docker/MEG, disk, monitoring ve operatör arayüzü (grafik/terminal konsol, paket deposu keşfi) belirti tabloları. +- Kapsam dışı: seviye kararları (17), adım-adım kurulum (20), merkez onay akışı (21), arayüzün tüm ekranları (33). + +## 3. Kararlar + +```text +KARAR: Her arıza girdisi aynı formatta yazılır: Belirti → Olası nedenler (olasılık sırasıyla) → Tanı komutları → Çözüm → Yükseltme kriteri. +GEREKÇE: Standart format, acemi teknisyenin bile doğru sırayla (tanıdan önce çözüm yok) ilerlemesini sağlar; her çözüm denenebilir komutla verilir. +ALTERNATİF: Serbest-anlatım sorun listesi — komutlar dağınık kalır, otomasyona çevrilemez; elendi. +RİSK: Tablo eskirse yanlış yönlendirir; azaltma: her PİLOT dalgası sonrası tablo gözden geçirilir. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Sorun giderme, kurtarma merdiveninin (17) "içini" doldurur: 17 hangi LEVEL, 19 o LEVEL'de hangi komut. Olasılık sırası önemlidir — en yaygın neden önce yazılır, teknisyen zamanı boşa harcanmaz. Yükseltme kriteri her girdinin sigortasıdır (inatla aynı komutu tekrarlamak yasak). + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Harici bilgi tabanı (Wiki araması) | Aranabilir | Çevrimdışı sahada erişilemez, Git akışı yok | Elendi (22'de Docusaurus içinde bu dosya zaten aranabilir) | +| Her arıza için ayrı sayfa | Detaylı | 700 cihazda 100 sayfa yönetilemez | Elendi (gerekirse Ek'e taşınır) | + +## 6. Avantajlar + +- Tek dosya, çevrimdışı da okunur (USB'de veya basılı). +- Komutlar kopyala-yapıştırılabilir; açıklamalar Türkçe, komut adları İngilizce orijinal. + +## 7. Dezavantajlar + +- 26.04.1'e özgü çıktı farkları LAB'da güncellenmelidir (kesin servis adları, log yolları). +- MEG'e özgü arızalar 13-MEG-ACCEPTANCE'tan beslenmelidir; bu dosya iskelettir. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Eski komut (servis adı değişti) | Orta | Orta | LAB'da her komut çalıştırılıp doğrulanır | +| Secret'lı komut geçmişe düşer | Düşük | Yüksek | Support-bundle kuralı (K-14); komutlarda secret yazılmaz | + +## 9. Uygulama Planı + +Önce genel tanı (her arızada ilk 60 saniye): + +```bash +bf-status +systemctl --failed +ip -brief addr; ip route show default +wg show wg0 2>&1 | head -20 +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.RestartPolicy}}' +df -h / /var/lib/docker | cat +``` + +Aynı raporlar operatör arayüzünden de okunur (komut ezberi gerekmez): + +```text +Arayüzden: sudo bf → `Durum` düğmesi (Durum özeti) ya da Detaylı mod → Cihaz bilgisi sekmesi + (kimlik, ağ/WireGuard, kaynaklar, servisler, son journal hataları — hepsi salt-okunur) +Komutla (yedek): sudo bf --tui → Cihaz bilgisi menüsü +``` + +### T-1 — Cihaz açılmıyor / ekranda hiçbir şey yok + +| # | Neden (olasılık sırası) | Tanı | Çözüm | +|---|---|---|---| +| 1 | Elektrik yok / BIOS güç ayarı | Priz, adaptör, güç LED'i | BIOS "Restore on AC Power Loss = Power On" (16) | +| 2 | Monitör/kablo | Başka kablo/ekran dene | Değiştir | +| 3 | Disk ölmüş | BIOS diski görmüyor | Donanım değişimi → L8 yeni diskte | + +Yükseltme: donanım sağlamsa ama OS açılmıyorsa T-2'ye geç. + +### T-2 — Boot terminale düşmüyor / servisler kalkmıyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Failed servis var | `systemctl --failed`, `journalctl -xb -p err` | İlgili servis girdisine git (T-3…T-6) | +| 2 | Disk dolu | `df -h /` | Docker log temizliği + rotasyon kontrolü (T-7) | +| 3 | Bozuk çekirdek/paket | `dpkg --audit`, `apt --fix-broken install` | 17-L7; olmazsa L8 | + +### T-3 — WireGuard tüneli yok (`wg show` boş / handshake eski) + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Servis inaktif | `systemctl status wg-quick@wg0` | `sudo systemctl enable --now wg-quick@wg0` | +| 2 | Anahtar/config hatası | `journalctl -u wg-quick@wg0 --no-pager`, `wg show wg0 latest-handshakes` | Config'i merkezden yeniden bas (17-L5); peer adı `bf-` mi kontrol et | +| 3 | Saha interneti yok / CGNAT | `ping -c3 8.8.8.8`, modem arayüzü | Yerel ağ düzeltilir; kopma sayacı not edilir (P1 görevi) | +| 4 | Merkez hub down | Başka cihazdan hub'a bak | 17-L9 kolu işletilir | + +Yükseltme: WG kalkmıyorsa RustDesk/MeshCentral ile devam (17-L4); onlar de yoksa saha ziyareti. + +### T-4 — SSH bağlanmıyor (WG ayaktayken) + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | sshd down / anahtar eşleşmiyor | `systemctl status ssh`, `ssh -vvv blueforce@bf-` | Anahtarı merkezden yeniden dağıt (K-13); password denemesi YOK | +| 2 | Yanlış IP/hostname | `ip -brief addr`, envanter karşılaştır | 02-NAMING'e göre düzelt | +| 3 | Güvenlik duvarı | `sudo ufw status`, `ss -tlnp` | 11-HARDENING standardına döndür | + +### T-5 — RDP açılmıyor / siyah ekran / hemen kopuyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | GUI katmanı kapalı | `systemctl status display-manager xrdp xrdp-sesman` | `bf-gui-on`, tekrar dene | +| 2 | Oturum çakışması (yerel + RDP aynı kullanıcı) | `who`, `loginctl list-sessions` | Ayrı kullanıcı/oturum; eski oturumu kapat | +| 3 | xRDP config bozulmuş | `journalctl -u xrdp --no-pager` | Bilinen-iyi config'i bas (17-L5) | + +### T-6 — RustDesk bağlanmıyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | İstemci servis/uygulama kapalı | RustDesk durumu, ID görünüyor mu | Servisi başlat; ID+röle adresi merkez değerlerle aynı mı | +| 2 | Key uyuşmazlığı | İstemci key vs hbbs key | Merkezi key'i yeniden bas (07-REMOTE-ACCESS) | +| 3 | hbbs/hbbr down | Merkezden konteyner/servis durumu | Merkez tarafını ayağa kaldır; health monitörü (Kuma) ne diyor | + +### T-7 — Docker/MEG kalkmıyor veya sürekli restart + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Restart loop (config/entrypoint) | `docker ps`, `docker logs --tail 100 ` | Config düzelt + `docker compose up -d` (17-L6) | +| 2 | Disk dolu (rotasyonsuz log) | `df -h /var/lib/docker`, `du -sh /var/lib/docker/containers/*` | `daemon.json` rotasyonu uygula; taşan logu buda | +| 3 | `latest` çekilmiş / sürüm kaymış | `docker images`, compose tag kontrolü | Sabit tag'e döndür (K-10); Watchtower varsa kaldır | +| 4 | Port çakışması / UFW bypass | `ss -tlnp`, `docker port ` | `127.0.0.1`-bind standardına döndür (K-10) | + +### T-8 — Disk doluyor + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Docker json-file logları | `du -sh /var/lib/docker/containers/*` | Rotasyon + budama, `docker system prune` (dikkatli) | +| 2 | journald şişmiş | `journalctl --disk-usage` | `SystemMaxUse` limiti + `journalctl --vacuum-size=` | +| 3 | Prometheus retention (merkez) | `df -h`, retention ayarı | 30–90 gün bandına çek (K-07) | + +### T-9 — Monitoring'de cihaz gri/kayıp + +| # | Neden | Tanı | Çözüm | +|---|---|---|---| +| 1 | Node Exporter down | `systemctl status node_exporter`, hedef scrape logu | Servisi başlat; textfile dizin izni | +| 2 | Uptime Push gelmiyor | Kuma "last seen" | Cihaz interneti + push job'ı (T-3'e dön) | +| 3 | Etiket yanlış (`BF-`) | Prometheus target etiketleri | Envanterle eşitle (02) | + +### T-10 — Grafik operatör arayüzü açılmıyor (`sudo bf` pencere açmıyor / hemen kapanıyor) + +| # | Neden (olasılık sırası) | Tanı | Çözüm | +|---|---|---|---| +| 1 | Ekran yok (SSH/başsız oturum) | Ekranda "Grafik ekran bulunamadı (DISPLAY ve WAYLAND_DISPLAY tanımlı değil)" + yönlendirme; çıkış kodu `3` | `sudo bf --tui` ile aynı işlemleri terminal konsolunda yürüt; yerel ekranda pencereli kullanmak için oturum açık bir makinede `sudo bf` | +| 2 | `python3-tk` kurulu değil (minimal imaj) | "tkinter bulunamadı; masaüstü arayüzü başlatılamıyor" + paket ipucu; çıkış kodu `3` | Çevrimdışı sahada paket kurulmaz: paket offline depoda pinlidir (`manifests/base-packages.txt`) ve normal kurulumla gelir. Eksikse `sudo bf --tui` ile devam et ve merkeze bildir; kurulum sonu kapısı (`18-final-check`) bu durumda **WARN** yazar ve kurulumu düşürmez | +| 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 | + +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) + +| # | Neden (olasılık sırası) | Tanı | Çözüm | +|---|---|---|---| +| 1 | Depo cihazda hiç yok | Ekranda `HATA: Yerel paket deposu yok. Repo sorumlusuna başvurun: provisioning/offline-repo/README.md` + **aranan yollar** + `ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.`; çıkış kodu `1`, sistem değişmez | Paket setini git ile tam olarak getir (paketler Git LFS ile gelir) ya da repo sorumlusunun bıraktığı kopyayı `/opt/blueforce/offline-repo` altına koy; kurulumu tekrar çalıştır | +| 2 | `packages/` dolu ama indeks (`built/`) yok | Aynı "depo yok" hatası: aday eksiksiz sayılmaz | Kurucu `Packages`, `Packages.gz`, `packages.lock.tsv`, `SHA256SUMS` dosyalarının **hepsini** arar; yalnız `.deb` havuzu yetmez. Repo sorumlusu `build-index.sh` ile `built/` dizinini üretip commit eder | +| 3 | Depo bulundu ama bozuk/eksik | `OFFLINE BLOCKED: ` + Türkçe çıkış nedeni (ör. checksum doğrulaması başarısız, kilit biçimi bozuk, pinlenen paket depoda yok); çıkış kodu `1` | Sahada onarılmaz: repo sorumlusu seti `add-packages.sh` + `build-index.sh` ile yeniden üretir, güncel git tarafı çekilir ve komut tekrarlanır. Cihazda hiçbir değişiklik yapılmadı | +| 4 | `BF_OFFLINE_REPO` yanlış | `OFFLINE BLOCKED: BF_OFFLINE_REPO mutlak bir yerel dizin olmalıdır.` | Değişkeni kaldır (varsayılan arama sırası kullanılır) veya geçerli bir mutlak dizin ver | +| 5 | "--offline bayrağını vermedim" kuşkusu | — | Bayrak **zorunlu değil**: kurucu depoyu her durumda otomatik arar (sıra: `BF_OFFLINE_REPO` → `/provisioning/offline-repo/built` → `/opt/blueforce/offline-repo`) | + +Yükseltme: paket seti sahada üretilmez/onarılmaz. İki denemede de aynı hata varsa cihazda hata ayıklamayı bırak, repo sorumlusuna başvur; kurulum paket adımlarına geçmediği için cihaz yarım durumda **kalmaz** (fail-closed). + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Her T girdisindeki ilk tanı komutu LAB'da çalışır | Komut + örnek çıktı bu dosyada | LAB(2) | +| WG-kill → T-3 → L4 onarım tatbikatı | < hedef sürede tünel döner | P1(5) | +| Disk-doldur simülasyonu → T-8 | Alarm + temizlik çalışır | LAB(2) | +| `DISPLAY` yok + `python3-tk` yok senaryoları → T-10 | Her iki durumda Türkçe yönlendirme, çıkış kodu 3, `--tui` önerisi | LAB(2) | +| Depo dizini kaldırılmış cihazda kurulum → T-11 | Türkçe hata + aranan yollar + çıkış nedeni, exit 1, sistem değişmez | LAB(2) | +| Yalnız `packages/`, indeks yok → T-11/2 | Kurulum "depo yok" diyerek durur (fail-closed) | LAB(2) | + +## 11. Rollback + +Yanlış çözüm uygulandıysa 17-L3 (son değişikliği geri alma) işletilir; tanı komutlarının kendisi read-only'dir, rollback gerektirmez. + +## 12. Kontrol Listesi + +- [ ] Her girdide Yükseltme kriteri var. +- [ ] Komutların tamamı LAB 26.04.1 imajında denendi. +- [ ] Hiçbir girdide secret/şifre yazılmıyor. +- [ ] MEG'e özgü satırlar 13 ile çapraz linkli. +- [ ] T-10/T-11 çıkış kodları (3 / 1) ve hata metinleri gerçek çıktıyla doğrulandı. +- [ ] Arayüz yolu belirtilen her girdide "Arayüzden" + "Komutla (yedek)" satırı var. + +## 13. Açık Sorular + +- [ ] Kesin `bf-gui-*` birim adları + GNOME oturum satırı — LAB çıktısı (sahibi: Faz 3). +- [ ] MEG log hacmi ölçümü → T-8 eşikleri (sahibi: 12-DOCKER yazarı). +- [ ] Turkcell CGNAT tanı notları — P1 verisi (sahibi: 08-WIREGUARD yazarı). +- [ ] `python3-tk` dışındaki grafik bağımlılıklarının da offline pinde gerekli olup olmadığı — LAB doğrulaması (sahibi: 27 + 33 yazarları). + +--- + +## Ek: Triyaj Akışı (hangi T girdisi?) + +```mermaid +flowchart TB + SORUN["Arıza bildirimi
    bf-status + systemctl --failed"] --> ARAC{"Sorun cihaz mı,
    operatör arayüzü mü?"} + ARAC -->|"Arayüz açılmıyor"| T10["T-10: DISPLAY / tkinter → sudo bf --tui"] + ARAC -->|"Kurulum paket adımına geçmiyor"| T11["T-11: yerel paket deposu"] + ARAC -->|Cihaz| ACILIR{"Cihaz açılıyor mu?"} + ACILIR -->|Hayır| T1["T-1: güç / BIOS / disk"] + ACILIR -->|Evet| BOOTOK{"Servisler kalkıyor mu?"} + BOOTOK -->|Hayır| T2["T-2: failed servis / disk dolu"] + BOOTOK -->|Evet| WG{"wg show handshake güncel mi?"} + WG -->|Hayır| T3["T-3: WireGuard + L4 yedek kanal"] + WG -->|Evet| SSH{"SSH oluyor mu?"} + SSH -->|Hayır| T4["T-4: sshd / anahtar / UFW"] + SSH -->|Evet| RDP{"RDP açılıyor mu?"} + RDP -->|Hayır| T5["T-5: bf-gui-on / xRDP"] + RDP -->|Evet| DERIN["T-6+: Docker / MEG / disk (T-7/T-8)"] +``` +
    + + +# 20 — Saha Teknisyeni Runbook + +> Kısa özet: Sahada cihaz başına giden teknisyenin el kitabı. Tek akış: USB'yi tak → Ubuntu'yu kur → `sudo bf` → bayi numarasını gir → sonucu gör. Komut ezberi istenmez: kurulum, durum ve destek paketi grafik arayüzdeki düğmelerle yapılır; her adımda "ne göreceksin, ne yazacaksın" vardır. + +- Dosya: `docs/20-FIELD-TECHNICIAN-RUNBOOK.md` +- İlgili kararlar: `24-DECISION-LOG.md#K-09` (USB → kurucu → bayi no), `#K-12` (bayi no → `BF-`), `#K-14` (`bf-status`, support-bundle), `#K-16` (offline kurulum) +- İlgili dokümanlar: `33-OPERATOR-CONSOLE.md` (arayüz rehberi), `27-OFFLINE-PROVISIONING.md` (paket deposu) +- Durum: [ ] Taslak + +--- + +## 1. Amaç + +Teknisyenin sahada tek başına, telefonsuz bile cihazı teslim edilebilir hale getirmesi. Bu dosya "neden" anlatmaz, "ne yap" söyler. Neden merak eden 01'e bakar. + +## 2. Kapsam + +- Kapsam içi: USB ile sıfır kurulum, grafik arayüzü açma, bayi no girişi, kurulum sonucunu doğrulama, arızalı cihazda ilk 3 hareket, merkeze ne zaman telefon açılacağı. +- Kapsam dışı: merkez onayları (21), arıza teşhis tablosu detayı (19 — sadece yönlendirme), update dalgaları (10). + +## 3. Kararlar + +```text +KARAR: Teknisyen akışı 4 adımdır: USB → Ubuntu kur → `sudo bf` ile kurulum (bayi no) → sonucu gör. Grafik arayüz tek giriş noktasıdır; terminal konsolu (`sudo bf --tui`) yalnız grafik ekran veya tkinter yoksa kullanılan yedektir. Teknisyen hostname, IP, anahtar, paket yolu veya config dosyası ile uğraşmaz; paketler depoyla gelir ve kurulumda internet gerekmez. +GEREKÇE: K-09/K-16'nın hedef akışı; insan hatası en çok elle config ve ezberlenen komutta olur. Teknisyenin tek manuel girdisi 8 haneli bayi numarasıdır (K-12). +ALTERNATİF: Teknisyene tam Linux eğitimi + manuel kurulum — 700 cihazda standardizasyon bozulur; elendi. Yalnız bash menülü kurulum — grafik arayüz varken gereksiz terminal bilgisi ister; yedek yol olarak korundu. +RİSK: Depo kopyası eski sürüm kalır; azaltma: repo sürüm etiketi + merkezden "şu sürümden eski kopyayı kullanma" duyurusu. +MALİYET: Ücretsiz. +LİSANS: Yok. +``` + +## 4. Neden Bu Karar? + +Teknisyenin işi "bilgisayarcılık" değil "bayi cihazını çalışır teslim etmek"tir. Karar, teknisyenin dikkatini tek kritik noktaya toplar: bayi numarasını doğru girmek. Geri kalan her şey uygulama + merkez otomasyonudur. Bu yüzden bu dosya kısa cümlelerle, emir kipiyle yazılır. + +## 5. Alternatifler + +| Alternatif | Artı | Eksi | Sonuç | +|---|---|---|---| +| Grafik arayüz (`sudo bf`) | Tek düğme, komut ezberi yok, onay görünür | Grafik katman gerekir | **Seçildi** | +| Detaylı Linux kılavuzu | Bilgili teknisyen | Uzun, hata açık, herkes okumaz | Elendi | +| Merkezden uzaktan kurulum (teknisyensiz) | Saha maliyeti yok | İlk kurulumda cihazda internet/VPN yok | Elendi (ilk kurulum sahada) | + +## 6. Avantajlar + +- Yeni teknisyen 1 saatlik eğitimle sahaya çıkar. +- Her cihaz aynı adımla kurulur; "ustalık farkı" kapanır. +- Kurulum internetsiz çalışır: paket seti depodan gelir, hiçbir şey indirilmez. + +## 7. Dezavantajlar + +- Uygulamanın yapmadığını teknisyen de yapamaz; olağandışı durumda merkeze bağımlıdır (bilerek böyle). +- Depo kopyası (paket setiyle birlikte ~888 MB) fiziksel/lojistik takip ister. + +## 8. Riskler + +| Risk | Olasılık | Etki | Azaltma | +|---|---|---|---| +| Bayi no yanlış girilir | Orta | Yüksek | Arayüz `BF-`'yu gösterir, teknisyen bayiye onaylatır | +| Eski depo kopyası ile kurulum | Orta | Orta | Repo sürüm etiketi + kurucu başlangıçta depoyu ve paket sayısını söyler | +| Grafik arayüz açılmaz (ekran/tkinter) | Orta | Orta | `sudo bf --tui` yedeği; sorun merkeze bildirilir | +| Sonuç görülmeden sahadan çıkılır | Düşük | Yüksek | Kontrol listesi imzalanmadan iş kapanmaz | + +## 9. Uygulama Planı + +### A. Sıfır kurulum (yeni cihaz) — 4 adım + +**ADIM 1 — USB'yi tak, bilgisayarı aç.** + +1. Kurulum USB'sini tak. +2. Bilgisayarı aç, USB'den başlat (açılışta genelde F12/F8/F2 — ekranda yazar). +3. Ubuntu kurulum ekranı gelene kadar bekle. + +> Göreceğin: Ubuntu kurulum ekranı. Bunu görmüyorsan: USB tam takılı mı, BIOS boot sırası USB mi? Olmazsa merkeze telefon aç. + +**ADIM 2 — Ubuntu'yu kur ve depo kopyasını getir.** + +1. Ekrandaki kurulumu varsayılanlarla tamamla (dil: Türkçe, disk: tamamını kullan). +2. Kurulum bitince USB'yi çıkarma, bilgisayarı yeniden başlat. +3. Repo kopyasını cihaza getir (merkezin söylediği yolla: `git clone` — paketler Git LFS ile birlikte gelir — veya hazır kopya `/opt/blueforce/offline-repo` altına konur). + +> Paketler repoyla birlikte gelir; **internetten hiçbir şey indirilmez**. + +**ADIM 3 — Grafik arayüzü aç, bayi numarasını gir, kurulumu başlat.** + +1. Terminali aç, şunu yaz: + +```bash +sudo bf +``` + +2. Pencere açılır: başlık **Blueforce Field OS — Yönetim**. (Ekran yoksa veya tkinter yoksa uygulama Türkçe uyarı verir; o zaman `sudo bf --tui` yaz — §D.) +3. Üst çubuktaki **Hedef > Bayi no (8 hane)** alanına **8 haneli bayi numarasını** yaz. Faturadaki/sistemdeki numarayı kullan. Örnek: `12010193`. +4. **Kurulum** sekmesi → **`Bu cihazı kur`**. +5. Arayüz komutu ekranda gösterir ve onay ister. `BF-12010193` görünüyor mu? Doğruysa onayla; yanlışsa iptal et ve numarayı düzelt. + +```text +Arayüzden: sudo bf → Hedef: Bayi no (8 hane) → Kurulum → "Bu cihazı kur" +Komutla (yedek): + sudo bf --tui # terminal konsolu; Kurulum menüsünden aynı akış + sudo ./scripts/install/blueforce-install.sh --dealer-id 12010193 +``` + +6. Numarayı bayiye sesli onaylat: "Cihaz numaranız BF-12010193, doğru mu?" + +**ADIM 4 — Sonucu gör, çık.** + +1. Kurulum bitene kadar bekle (kapatma, fişi çekme). Cihazın açık kalması şart. +2. Sonucu gör: + +```text +Arayüzden: `Durum` düğmesi (basit mod) → Durum özeti ya da Kurulum sekmesi → "Kurulum modül durumu" +Komutla (yedek): bf-status +``` + +3. **`PROVISIONED_OFFLINE`** görmen normaldir: kurulum yerelde tamamlandı, cihaz internetsiz kuruldu. Bu durumda cihaz `READY` değildir. +4. İnternet geldiğinde merkezden tek-kullanımlık enrollment token iste ve 29'daki adımı uygula. +5. Token sonrası **ENROLLED** görünür; WireGuard, remote ve monitoring merkezi doğrulaması bitince **READY** görürsün. +6. READY sonrası kontrol listesini doldur (§12), sahadan çık. + +### B. Arızalı cihaza gittin — ilk 3 hareket + +1. Durumu oku: + +```text +Arayüzden: sudo bf → `Durum` düğmesi (Durum özeti) +Komutla (yedek): bf-status +``` + +Kırmızı satır hangisi? Not al. +2. Bilgisayarı bir kez yeniden başlat. Düzelirse READY'yi gör, çık. +3. Düzelmezse destek paketini al, merkeze gönder: + +```text +Arayüzden: sudo bf → Detaylı mod → Araçlar sekmesi → "Destek paketi oluştur" +Komutla (yedek): bf-support-bundle --out /tmp/bf--bundle.tar.gz +``` + +Paketi merkeze ilet (telefonla nasıl göndereceğin söylenir), merkez sana söylemeden başka komut çalıştırma. + +### C. Merkeze telefon açma kuralları + +Hemen ara: sonucu alamıyorsan, aynı arıza 2 cihazda üst üste çıktıysa, donanım kırık/yanık kokusu varsa, bayi numarasından emin değilsen. +Arama: cihaz başında ol, `BF-`'yu ve durum özetini/`bf-status` çıktısını hazır tut. + +### D. Grafik arayüz açılmıyor — yedek yol + +| Belirti | Ne yap | +|---|---| +| "Grafik ekran bulunamadı" / SSH oturumu | `sudo bf --tui` yaz; aynı işlemler terminal menüsünde numaralarla yürür | +| "tkinter bulunamadı" | `sudo bf --tui` ile devam et ve merkeze bildir; `python3-tk` paketi kurulum depounda pinlidir (19 §T-10) | +| Pencere açıldı ama dondu | Pencereyi kapat, `sudo bf --tui` ile devam et, merkeze bildir | + +## 10. Test Planı + +| Test | Beklenen sonuç | Ortam | +|---|---|---| +| Sıfır teknisyen 4 adımı desteksiz yapar | Hedef teslim durumu < hedef süre | LAB(2) | +| Yanlış bayi no girilir | Uygulama uyarır, komut üretilmez/çalışmaz | LAB(2) | +| Ekransız oturumda `sudo bf` | Türkçe yönlendirme + `sudo bf --tui` önerisi, exit 3 | LAB(2) | +| Depo kopyası olmayan cihazda kurulum | Türkçe hata + aranan yollar + çıkış nedeni, sistem değişmez | LAB(2) | +| Elektrik kesintisi simülasyonu sonrası durum | Kurulum kaldığı yerden tamamlanır; durum doğru raporlanır | LAB(2) | + +## 11. Rollback + +Teknisyen rollback yapmaz. Kurulum yarıda kesildiyse: `sudo bf` → Kurulum sekmesi → **`Başarısız kuruluma devam et (--resume)`** (komutla yedek: aynı kurucu `--resume` bayrağıyla). İkinci denemede de olmazsa merkeze telefon aç — merkez 17-RECOVERY'den seviyeyi seçer. + +## 12. Kontrol Listesi + +- [ ] Depo kopyası/checkout sürümü güncel (etiket + merkez duyurusu eşleşiyor). +- [ ] `BF-` bayiye onaylatıldı. +- [ ] Kurulum sonucu görüldü (`PROVISIONED_OFFLINE` normaldir; READY için §A/ADIM 4-5). +- [ ] Cihaz fişe + internete bağlı bırakıldı. +- [ ] İş formu dolduruldu (bayi no, tarih, teknisyen adı). + +## 13. Açık Sorular + +- [ ] Depo kopyasının sahaya taşınma/sürüm takip yöntemi (git clone + LFS mı, hazır kopya mı) (sahibi: merkezi admin, 21). +- [ ] İş formu kağıt mı mobil mi (sahibi: 25-ROADMAP). +- [ ] Hedef kurulum süresi (öneri: P2'de ölçülecek). + +--- + +## Ek: Mermaid — teknisyen akışı + +```mermaid +flowchart TB + USB["USB tak → aç"] --> KUR["Ubuntu kur"] + KUR --> DEPO["Depo kopyasını getir
    (paketler LFS ile gelir, indirme yok)"] + DEPO --> BF["sudo bf"] + BF --> EKRAN{"Grafik ekran +
    python3-tk var mı?"} + EKRAN -->|Hayır| TUI["sudo bf --tui
    (yedek terminal konsolu)"] + EKRAN -->|Evet| NO["Hedef: Bayi no (8 hane)"] + TUI --> NO + NO --> ONAY["Arayüz komutu gösterir + onay
    BF-no'yu bayiye onaylat"] + ONAY --> BEKLE["Kurulum bitsin
    (kapatma / fişi çekme)"] + BEKLE --> DURUM["Durum: PROVISIONED_OFFLINE / ENROLLED / READY"] + DURUM -->|"READY"| CIK["Formu doldur → çık"] + DURUM -->|"sorun var"| TEL["Destek paketi + merkeze telefon aç"] +``` +
    + + +# First boot — terminal wizard, kimlik koruması ve yarıda kalan kurulum + +Bu dizin, cihazın ilk açılışında sahada çalışan giriş noktasını barındırır: + +- `bf-firstboot` — bayi numarasını toplayan/doğrulayan ve yerel offline kurucuyu bir kez çalıştıran script. Kurucu paket deposunu kendisi bulur; internet gerekmez, teknisyen hiçbir şey indirmez. +- `blueforce-firstboot.service` — boot'ta script'i bir kez çalıştıran systemd unit'i. +- Bu doküman — gerçek davranış, otomasyon yolu, kimlik değişimi prosedürü ve secret kuralları. + +İlk boot, yalnız Subiquity'nin assisted storage onayıyla tamamlanan kurulumdan ve yeniden başlatmadan sonra çalışır. +Kimlik formatı `^[0-9]{8}$` (`docs/02-DEVICE-NAMING-AND-INVENTORY.md`) ve merkezi durumlar +(`PROVISIONED_OFFLINE` → `ENROLLED` → `READY`, `docs/29-DEVICE-ENROLLMENT.md`) bu adımın dışındadır: +firstboot yalnız offline provision'ı yapar, enrollment ve READY ayrı adımlardır. + +## 1. Wizard (teknisyen ekranı) + +Konsolda (TTY mevcut ve otomasyon istenmemiş) teknisyen şu ekranı görür: + +```text +# BLUEFORCE FIELD OS +8 Haneli Bayi Numarası: +> _ +``` + +Davranış: + +- Girdi `^[0-9]{8}$` ile doğrulanır. **Hatalı girdi kabul edilmez**; hata satırı ve `(deneme N/3)` ile yeniden sorulur. + Yalnız baştaki/sondaki boşluk kırpılır (yapıştırma artığı); değerin içinde boşluk veya rakam dışı karakter varsa geçersiz sayılır. +- 3 hatalı denemeden sonra script net bir hatayla `exit 2` verir ve hiçbir değişiklik yapmaz + (`BF_FIRSTBOOT_MAX_ATTEMPTS` ile deneme sayısı değiştirilebilir). +- Doğru girdide onay ekranı gösterilir; onay `e`/`evet`/`y`/`yes` ile alınır: + +```text +Device ID (cihaz kimliği): BF- +Hostname (makine adı) : bf- +Kurulum bu kimlikle başlatılsın mı? [e/H]: +``` + +- Onay verilmezse kurulum başlamaz; deneme hakkı tükenirse `exit 2`. +- `read` zaman aşımına uğrarsa (varsayılan 300 sn, `BF_FIRSTBOOT_INPUT_TIMEOUT`) script + "no terminal input arrived" hatasıyla `exit 2` verir; konsolda kimse olmadığında sonsuza kadar bloklamaz. + +TTY seçimi: stdin zaten bir terminal ise (systemd `StandardInput=tty-force` durumu) fd 0 kullanılır; +değilse `/dev/tty` açılır; o da açılamıyorsa wizard atlanır ve argüman/dosya yoluna düşülür. + +## 2. Kimlik çözümleme sırası + +| # | Kaynak | Mod | +|---|---|---| +| 1 | `--dealer-id <8hane>` | non-interactive (otomasyon) | +| 2 | `--dealer-id-file ` | non-interactive (otomasyon) | +| 3 | `--interactive` | wizard (TTY zorunlu) | +| 4 | `BF_FIRSTBOOT_NONINTERACTIVE=1` | non-interactive; seed dosyası (`/etc/blueforce/dealer-id`) okunur | +| 5 | Seed dosyası `/etc/blueforce/dealer-id` geçerliyse | non-interactive; wizard atlanır (log satırı yazılır) | +| 6 | TTY mevcut | wizard | +| 7 | Hiçbiri | net hata + `exit 2` (argüman/dosya önerilir) | + +Otomasyon yolu (1, 2, 4) hiçbir soru sormaz: seed/USB ile kurulan cihazda teknisyen girdisi gerekmez. +1. ve 2. maddeler açıkça verildiği için onay ekranı çıkmaz; bu bilinçli bir otomasyon kapısıdır. + +### Komut arayüzü + +```bash +sudo bf-firstboot # wizard (konsol) +sudo bf-firstboot --dealer-id <8hane> # otomasyon +sudo bf-firstboot --dealer-id-file /path/to/dealer-id +sudo bf-firstboot --check --dealer-id <8hane> # salt-okunur denetim +sudo bf-firstboot --interactive # seed dosyası olsa bile wizard +BF_FIRSTBOOT_NONINTERACTIVE=1 sudo bf-firstboot # boot/otomasyon +``` + +Ortam değişkenleri: `BF_FIRSTBOOT_MARKER` (marker yolu), `STATE_DIR` (varsayılan `/var/lib/blueforce`), +`BF_INSTALLER_PATH` (kurucu yolu), `BF_ADMIN_PUBLIC_KEY_FILE`, `BF_FIRSTBOOT_MAX_ATTEMPTS`, +`BF_FIRSTBOOT_INPUT_TIMEOUT`, `BF_FIRSTBOOT_TEST_MODE`, `BF_FIRSTBOOT_NONINTERACTIVE`. + +### Çıkış kodları + +| Kod | Anlam | +|---|---| +| 0 | Kurulum tamam (marker yazıldı) veya zaten tamamlanmış (idempotent) veya `--check` raporu | +| 1 | Offline önkoşul eksik (admin public key), root yok, kurucu yok, veya kurucu hata verdi | +| 2 | Girdi hatası: geçersiz bayi numarası, terminal yok, onay verilmedi, deneme tükendi | + +## 3. İdempotentlik ve yarıda kalan kurulum + +- Marker: `/var/lib/blueforce/firstboot-complete` (mode `600`, içinde bayi numarası). +- Marker varsa script **hiçbir şey yapmaz**: sormaz, yazmaz, kurucuyu çağırmaz, `exit 0`. Wizard bu durumda asla açılmaz. +- Marker varken farklı bir bayi numarası verilirse identity değişmez; yalnız uyarı yazılır ve admin prosedürüne + (§4) işaret edilir. Sıradan kullanıcı yoluyla kimlik değiştirilemez. +- Marker yalnız kurucu başarıyla bittikten sonra yazılır (`set -euo pipefail` sayesinde hata hâlinde yazılmaz). +- Önceki koşu yarıda kaldıysa (`$STATE_DIR/install-state` dolu) kurucu `--resume` ile çağrılır: + kurucu `--offline --dealer-id --yes` (+ gerekirse `--resume`) argümanlarıyla koşar. +- Script kurucuyu `--offline` ile çağırır. **Bu bayrak artık zorunlu değildir**: kurucu yerel + paket deposunu kendisi bulur (`BF_OFFLINE_REPO` → `/provisioning/offline-repo/built` → + `/opt/blueforce/offline-repo`). firstboot bayrağı geriye uyumluluk için verir; sonuç aynıdır: + yerel depodan, internet olmadan kurulum ve `PROVISIONED_OFFLINE` durumu. +- `--check` hiçbir dosya/dizin/log üretmez ve kurucuyu çağırmaz. + +### Depo bulunamazsa / doğrulanamazsa + +firstboot kurucuyu çağırır; paket deposuna ilişkin karar ve mesaj **kurucunun** sorumluluğundadır. +İki farklı durum vardır ve ikisinde de firstboot marker **yazmaz** (cihaz provision edilmiş sayılmaz): + +| Durum | Kim söyler | Ekranda ne görünür (kısaltılmış) | Sonuç | +|---|---|---|---| +| Yerel depo hiç yok (üç arama yerinde de yok) | Kurucu | `HATA: Yerel paket deposu yok. Repo sorumlusuna başvurun: provisioning/offline-repo/README.md` … `ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.` | `exit 1`; hiçbir şey değişmez | +| Depo bulundu ama doğrulanamadı (checksum, kilit, eksik pin) | Kurucu | `OFFLINE BLOCKED: ` … `ÇIKIŞ NEDENİ: yerel paket deposu doğrulanamadı…` | `exit 1`; hiçbir şey değişmez | +| Admin public key eksik/geçersiz | firstboot'un kendi kapısı | `OFFLINE BLOCKED: seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required.` (veya anahtar kuralı satırı) | `exit 1`; kurucu hiç çağrılmaz | + +Çözüm sahadaki teknisyenin işi değildir: mesajı repo sorumlusuna iletmek ve depo düzeltildikten +sonra kurulumu tekrar çalıştırmak yeterlidir. Kurulumu yapan kişi hiçbir şey indirmez; paketler +git deposunda taşınır. + +## 4. Kimlik değişimi prosedürü (admin / recovery — varsayılan KAPALI) + +Bayi numarası değişimi **sıradan kullanıcı yolu değildir**. Wizard ve normal koşu bu yolu içermez. +Yalnız root + açık operatör kapısı + yazılı onay + audit kaydı ile çalışır: + +1. Fiziksel konsolda root oturumu açılır ve operatör kapısı açıkça verilir: `BF_ALLOW_REIDENTITY=1`. +2. Komut: `BF_ALLOW_REIDENTITY=1 sudo -E bf-firstboot --force-reidentity --dealer-id `. +3. Ekranda eski/yeni kimlik ve "dokuz merkezi sistem güncellenmeli" uyarısı gösterilir. +4. Onay, **yeni bayi numarasının yeniden yazılması** ile alınır; eşleşmezse hiçbir değişiklik yapılmaz (`exit 2`). +5. Kurucu yeni kimlikle koşar, eski marker `firstboot-complete.reidentified-` olarak saklanır, + yeni marker yazılır. +6. Her adım `/var/log/blueforce-reidentity.log` (mode `600`, UTC zaman + `uid=`) ve journal'a yazılır: + kim, ne zaman, eski → yeni. Audit log yazılamıyorsa değişim **reddedilir** (fail-closed). + +Kapılar: root değilse `exit 1`; `BF_ALLOW_REIDENTITY=1` yoksa `exit 1`; TTY yoksa `exit 2`. +Değişimden sonra `docs/02-DEVICE-NAMING-AND-INVENTORY.md` §9'daki yeniden-numaralandırma adımları +(WireGuard peer, RustDesk/MeshCentral etiketi, Ansible inventory, monitoring etiketi) uygulanır. + +## 5. Secret kuralları + +- `BF_ADMIN_PUBLIC_KEY_FILE` **yalnız public SSH key** içerebilir; root sahipliğinde ve grup/dünya + tarafından yazılamaz olmalıdır (`root:600` veya `root:640`). Private key, inline anahtar veya + boş/okunamayan dosya `OFFLINE BLOCKED` ile reddedilir. +- Bu kural değiştirilmedi: dosya `ssh-ed25519`/`ssh-rsa`/`ssh-ecdsa`/`sk-ssh-ed25519` public key satırı + ile başlamalı ve `PRIVATE KEY` içermemelidir. +- Script secret **loglamaz**: mesajlarda yalnız dosya yolları ve cihaz kimlikleri geçer; anahtar içeriği, + token, parola hiçbir satıra yazılmaz (`--force-reidentity` audit kaydı da yalnız kimlikleri yazar). +- Wizard girdisi ekrana yankılanır (teknisyen yazar); bu ekran token/parola istemez, yalnız bayi numarası alır. +- Kurucu, yerel paket deposunu kendisi bulur (`BF_OFFLINE_REPO` → `/provisioning/offline-repo/built` + → `/opt/blueforce/offline-repo`); `--offline` bayrağı gerekmez. Internet kurulum için gerekmez. + Public-key önkoşulu geçtikten sonra: + * depo hiç yoksa kurucu Türkçe `HATA: Yerel paket deposu yok…` + `ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.` ile durur; + * depo bulunup da doğrulanamazsa (geçerli yerel APT indeksleri, checksum/lock ya da tüm somut + pinli paketler eksik/bozuk) kurucu `OFFLINE BLOCKED: ` ile durur. + Her iki durumda cihaz provision edilmiş sayılmaz ve firstboot marker yazmaz. Yalnız yerel depo + + public-key önkoşulları geçerse `PROVISIONED_OFFLINE` yazılır. Ayrıntı: §3 ve + `provisioning/offline-repo/README.md` §3. + +## 6. systemd unit ve boot'u kilitlememe + +`blueforce-firstboot.service`: + +- `ConditionPathExists=/etc/blueforce/admin-authorized-key.pub` — zorunlu seed önkoşulu yoksa unit hiç başlamaz. +- `ConditionPathExists=!/var/lib/blueforce/firstboot-complete` — provision edilmiş cihazda atlanır (başarısız sayılmaz). +- `StandardInput=tty-force` + `TTYPath=/dev/tty1` — wizard sahada konsolda görünür. +- TTY kurulamazsa (konsolsuz/headless sistem) systemd unit'i başarısız sayar, **boot devam eder**; + script de TTY yoksa argüman/dosya yoluna düşer. +- `TimeoutStartSec=45min` — konsolda kimse yoksa job sonsuza kadar açık kalmaz. +- `Type=oneshot`, `RemainAfterExit=yes`, `WantedBy=multi-user.target` — boot'ta bir kez çalışır. +- `NoNewPrivileges=yes`, `PrivateTmp=yes` korunur. `ProtectSystem=strict` ve `ProtectHome=yes` + **bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve kimlikten türeyen `/home/bf-` altına yazar. + +Not: unit çıktısı konsol TTY'sine gider; aynı satırlar `logger -t bf-firstboot` ile journal'a da yazılır, +kurucu kendi logunu `/var/log/blueforce-install.log` içinde tutar. + +## 7. Test + +```bash +bash tests/test-firstboot-static.sh +``` + +Statik test, gerçek systemd/apt işlemi yapmadan şunları doğrular: bayi numarası regex'i, hatalı girdinin +reddi, wizard ekranı ve onay akışı (pty ile), marker idempotentliği, kimlik değişiminin kapalı olması, +public-key önkoşulu ve secret sızıntısı olmadığı, `--check` ve `BF_FIRSTBOOT_TEST_MODE` modlarının +mutasyonsuzluğu. Root gerektiren senaryolar `unshare -r` ile ayrı bir kullanıcı ad alanında koşar; +bu izin yoksa ilgili testler `SKIP` olarak raporlanır. + + + +# Blueforce Field OS - offline APT pin manifest: base system packages +# +# Target release : Ubuntu 26.04 LTS, architecture amd64 (LAB confirms both) +# Format : one exact pin per line -> package=version ("#" starts a comment) +# Consumed by : scripts/install/blueforce-install.sh --offline, which refuses to +# run any package module unless every pin here is present in the +# bundle lock file /packages.lock.tsv. +# +# UNPINNED is a sentinel, not a version. Fill it in LAB: +# 1. acquire the reviewed .deb for the package into one local pool directory +# 2. read the exact version: dpkg-deb -f .deb Package Version +# 3. replace UNPINNED with that version, keeping the line shape: +# openssh-server= +# 4. verify with the static plan: ../build-offline-repo.sh --dry-run-plan +# (it must print PLAN READY before --check or a real build is attempted) +# +# Rules enforced by the tooling: lower-case package names, exactly one pin per +# package across all manifests, no inline comments, no whitespace around "=", +# no placeholder/replace/todo tokens. Adding or removing a package is a contract +# change: update manifests/requirements.tsv in the same edit or the plan fails. + +# --- base tooling (installer module 02-system.sh, BASE_PKGS) --- +curl=8.18.0-1ubuntu2.5 +wget=1.25.0-2ubuntu4.4 +ca-certificates=20260601~26.04.1 +gnupg=2.4.8-4ubuntu3.1 +lsb-release=12.1-2build1 +# Transitional dummy on apt >= 2.0 (https is built into apt). Kept for air-gapped +# parity with the reference build; the LAB may drop it after review. +apt-transport-https=3.2.0 + +# --- remote shell and encrypted transport (modules 07-ssh.sh, 06-wireguard.sh) --- +openssh-server=1:10.2p1-2ubuntu3.6 +wireguard-tools=1.0.20250521-1ubuntu1 + +# --- field diagnostics: SMART disk health evidence --- +# Consumed by scripts/diagnostics/bf-diagnostics and bf-hardware-inventory.sh. +smartmontools=7.5-2 + +# --- firewall baseline (module 13-firewall.sh) --- +ufw=0.36.2-9build1 +iptables=1.8.11-2ubuntu3 + +# --- operator GUI (bfos/ui_gui.py; module 18-final-check.sh) --- +# tkinter ships as its own package on Ubuntu, so a minimal image has Python but +# no GUI. Without it the operator application cannot open its window at all -- +# and the fleet has no network to fetch it from. +python3-tk=3.14.3-0ubuntu2 +# python3-tk's own dependency closure. Pinned explicitly because the installer +# resolves nothing from a remote source: whatever the pin does not name must +# already be on the image. +python3.14-tk=3.14.4-1ubuntu0.2 +libtk8.6=8.6.17-1build1 +libreadline8t64=8.3-4 +readline-common=8.3-4 +libffi8=3.5.2-4 +libsqlite3-0=3.46.1-9ubuntu0.2 +libbrotli1=1.2.0-3build1 +openssl=3.5.5-1ubuntu3.5 + +# --- monitoring (module 12-monitoring.sh) --- +# The textfile collector ships inside this package; no extra dependency is needed. +prometheus-node-exporter=1.10.2-1 + + + +# Manifest audit table - the contract behind manifests/*.txt +# +# Four TAB-separated columns, one row per required package: +# manifest package required-by why +# +# build-offline-repo.sh --dry-run-plan fails on drift in either direction: +# * a row whose package is not pinned in that manifest -> MISSING PACKAGES +# * a pin in a manifest with no row here -> PLAN ERROR (rogue pin) +# +# scripts/install/blueforce-install.sh never reads this file: it is the audit +# trail for humans and for the dry-run plan. Kernel modules and other artifacts +# that are not shipped as .deb files are out of scope. +base-packages.txt curl scripts/install/installer/modules/02-system.sh BASE_PKGS: HTTPS transfers for installer tooling +base-packages.txt wget scripts/install/installer/modules/02-system.sh BASE_PKGS: downloader expected by field runbooks +base-packages.txt ca-certificates scripts/install/installer/modules/02-system.sh BASE_PKGS: root store for TLS verification +base-packages.txt gnupg scripts/install/installer/modules/02-system.sh BASE_PKGS: signature handling for reviewed artifacts +base-packages.txt lsb-release scripts/install/installer/modules/02-system.sh BASE_PKGS: distribution identification +base-packages.txt apt-transport-https scripts/install/installer/modules/02-system.sh BASE_PKGS-adjacent: transitional dummy on apt >= 2.0, kept for parity +base-packages.txt openssh-server scripts/install/installer/modules/07-ssh.sh managed SSH access channel +base-packages.txt wireguard-tools scripts/install/installer/modules/06-wireguard.sh wg command for the WireGuard tunnel +base-packages.txt smartmontools scripts/diagnostics/bf-diagnostics SMART disk health evidence for field reports +base-packages.txt ufw scripts/install/installer/modules/13-firewall.sh host firewall baseline +base-packages.txt iptables scripts/install/installer/modules/13-firewall.sh rule backends used by the firewall module +base-packages.txt prometheus-node-exporter scripts/install/installer/modules/12-monitoring.sh node exporter including the textfile collector +base-packages.txt python3-tk scripts/install/installer/modules/18-final-check.sh tkinter is a separate package on Ubuntu; without it the operator GUI cannot start +base-packages.txt python3.14-tk scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt libtk8.6 scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt libreadline8t64 scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt readline-common scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt libffi8 scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt libsqlite3-0 scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt libbrotli1 scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +base-packages.txt openssl scripts/install/installer/modules/18-final-check.sh python3-tk dependency closure; the installer resolves nothing from a remote source +remote-access.txt rustdesk scripts/install/installer/modules/09-rustdesk.sh pinned vendor .deb; not in the Ubuntu archive +remote-access.txt libxdo3 scripts/install/installer/modules/09-rustdesk.sh rustdesk 1.4.9 Depends; pulled explicitly because the vendor .deb is installed with dpkg, not from an APT source +remote-access.txt libva-x11-2 scripts/install/installer/modules/09-rustdesk.sh rustdesk 1.4.9 Depends; pulled explicitly because the vendor .deb is installed with dpkg, not from an APT source +remote-access.txt xrdp scripts/install/installer/modules/08-rdp.sh RDP access channel +remote-access.txt xorgxrdp scripts/install/installer/modules/08-rdp.sh X11 backend recommended by xrdp for the GNOME session +remote-access.txt gnome-session scripts/install/installer/modules/08-rdp.sh session started by xRDP logins +remote-access.txt ubuntu-desktop-minimal scripts/install/installer/modules/16-gui.sh minimal desktop stack for the field device +docker.txt docker-ce scripts/install/installer/modules/10-docker.sh container engine +docker.txt docker-ce-cli scripts/install/installer/modules/10-docker.sh docker CLI used by modules and diagnostics +docker.txt containerd.io scripts/install/installer/modules/10-docker.sh runtime bundled with Docker CE +docker.txt docker-buildx-plugin scripts/install/installer/modules/10-docker.sh buildx plugin requested by the offline install path +docker.txt docker-compose-plugin scripts/install/installer/modules/10-docker.sh compose v2 plugin for the field stack + + + +#!/usr/bin/env bash +# Offline APT repository intake. +# +# Validate reviewed Debian packages and copy them into +# provisioning/offline-repo/packages/ so that they can be committed to git. The +# repository owner runs this on an Ubuntu machine; the device that later +# installs from the bundle never downloads anything. +# +# Guarantees: +# * nothing is downloaded and nothing is resolved from a remote source, +# * a file that is not a readable Debian binary package is refused, +# * a package for a foreign architecture is refused (the target is amd64, +# packages marked "all" are accepted), +# * a second version of a package that is already accepted is refused instead +# of silently overwritten: manifests/*.txt pin exactly one version, +# * manifests/*.txt is never edited here. For every pin that still reads the +# UNPINNED sentinel the exact replacement line is printed for the owner. +# +# --check performs the same validation without copying anything. +# +# User-facing output is Turkish; code and comments are English. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGES_DIR="$SCRIPT_DIR/packages" +TARGET_ARCH='amd64' +ALLOWED_ARCH_RE='^(amd64|all)$' +PACKAGE_NAME_RE='^[a-z0-9][a-z0-9+.-]*$' +PIN_RE='^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$' +UNPINNED='UNPINNED' + +usage() { + cat <<'EOF' +Kullanım: add-packages.sh [seçenekler] [ ...] + add-packages.sh --from DIZIN [--manifest DOSYA] + +İncelenmiş .deb dosyalarını provisioning/offline-repo/packages/ altına ekler. +Hiçbir indirme yapmaz: dosyaları siz verirsiniz, bu araç yalnızca doğrular. + +Seçenekler: + --from DIZIN DIZIN içindeki tüm *.deb dosyalarını aday olarak alır + --manifest DOSYA Yalnızca bu manifestteki pinlere uyan adayları ekler + (birden fazla --manifest verilebilir) + --include-all --manifest ile birlikte: pin dışı adayları da ekler + (bağımlılık paketleri için gerekir) + --packages DIZIN Hedef dizin (varsayılan: