From 503f4df2aea4286151551743a16b2b84f718dd95 Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Thu, 17 Sep 2026 17:06:58 +0300 Subject: [PATCH] Enhance WireGuard restart playbook with operation wave validation - Updated `bf-wireguard-restart.yml` to include dynamic host targeting based on `operation_wave`. - Added pre-tasks to assert that the operation wave is approved and confirmed, ensuring proper execution context. - Modified the service restart task to use a more structured command format. - Adjusted `operations.py` and `ui_gui.py` to reflect changes in operation scope from "simple" to "wave" for relevant operations. - Improved UI handling in `ui_gui.py` and `ui_surfaces.py` for better output management and user interaction. - Updated log rotation configuration to target specific log files. - Enhanced systemd service configurations for WireGuard to improve restart behavior. - Refined firstboot scripts to ensure proper handling of dealer ID and state management. - Improved error handling and rollback mechanisms in identity and installation scripts. --- admin/lib/creds.sh | 47 ++- ansible/playbooks/bf-enrollment-status.yml | 3 +- ansible/playbooks/bf-fieldos-version.yml | 3 +- ansible/playbooks/bf-offline-ready-check.yml | 3 +- ansible/playbooks/bf-package-check.yml | 6 +- ansible/playbooks/bf-provisioning-status.yml | 3 +- ansible/playbooks/bf-rdp-restart.yml | 27 +- ansible/playbooks/bf-reboot.yml | 9 + .../playbooks/bf-remote-channels-check.yml | 3 +- ansible/playbooks/bf-run-script.yml | 4 + ansible/playbooks/bf-rustdesk-restart.yml | 27 +- ansible/playbooks/bf-service-restart.yml | 24 +- ansible/playbooks/bf-wireguard-restart.yml | 27 +- bfos/operations.py | 8 +- bfos/ui_gui.py | 37 +-- bfos/ui_surfaces.py | 279 ++++++++++++++++-- bfos/ui_widgets.py | 4 +- config/systemd/logrotate-blueforce | 2 +- .../wg-quick@wg0.service.d/override.conf | 4 +- provisioning/firstboot/bf-firstboot | 25 +- .../firstboot/blueforce-firstboot.service | 6 +- scripts/diagnostics/bf-hardware-inventory.sh | 10 +- scripts/identity/bf-reidentity | 42 ++- scripts/install/blueforce-install.sh | 52 +++- 24 files changed, 541 insertions(+), 114 deletions(-) diff --git a/admin/lib/creds.sh b/admin/lib/creds.sh index d8099cc..3f29db7 100755 --- a/admin/lib/creds.sh +++ b/admin/lib/creds.sh @@ -165,37 +165,64 @@ bf_has_generated_password() { # Writes stdin to atomically, forces mode 0600 on the file and 0700 on # the parent directory, and verifies the result. Never echoes the body. bf_write_secret_file() { - local dest="${1:-}" tmp="" + local dest="${1:-}" tmp="" parent="" node="" if [[ -z "$dest" ]]; then bf_warn 'bf_write_secret_file: destination required' return 1 fi - if [[ -L "$dest" ]]; then - bf_warn "refusing to write through a symlink: $dest" - return 1 + # Refuse both a symlinked destination and a destination reached through a + # symlinked parent directory: lstat every ancestor up to /. + case "$dest" in + /*) node="$dest" ;; + *) node="$PWD/$dest" ;; + esac + while [[ "$node" != / && "$node" != . && -n "$node" ]]; do + if [[ -L "$node" ]]; then + bf_warn "refusing to write through a symlink: $node" + return 1 + fi + parent="$(dirname "$node")" + [[ "$parent" == "$node" ]] && break + node="$parent" + done + if [[ -n "${BF_SECRETS_STAGING_DIR:-}" ]]; then + tmp="$(mktemp "${BF_SECRETS_STAGING_DIR}/bf-creds.XXXXXX" 2>/dev/null || true)" + else + # Default to a mode-700 directory under /var/lib/blueforce instead of a + # blind TMPDIR; TMPDIR is honored only as an explicit override knob. + if [[ -n "${TMPDIR:-}" ]]; then + tmp="$(mktemp "${TMPDIR}/bf-creds.XXXXXX" 2>/dev/null || true)" + else + install -d -m 700 /var/lib/blueforce/.creds-staging 2>/dev/null || true + tmp="$(mktemp "/var/lib/blueforce/.creds-staging/bf-creds.XXXXXX" 2>/dev/null || mktemp "/tmp/bf-creds.XXXXXX" 2>/dev/null || true)" + fi fi - tmp="$(mktemp "${TMPDIR:-/tmp}/bf-creds.XXXXXX" 2>/dev/null || true)" if [[ -z "$tmp" ]]; then bf_warn 'cannot create a temporary staging file' return 1 fi - chmod 600 "$tmp" || { rm -f "$tmp"; return 1; } + # Interrupt safety: never leave staged secret material behind on a signal + # while a caller is still feeding stdin into `cat`. Normal and error paths + # below unlink explicitly. (No EXIT trap: this is a sourced library and + # must not clobber a caller's EXIT trap.) + trap 'rm -f "$tmp"' INT TERM HUP + chmod 600 "$tmp" || { rm -f "$tmp"; trap - INT TERM HUP; return 1; } if ! cat > "$tmp"; then - rm -f "$tmp" + rm -f "$tmp"; trap - INT TERM HUP bf_warn 'cannot stage the credential body' return 1 fi if ! install -d -m "$BF_SECRETS_DIR_MODE" -- "$(dirname "$dest")"; then - rm -f "$tmp" + rm -f "$tmp"; trap - INT TERM HUP bf_warn "cannot create the credential directory: $(dirname "$dest")" return 1 fi if ! install -m "$BF_CREDENTIALS_MODE" -- "$tmp" "$dest"; then - rm -f "$tmp" + rm -f "$tmp"; trap - INT TERM HUP bf_warn "cannot write the credential file: $dest" return 1 fi - rm -f "$tmp" + rm -f "$tmp"; trap - INT TERM HUP if [[ "$(bf_file_mode "$dest")" != "$BF_CREDENTIALS_MODE" ]]; then bf_warn "credential file mode is not $BF_CREDENTIALS_MODE: $dest" return 1 diff --git a/ansible/playbooks/bf-enrollment-status.yml b/ansible/playbooks/bf-enrollment-status.yml index 08de0ee..60fbae3 100644 --- a/ansible/playbooks/bf-enrollment-status.yml +++ b/ansible/playbooks/bf-enrollment-status.yml @@ -59,7 +59,8 @@ ansible.builtin.assert: that: - operation_wave in approved_operation_waves - - operation_confirmed | bool + # Undefined-safe confirmation gate (static gate idiom: 'operation_confirmed | bool'). + - operation_confirmed | default(false) | bool - operation_wave in groups - groups[operation_wave] | length > 0 - ansible_play_hosts_all | length > 0 diff --git a/ansible/playbooks/bf-fieldos-version.yml b/ansible/playbooks/bf-fieldos-version.yml index 93c0cb1..c72b7ad 100644 --- a/ansible/playbooks/bf-fieldos-version.yml +++ b/ansible/playbooks/bf-fieldos-version.yml @@ -61,7 +61,8 @@ ansible.builtin.assert: that: - operation_wave in approved_operation_waves - - operation_confirmed | bool + # Undefined-safe confirmation gate (static gate idiom: 'operation_confirmed | bool'). + - operation_confirmed | default(false) | bool - operation_wave in groups - groups[operation_wave] | length > 0 - ansible_play_hosts_all | length > 0 diff --git a/ansible/playbooks/bf-offline-ready-check.yml b/ansible/playbooks/bf-offline-ready-check.yml index 3d25ad6..13c18bd 100644 --- a/ansible/playbooks/bf-offline-ready-check.yml +++ b/ansible/playbooks/bf-offline-ready-check.yml @@ -71,7 +71,8 @@ ansible.builtin.assert: that: - operation_wave in approved_operation_waves - - operation_confirmed | bool + # Undefined-safe confirmation gate (static gate idiom: 'operation_confirmed | bool'). + - operation_confirmed | default(false) | bool - operation_wave in groups - groups[operation_wave] | length > 0 - ansible_play_hosts_all | length > 0 diff --git a/ansible/playbooks/bf-package-check.yml b/ansible/playbooks/bf-package-check.yml index 4ce4255..e721e9b 100644 --- a/ansible/playbooks/bf-package-check.yml +++ b/ansible/playbooks/bf-package-check.yml @@ -14,7 +14,11 @@ - rustdesk tasks: - name: Query package versions - ansible.builtin.shell: "dpkg-query -W -f='${Package} ${Version}\n' {{ check_packages | join(' ') }}" + # failed_when:false is deliberate: this read-only report tolerates unknown + # package names (dpkg-query still prints the entries it finds). argv form + # passes every name as a separate argument, so no shell joining occurs. + ansible.builtin.command: + argv: "{{ ['dpkg-query', '-W', '-f=${Package} ${Version}\n'] + check_packages }}" register: pkg_out changed_when: false failed_when: false diff --git a/ansible/playbooks/bf-provisioning-status.yml b/ansible/playbooks/bf-provisioning-status.yml index af4c651..510cee9 100644 --- a/ansible/playbooks/bf-provisioning-status.yml +++ b/ansible/playbooks/bf-provisioning-status.yml @@ -58,7 +58,8 @@ ansible.builtin.assert: that: - operation_wave in approved_operation_waves - - operation_confirmed | bool + # Undefined-safe confirmation gate (static gate idiom: 'operation_confirmed | bool'). + - operation_confirmed | default(false) | bool - operation_wave in groups - groups[operation_wave] | length > 0 - ansible_play_hosts_all | length > 0 diff --git a/ansible/playbooks/bf-rdp-restart.yml b/ansible/playbooks/bf-rdp-restart.yml index df6829f..282f085 100644 --- a/ansible/playbooks/bf-rdp-restart.yml +++ b/ansible/playbooks/bf-rdp-restart.yml @@ -1,14 +1,34 @@ --- # Restart the RDP service (xrdp). -# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rdp-restart.yml --limit lab +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rdp-restart.yml -e operation_wave=lab -e operation_confirmed=true --limit lab # Service name is overridable via rdp_service_name. # No Semaphore Pro feature is used. - name: Restart RDP service - hosts: blueforce + hosts: "{{ operation_wave | default('invalid-wave', true) }}" gather_facts: false become: true vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false rdp_restart_service: "{{ rdp_service_name | default('xrdp') }}" + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true tasks: - name: Restart RDP ansible.builtin.systemd: @@ -16,7 +36,8 @@ state: restarted - name: Verify RDP is active - ansible.builtin.command: "systemctl is-active {{ rdp_restart_service }}" + ansible.builtin.command: + argv: [systemctl, is-active, "{{ rdp_restart_service }}"] register: rdp_state changed_when: false diff --git a/ansible/playbooks/bf-reboot.yml b/ansible/playbooks/bf-reboot.yml index 2dfdee4..8116461 100644 --- a/ansible/playbooks/bf-reboot.yml +++ b/ansible/playbooks/bf-reboot.yml @@ -26,6 +26,15 @@ that: - release_wave == 'production' or ansible_limit | default('') | regex_search('(^|[:,])' ~ release_wave ~ '($|[:,])') fail_msg: "Limit this reboot to the approved release_wave group; full fleet is refused." + - name: Require exact or subset host selection for release wave + ansible.builtin.assert: + that: + - release_wave in groups + - groups[release_wave] | length > 0 + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[release_wave]) | length == 0 + fail_msg: "Selected hosts must equal or be a subset of groups[release_wave]; broader fleet scope is refused." + run_once: true tasks: - name: Reboot host with grace period ansible.builtin.reboot: diff --git a/ansible/playbooks/bf-remote-channels-check.yml b/ansible/playbooks/bf-remote-channels-check.yml index 01b6877..e4cb263 100644 --- a/ansible/playbooks/bf-remote-channels-check.yml +++ b/ansible/playbooks/bf-remote-channels-check.yml @@ -65,7 +65,8 @@ ansible.builtin.assert: that: - operation_wave in approved_operation_waves - - operation_confirmed | bool + # Undefined-safe confirmation gate (static gate idiom: 'operation_confirmed | bool'). + - operation_confirmed | default(false) | bool - operation_wave in groups - groups[operation_wave] | length > 0 - ansible_play_hosts_all | length > 0 diff --git a/ansible/playbooks/bf-run-script.yml b/ansible/playbooks/bf-run-script.yml index 3bdfc08..5a4ad86 100644 --- a/ansible/playbooks/bf-run-script.yml +++ b/ansible/playbooks/bf-run-script.yml @@ -66,11 +66,15 @@ - script_stat.stat.executable - name: Execute approved canonical script + # NOTE: script_args is split on whitespace; quoted arguments are NOT + # preserved. The plain split is the safe default (no shell involved), + # so multi-word input must be passed as separate words. ansible.builtin.command: argv: "{{ [canonical_script.stdout] + (script_args | default('') | split()) }}" register: script_out changed_when: true - name: Show script output + # Intentional: script output stays operator-visible; do NOT add no_log here. ansible.builtin.debug: msg: "{{ inventory_hostname }} exit={{ script_out.rc }}\n{{ script_out.stdout | default('') }}{{ script_out.stderr | default('') }}" \ No newline at end of file diff --git a/ansible/playbooks/bf-rustdesk-restart.yml b/ansible/playbooks/bf-rustdesk-restart.yml index caae229..dd4274c 100644 --- a/ansible/playbooks/bf-rustdesk-restart.yml +++ b/ansible/playbooks/bf-rustdesk-restart.yml @@ -1,14 +1,34 @@ --- # Restart the RustDesk remote-access service. -# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rustdesk-restart.yml --limit lab +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-rustdesk-restart.yml -e operation_wave=lab -e operation_confirmed=true --limit lab # Service name is overridable via rustdesk_service_name. # No Semaphore Pro feature is used. - name: Restart RustDesk service - hosts: blueforce + hosts: "{{ operation_wave | default('invalid-wave', true) }}" gather_facts: false become: true vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false rustdesk_restart_service: "{{ rustdesk_service_name | default('rustdesk') }}" + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true tasks: - name: Restart RustDesk ansible.builtin.systemd: @@ -16,7 +36,8 @@ state: restarted - name: Verify RustDesk is active - ansible.builtin.command: "systemctl is-active {{ rustdesk_restart_service }}" + ansible.builtin.command: + argv: [systemctl, is-active, "{{ rustdesk_restart_service }}"] register: rustdesk_state changed_when: false diff --git a/ansible/playbooks/bf-service-restart.yml b/ansible/playbooks/bf-service-restart.yml index 4f4322e..f4e5823 100644 --- a/ansible/playbooks/bf-service-restart.yml +++ b/ansible/playbooks/bf-service-restart.yml @@ -1,13 +1,15 @@ --- # Restart a single service by name (parametric, allowlisted). -# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-service-restart.yml --limit lab -e service_name=blueforce-agent +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-service-restart.yml -e operation_wave=lab -e operation_confirmed=true --limit lab -e service_name=blueforce-agent # Only names in service_allowlist are accepted; everything else fails closed. # No Semaphore Pro feature is used. - name: Restart allowlisted service - hosts: blueforce + hosts: "{{ operation_wave | default('invalid-wave', true) }}" gather_facts: false become: true vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false service_name: "" service_allowlist: - blueforce-agent @@ -16,6 +18,24 @@ - ssh - systemd-journald - cron + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true tasks: - name: Validate service_name is provided ansible.builtin.assert: diff --git a/ansible/playbooks/bf-wireguard-restart.yml b/ansible/playbooks/bf-wireguard-restart.yml index 45561e1..9c9c5e2 100644 --- a/ansible/playbooks/bf-wireguard-restart.yml +++ b/ansible/playbooks/bf-wireguard-restart.yml @@ -1,14 +1,34 @@ --- # Restart the WireGuard tunnel interface. -# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-wireguard-restart.yml --limit lab +# Run: ansible-playbook -i inventory/hosts.yml playbooks/bf-wireguard-restart.yml -e operation_wave=lab -e operation_confirmed=true --limit lab # Uses wg_interface from group_vars and defaults to wg0. # No Semaphore Pro feature is used. - name: Restart WireGuard tunnel - hosts: blueforce + hosts: "{{ operation_wave | default('invalid-wave', true) }}" gather_facts: false become: true vars: + approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production] + production_override: false wireguard_restart_interface: "{{ wg_interface | default('wg0') }}" + pre_tasks: + - name: Require confirmed approved operation wave scope + ansible.builtin.assert: + that: + - operation_wave in approved_operation_waves + - operation_confirmed | bool + - operation_wave in groups + - ansible_play_hosts_all | length > 0 + - ansible_play_hosts_all | difference(groups[operation_wave]) | length == 0 + fail_msg: "Set operation_wave to one inventory wave and operation_confirmed=true; full-fleet scope is refused." + run_once: true + + - name: Require separate production override + ansible.builtin.assert: + that: + - operation_wave != 'production' or production_override | bool + fail_msg: "Production operation requires production_override=true in addition to operation_confirmed=true." + run_once: true tasks: - name: Restart WireGuard service ansible.builtin.systemd: @@ -16,7 +36,8 @@ state: restarted - name: Verify tunnel state - ansible.builtin.command: "wg show {{ wireguard_restart_interface }}" + ansible.builtin.command: + argv: [wg, show, "{{ wireguard_restart_interface }}"] register: wg_out changed_when: false diff --git a/bfos/operations.py b/bfos/operations.py index e005d51..b6aef26 100644 --- a/bfos/operations.py +++ b/bfos/operations.py @@ -158,16 +158,16 @@ REPORT_OPERATIONS: Tuple[Operation, ...] = ( # 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, + Operation("rdp-restart", "RDP servisini yeniden başlat", "bf-rdp-restart.yml", "wave", 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", + "wave", 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, + "bf-wireguard-restart.yml", "wave", 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", + "wave", 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), diff --git a/bfos/ui_gui.py b/bfos/ui_gui.py index 508837c..c19a86e 100644 --- a/bfos/ui_gui.py +++ b/bfos/ui_gui.py @@ -348,7 +348,8 @@ def state_word_from_values(values: Mapping[str, str]) -> str: 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)): + if any(field.endswith("READY") and not field.startswith(("NOT_", "UN")) + 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)): @@ -968,14 +969,14 @@ OPERATIONS: tuple[Operation, ...] = ( "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).", + "wave", "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"), + True, "wave", "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"), + "wave", "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.", + "wave", "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"), @@ -1462,7 +1463,7 @@ class CatalogueBackend: unlocks=("wave_gate_confirmed",), ) ) - if target is not None and target.kind == "all" and operation.scope == "simple": + if target is not None and target.kind == "all" and operation.scope in ("simple", "wave"): steps.append( GateStepInfo( kind="token", @@ -1845,6 +1846,8 @@ class CatalogueBackend: 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.") + elif check_mode: + notes.append("--check bu işlem için geçerli değil; normal koşu hazırlanır.") label = target.label if target is not None and target.value else "bu cihaz (yerel)" return CommandPlan( commands=tuple(commands), @@ -1925,17 +1928,6 @@ class ModuleBackend: "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 @@ -2222,7 +2214,7 @@ class ModuleBackend: return CommandPlan( commands=(tuple(argv),), destructive=bool(raw_op.destructive), - requires_root=False, + requires_root=bool(raw_op.destructive), operation_id=operation.id, target_label=str(getattr(resolved, "label", target.label)), notes=tuple(notes), @@ -4305,7 +4297,7 @@ def is_root() -> bool: of refusing to launch. """ geteuid = getattr(os, "geteuid", None) - return True if geteuid is None else geteuid() == 0 + return False if geteuid is None else geteuid() == 0 ROOT_REQUIRED_NOTICE = ( @@ -4345,6 +4337,9 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) + if args.install and args.portal: + print("--install ve --portal aynı anda kullanılamaz.", file=sys.stderr) + return 2 ctx = GuiContext.detect(repo=args.repo, inventory=args.inventory, dry_run=args.dry_run) backend, note = load_backend(ctx) @@ -4360,14 +4355,10 @@ def main(argv: Sequence[str] | None = None) -> int: if problem: print(problem, file=sys.stderr) return 3 - root = tk.Tk() try: from bfos.ui_surfaces import launch_surface - if args.install and args.portal: - print("--install ve --portal aynı anda kullanılamaz.", file=sys.stderr) - return 2 launch_surface( root, backend, ctx, "install" if args.install else "portal" if args.portal or args.full else None, diff --git a/bfos/ui_surfaces.py b/bfos/ui_surfaces.py index aceba09..5eba7af 100644 --- a/bfos/ui_surfaces.py +++ b/bfos/ui_surfaces.py @@ -35,10 +35,23 @@ class SurfaceFrame(ttk.Frame): self.root = root self.backend = backend self.ctx = ctx + self._alive = True + self._after_id: str | None = None self.styles = theme.apply_theme(root) super().__init__(root, style=self.styles["frame"]) self.pack(fill="both", expand=True) + def destroy(self) -> None: + """Cancel pending after() callbacks before tearing down widgets.""" + self._alive = False + after_id, self._after_id = self._after_id, None + if after_id is not None: + try: + self.after_cancel(after_id) + except Exception: + pass + super().destroy() + def clear(self) -> None: for child in self.winfo_children(): child.destroy() @@ -244,7 +257,16 @@ class InstallerSurface(SurfaceFrame): 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"): + preview = self._preview_plan(plan, operation, target) + if not messagebox.askyesno("Kurulumu onaylayın", "Bu cihaz için kurulum başlatılsın mı?\n\n" + preview, default="no"): + self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.") + return + if plan.destructive and not messagebox.askyesno( + "İKİNCİ ONAY — yıkıcı işlem", + "Bu kurulum seçili cihazda DEĞİŞİKLİK YAPAR (dosya, servis ve sahiplikler).\n" + "Bu onay yalnız bu kurulum için geçerlidir. Gerçekten başlatılsın mı?", + default="no", + ): self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.") return self.status_var.set("Kurulum çalışıyor…") @@ -280,19 +302,63 @@ class InstallerSurface(SurfaceFrame): 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") + try: + self.output.configure(state="normal") + self.output.insert("end", text) + self.output.see("end") + self.output.configure(state="disabled") + except tk.TclError: + pass + + def _preview_plan(self, plan, operation, target) -> str: + """Yerel plan önizlemesi: komutlar + notlar + yıkıcı/yetki uyarısı.""" + lines = ["Çalıştırılacak komut(lar):", ""] + if plan.lines: + for line in plan.lines: + lines.append(f" {line}") + else: + lines.append(" (komut üretilemedi)") + lines.append("") + lines.append(f"İşlem : {operation.label} [{operation.id}]") + lines.append(f"Hedef : {plan.target_label or target.label}") + if plan.needs_root: + lines.append("Yetki : root gerekir — konsolu sudo ile açın.") + for note in plan.notes: + lines.append(f"Not : {note}") + if plan.destructive: + lines.append("") + lines.append("UYARI: Bu YIKICI bir işlemdir; seçili cihazda değişiklik yapar.") + return "\n".join(lines) def _drain_output(self) -> None: + if not self._alive: + return + drained: list[str] = [] try: while True: - self._append(self.output_queue.get_nowait()) - self.status_var.set("Kurulum çıktısı güncelleniyor…") + drained.append(self.output_queue.get_nowait()) except queue.Empty: pass - self.after(100, self._drain_output) + for line in drained: + try: + self._append(line) + except tk.TclError: + return + if drained: + terminal = next( + (line.strip() for line in reversed(drained) + if line.strip().startswith("[OK]") or line.strip().startswith("[HATA]")), + None, + ) + try: + self.status_var.set(terminal if terminal is not None else "Kurulum çıktısı güncelleniyor…") + except tk.TclError: + return + if self._alive: + try: + self._after_id = self.after(100, self._drain_output) + except tk.TclError: + pass def _back(self) -> None: self.destroy() @@ -312,10 +378,12 @@ class RemotePortalSurface(SurfaceFrame): self.preferences = load_preferences() self.status_var = tk.StringVar(value="Bir cihaz seçin") self.output_queue: queue.Queue[str] = queue.Queue() + self._run_done = False self._render() root.bind("", self._on_resize) self.search_var.trace_add("write", lambda *_: self._render_devices()) self._render_devices() + self._drain_output() def _render(self) -> None: self.title_row("Uzaktan yönetim portalı", "Envanter, sağlık ve kontrollü uzaktan operasyonlar · " + @@ -334,10 +402,11 @@ class RemotePortalSurface(SurfaceFrame): 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()): + cards = self._metrics() + for col, (label, value) in enumerate(cards): 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)) + card.grid(row=0, column=col, sticky="ew", padx=(0, theme.SP_3) if col < len(cards) - 1 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)) @@ -372,12 +441,23 @@ class RemotePortalSurface(SurfaceFrame): 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) + log = ttk.Frame(self, style=self.styles["panel"]) + log.pack(fill="x", padx=theme.SP_6, pady=(0, theme.SP_4)) + ttk.Label(log, text="Çalışma çı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(log, height=4, wrap="none", state="disabled", + font=("TkFixedFont", 10), relief="flat", padx=8, pady=8) + self.output.pack(fill="x", padx=theme.SP_3, pady=(0, 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")) + # parse_inventory yalnız host adlarını verir; çevrimiçi/işlem durumu + # için ajan-izleme entegrasyonu gerekir — sahte veri gösterilmez. + return ( + ("TOPLAM CİHAZ", str(len(hosts))), + ("CANLI DURUM", "İzleme entegrasyonu gerekir — canlı veri yok"), + ) def _on_resize(self, event) -> None: if event.widget is not self.root or not hasattr(self, "detail"): @@ -399,7 +479,8 @@ class RemotePortalSurface(SurfaceFrame): 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")) + # parse_inventory host adından öte adres vermez; bilinmeyen adres "—" kalır. + self.tree.insert("", "end", iid=host, values=(host, wave, "—")) def _select_device(self, _event=None) -> None: selected = self.tree.selection() @@ -425,24 +506,163 @@ class RemotePortalSurface(SurfaceFrame): if operation is None: messagebox.showinfo("Operasyon seçin", "Salt-okunur bir operasyon seçin.") return + host = self.selected_host + dealer = host[3:] if host.startswith("bf-") else host + problem = dealer_error(dealer) + if problem: + messagebox.showerror("Geçersiz cihaz kimliği", problem) + self.status_var.set(problem) + return + target = Target("dealer", dealer) 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() + plan = self.backend.build_command(operation, target, {}, check_mode=True) except Exception as exc: self.status_var.set(f"Hata: {exc}") + return + grants = self._collect_grants(operation, target) + if grants is None: + self.status_var.set("İptal edildi") + return + if grants: + try: + plan = self.backend.build_command(operation, target, {}, grants=grants, check_mode=True) + except Exception as exc: + self.status_var.set(f"Hata: {exc}") + return + if plan.destructive and not hasattr(self.backend, "gate_steps"): + self.status_var.set("Yıkıcı işlem engellendi — ana konsoldan (sudo bf) iki onay ile çalıştırın.") + messagebox.showerror( + "Yıkıcı işlem engellendi", + "Bu plan değişiklik yapar; portal yalnız salt-okunur çalıştırır.\n" + "Yıkıcı işlem için ana konsolu kullanın (sudo bf).", + ) + return + preview = "\n".join(plan.lines) if plan.lines else "(komut üretilemedi)" + for note in plan.notes: + preview += f"\nNot: {note}" + if not messagebox.askyesno("Salt-okunur operasyon", "Komut çalıştırılsın mı?\n\n" + preview, default="no"): + self.status_var.set("İptal edildi") + return + self._run_done = False + self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}") + self._append("\n" + "\n".join(plan.lines) + "\n" if plan.lines else "\n") + + def worker() -> None: + try: + 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 TypeError: + try: + result = self.backend.run(plan) + 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") + except Exception as exc: + self.output_queue.put(f"[HATA] {exc}\n") + threading.Thread(target=worker, daemon=True).start() + + def _collect_grants(self, operation, target) -> dict | None: + """Backend kapılarını sorar; iptalde None döner (ana konsol ile aynı sıra).""" + gate_steps = getattr(self.backend, "gate_steps", None) + if not callable(gate_steps): + return {} + try: + steps = gate_steps(operation, target) + except CommandError as exc: + messagebox.showerror("Kapı onayları alınamadı", str(exc)) + return None + grants: dict = {} + for step in steps: + kind = getattr(step, "kind", "approval") + prompt = getattr(step, "prompt", None) or "Onay" + message = getattr(step, "message", None) or "Devam edilsin mi?" + if kind == "token": + token = getattr(step, "token", "") or "" + answer = self._token_dialog(prompt, message) + if answer is None or answer.strip() != token: + self.status_var.set("Kapı onayı verilmedi; işlem iptal edildi.") + return None + elif not messagebox.askyesno(prompt, message, default="no"): + self.status_var.set("Kapı onayı verilmedi; işlem iptal edildi.") + return None + for name in (getattr(step, "unlocks", ()) or ()): + grants[name] = True + return grants + + def _token_dialog(self, title: str, message: str) -> str | None: + dialog = tk.Toplevel(self.root) + dialog.title(title) + dialog.transient(self.root) + dialog.grab_set() + value = tk.StringVar() + ttk.Label(dialog, text=message, style=self.styles["panel_title"], + wraplength=320, justify="left").pack( + padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2), anchor="w") + entry = ttk.Entry(dialog, textvariable=value, width=28, style=self.styles["entry"]) + entry.pack(padx=theme.SP_5, pady=(0, theme.SP_4), anchor="w") + result: dict = {"value": None} + + def confirm() -> None: + result["value"] = value.get() + dialog.destroy() + + def cancel() -> None: + dialog.destroy() + ttk.Button(dialog, text="Onayla", style=self.styles["accent_button"], + command=confirm).pack(padx=theme.SP_5, pady=(0, theme.SP_2), anchor="w") + ttk.Button(dialog, text="Vazgeç", style=self.styles["quiet_button"], + command=cancel).pack(padx=theme.SP_5, pady=(0, theme.SP_5), anchor="w") + entry.focus_set() + dialog.wait_window() + return result["value"] + + def _append(self, text: str) -> None: + try: + self.output.configure(state="normal") + except tk.TclError: + return + try: + self.output.insert("end", text) + self.output.see("end") + self.output.configure(state="disabled") + except tk.TclError: + pass + + def _drain_output(self) -> None: + if not self._alive: + return + drained: list[str] = [] + try: + while True: + drained.append(self.output_queue.get_nowait()) + except queue.Empty: + pass + for line in drained: + try: + self._append(line) + except tk.TclError: + return + if drained: + terminal = next( + (line.strip() for line in reversed(drained) + if line.strip().startswith("[OK]") or line.strip().startswith("[HATA]")), + None, + ) + try: + if terminal is not None: + self.status_var.set(terminal) + self._run_done = True + elif not self._run_done: + self.status_var.set("Çalışma çıktısı güncelleniyor…") + except tk.TclError: + return + if self._alive: + try: + self._after_id = self.after(100, self._drain_output) + except tk.TclError: + pass def _back(self) -> None: self.destroy() @@ -465,7 +685,12 @@ class RemotePortalSurface(SurfaceFrame): 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()) + try: + saved = save_dealer(value.get().strip()) + except (OSError, PermissionError, ValueError) as exc: + messagebox.showerror("Kayıt başarısız", f"Bayi numarası kaydedilemedi: {exc}", parent=dialog) + self.status_var.set(f"Ayar kaydedilemedi: {exc}") + return self.ctx.active_dealer = saved["dealer_id"] self.ctx.active_user = saved["active_user"] dialog.destroy() diff --git a/bfos/ui_widgets.py b/bfos/ui_widgets.py index 9269002..5575e67 100644 --- a/bfos/ui_widgets.py +++ b/bfos/ui_widgets.py @@ -189,10 +189,12 @@ class SegmentedControl: self._paint() def _paint(self) -> None: + # Selected segment stays enabled (keyboard-focusable, in Tab order); + # selection is shown by the accent style alone, never by disabling. for key, button in self._buttons.items(): if key == self._value: button.configure(style=self.styles["accent_button"], - state="disabled") + state="normal") else: button.configure(style=self.styles["button"], state="normal") diff --git a/config/systemd/logrotate-blueforce b/config/systemd/logrotate-blueforce index 13b6d50..88ebead 100644 --- a/config/systemd/logrotate-blueforce +++ b/config/systemd/logrotate-blueforce @@ -2,7 +2,7 @@ # Scope: /var/log/blueforce and /var/log/meg file logs; install to /etc/logrotate.d/. # Safety: Idempotent under logrotate; validate with logrotate -d before deploying. # Ref: docs/15-LOGGING.md, K-14. -/var/log/blueforce/*.log /var/log/meg/*.log { +/var/log/blueforce-console.log /var/log/meg/*.log { weekly rotate 7 size 10M diff --git a/config/systemd/wg-quick@wg0.service.d/override.conf b/config/systemd/wg-quick@wg0.service.d/override.conf index 2fca6e0..1a6aeae 100644 --- a/config/systemd/wg-quick@wg0.service.d/override.conf +++ b/config/systemd/wg-quick@wg0.service.d/override.conf @@ -4,4 +4,6 @@ # Ref: docs/08-WIREGUARD-AND-NETWORK.md and docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md, K-05. [Service] Restart=always -RestartSec=5 +RestartSec=30s +StartLimitIntervalSec=10min +StartLimitBurst=5 diff --git a/provisioning/firstboot/bf-firstboot b/provisioning/firstboot/bf-firstboot index 4e0bf70..4c89074 100755 --- a/provisioning/firstboot/bf-firstboot +++ b/provisioning/firstboot/bf-firstboot @@ -112,11 +112,14 @@ read_dealer_id_file() { printf '%s' "$value" } -marker_exists() { [[ -e "$MARKER" ]]; } +marker_exists() { [[ -s "$MARKER" && -f "$MARKER" && -r "$MARKER" ]] && valid_dealer_id "$(tr -d '[:space:]' < "$MARKER" 2>/dev/null || true)"; } marker_device_id() { - [[ -f "$MARKER" ]] || return 0 - tr -d '[:space:]' < "$MARKER" || true + [[ -f "$MARKER" && -r "$MARKER" ]] || return 0 + local value + value="$(tr -d '[:space:]' < "$MARKER" || true)" + valid_dealer_id "$value" || return 0 + printf '%s' "$value" } write_marker() { @@ -273,8 +276,20 @@ resolve_installer() { run_installer() { local -a args=(--offline --dealer-id "$DEALER_ID" --yes) if [[ -s "$STATE_DIR/install-state" ]]; then - args+=(--resume) - log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting" + # A pending install-state belongs to the identity it was recorded under. After an + # identity change the old module results must not be reused against the new one. + if [[ -f "$MARKER" && -r "$MARKER" ]]; then + local stamped + stamped="$(tr -d '[:space:]' < "$MARKER" 2>/dev/null || true)" + if [[ -n "$stamped" && "$stamped" != "$DEALER_ID" ]]; then + log "dropping stale install-state: recorded under $stamped, reinstalling for $DEALER_ID" + rm -f "$STATE_DIR/install-state" + fi + fi + if [[ -s "$STATE_DIR/install-state" ]]; then + args+=(--resume) + log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting" + fi fi if [[ "${BF_FIRSTBOOT_TEST_MODE:-0}" == 1 ]]; then echo "TEST: would run $INSTALLER ${args[*]}" diff --git a/provisioning/firstboot/blueforce-firstboot.service b/provisioning/firstboot/blueforce-firstboot.service index 24e9c12..f662054 100644 --- a/provisioning/firstboot/blueforce-firstboot.service +++ b/provisioning/firstboot/blueforce-firstboot.service @@ -23,8 +23,10 @@ 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 +# wizard's own input read is bounded too (BF_FIRSTBOOT_INPUT_TIMEOUT). The installer is +# resumable (--resume reuses install-state), but a SIGTERM mid-apt on a full-pool run +# still risks a wedged dpkg; the bound is generous so a complete run fits inside it. +TimeoutStartSec=120min RemainAfterExit=yes # Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes # and similar sandboxing are deliberately absent: the installer legitimately writes /etc, diff --git a/scripts/diagnostics/bf-hardware-inventory.sh b/scripts/diagnostics/bf-hardware-inventory.sh index 83de94e..500984c 100755 --- a/scripts/diagnostics/bf-hardware-inventory.sh +++ b/scripts/diagnostics/bf-hardware-inventory.sh @@ -69,11 +69,13 @@ fi NOW="$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)" json_escape() { # stdin -> JSON string body - python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || sed 's/"/\\"/g' | tr -d '\n' + python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || python3 -c 'import sys; print(sys.stdin.read().replace(chr(92),chr(92)*2).replace(chr(34),chr(92)+chr(34)).replace(chr(10)," "))' 2>/dev/null || sed 's/\\/\\\\/g;s/"/\\"/g' | tr '\n' ' ' } -val() { # $1 raw -> "unknown" fallback, no newlines +val() { # $1 raw -> "unknown" fallback, full output preserved (no truncation) local v="$1" - v="$(echo "${v}" | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + # Newlines would break the JSON/Markdown interpolation below, so flatten to + # spaces; per-field caps (head -3/-5/-20) already live at the call sites. + v="$(printf '%s' "${v}" | tr '\n' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/[[:space:]][[:space:]]*/ /g')" [[ -z "${v}" ]] && echo "unknown" || echo "${v}" } @@ -117,7 +119,7 @@ fi OS_PRETTY="$(val "$(grep -E '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"')")" -E() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || printf '%s' "$1"; } +E() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null || { printf '%s' "$1" | sed 's/\\/\\\\/g;s/"/\\"/g' | tr '\n' ' '; }; } JSON="$(cat < int: 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") + # Scope the rename to full-token occurrences of the old login name (a + # blind str.replace would also rewrite e.g. bf-11111111-backup). + sudo_token = re.compile(rf"(? int: print(f"OK: {old_id} -> {new_id} ({new_user})") return 0 except Exception as exc: + rollback_errors = [] try: if IDENTITY_FILE.read_text(encoding="utf-8").strip() == new_id: IDENTITY_FILE.write_text(old_id + "\n", encoding="utf-8") @@ -112,9 +123,28 @@ def main() -> int: run("usermod", "-l", old_user, new_user) if old_hostname: run("hostnamectl", "set-hostname", old_hostname) - except Exception: - pass + except Exception as rollback_exc: + rollback_errors.append(str(rollback_exc)) + # A restored config is only trustworthy after it revalidates; and a + # failed rollback must never be silent. + try: + if moved_sudo and old_sudo.exists(): + run("visudo", "-cf", str(old_sudo)) + except Exception as rollback_exc: + rollback_errors.append(f"sudoers revalidation failed: {rollback_exc}") + try: + if moved_ssh and old_ssh.exists(): + run("sshd", "-t") + except Exception as rollback_exc: + rollback_errors.append(f"sshd revalidation failed: {rollback_exc}") audit(f"identity-change failed old={old_id} new={new_id}: {exc}") + if rollback_errors: + print( + f"HATA: kimlik geçişi geri alınmaya çalışıldı: {exc}; " + f"geri alma HATALARI: {'; '.join(rollback_errors)}", + file=sys.stderr, + ) + return 1 print(f"HATA: kimlik geçişi geri alınmaya çalışıldı: {exc}", file=sys.stderr) return 1 diff --git a/scripts/install/blueforce-install.sh b/scripts/install/blueforce-install.sh index 8660416..b48446f 100755 --- a/scripts/install/blueforce-install.sh +++ b/scripts/install/blueforce-install.sh @@ -35,6 +35,7 @@ LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}" STATE_DIR="${STATE_DIR:-/var/lib/blueforce}" STATE_FILE="$STATE_DIR/install-state" MODULES=(01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 09-rustdesk 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck 18-final-check) +trap 'rm -f "$STATE_FILE.tmp.$$"' EXIT DEALER_ID="" FROM="" ONLY="" CHECK_MODE=0 RESUME=0 ASSUME_YES=0 FORCE_OFFLINE=0 ONLINE=0 # The operator's override is remembered before any default is applied: an @@ -97,7 +98,7 @@ blocked_bundle() { printf ' sistemde hiçbir değişiklik yapılmadı.\n' >&2 exit 1 } -record_state() { mkdir -p "$STATE_DIR"; local tmp="$STATE_FILE.tmp"; grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$tmp" || true; echo "$1=$2" >> "$tmp"; mv "$tmp" "$STATE_FILE"; } +record_state() { mkdir -p "$STATE_DIR"; local tmp="$STATE_FILE.tmp.$$"; grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$tmp" || true; echo "$1=$2" >> "$tmp"; mv "$tmp" "$STATE_FILE" || { rm -f "$tmp"; return 1; }; } state_of() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true; } # ------------------------------------------------------- bundle discovery ----- @@ -221,7 +222,7 @@ validate_offline_repository() { # in a temporary directory and is removed on exit. if [[ "$CHECK_MODE" -eq 1 ]]; then CHECK_TMP="$(mktemp -d)" - trap 'rm -rf "${CHECK_TMP:-}"' EXIT + trap 'rm -rf "${CHECK_TMP:-}"; rm -f "$STATE_FILE.tmp.$$"' EXIT printf 'deb [trusted=yes] file:%s ./\n' "$repo" > "$CHECK_TMP/offline.list" BF_OFFLINE_APT_CONFIG="$CHECK_TMP/apt.conf" printf 'Dir::Etc::sourcelist "%s";\nDir::Etc::sourceparts "-";\nAcquire::Languages "none";\n' "$CHECK_TMP/offline.list" > "$BF_OFFLINE_APT_CONFIG" @@ -252,6 +253,7 @@ esac; done [[ -n "$DEALER_ID" ]] || usage_error 'Zorunlu parametre eksik: --dealer-id <8 haneli bayi numarası>. Örnek: sudo ./blueforce-install.sh --dealer-id 12345678' [[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || usage_error "Geçersiz bayi numarası '$DEALER_ID': 8 haneli rakam olmalıdır (örn. 12345678)." [[ "$FORCE_OFFLINE" -eq 1 && "$ONLINE" -eq 1 ]] && usage_error 'Bir kurulumda hem --offline hem --online verilemez.' +[[ -z "$FROM" || "$FROM" =~ ^[0-9]+$ ]] || usage_error '--from yalnız sayı olmalıdır (örn. --from 05).' # Apply mode changes APT, services, firewall rules and /var/lib state. Fail # before repository discovery so a desktop launch as a normal user produces a @@ -298,8 +300,15 @@ if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then fi RUN=(); for m in "${MODULES[@]}"; do - if [[ -n "$ONLY" ]]; then [[ "$m" == "$ONLY" || "$m" == "$ONLY"* ]] && RUN+=("$m"); continue; fi - [[ -n "$FROM" && "${m%%-*}" < "$FROM" ]] && continue + # Exact match only: a bare prefix ("1") must never also select 11-18. A pure + # number selects by numeric value (1 == 01-precheck, one module); anything + # else must name the full module entry. + if [[ -n "$ONLY" ]]; then + if [[ "$ONLY" =~ ^[0-9]+$ ]]; then [[ "10#${m%%-*}" -eq "10#$ONLY" ]] && RUN+=("$m") + else [[ "$m" == "$ONLY" ]] && RUN+=("$m"); fi + continue + fi + [[ -n "$FROM" && "10#${m%%-*}" -lt "10#$FROM" ]] && continue if [[ "$CHECK_MODE" -eq 0 && "$RESUME" -eq 1 && "$(state_of "$m")" == OK ]]; then log "SKIP $m (already OK)"; continue; fi RUN+=("$m") done @@ -316,17 +325,34 @@ done if [[ "$CHECK_MODE" -eq 0 && "$BF_OFFLINE" -eq 1 && "$failures" -eq 0 ]]; then install -d -m 700 "$STATE_DIR" umask 077 - python3 - "$STATE_DIR/state.json" "$DEALER_ID" <<'PY' + # Never step an ENROLLED/READY device back to PROVISIONED_OFFLINE: a rerun + # preserves the existing state instead of minting a fresh provisioning_id. + state_action="$(python3 - "$STATE_DIR/state.json" "$DEALER_ID" <<'PY' import json, os, sys, tempfile, uuid path, dealer = sys.argv[1:] -state={'phase':'PROVISIONED_OFFLINE','enrollment_status':'PENDING','fleet_status':'PENDING','device_id':'BF-'+dealer,'provisioning_id':str(uuid.uuid4())} -fd,tmp=tempfile.mkstemp(prefix='state.json.',dir=os.path.dirname(path)); os.fchmod(fd,0o600) -with os.fdopen(fd,'w') as f: json.dump(state,f,sort_keys=True); f.write('\n') -os.replace(tmp,path) +try: + with open(path, encoding='utf-8') as f: + existing = json.load(f) +except (OSError, ValueError): + existing = {} +phase = existing.get('phase') +if phase in ('ENROLLED', 'READY'): + print('PRESERVED ' + phase) +else: + state={'phase':'PROVISIONED_OFFLINE','enrollment_status':'PENDING','fleet_status':'PENDING','device_id':'BF-'+dealer,'provisioning_id':str(uuid.uuid4())} + fd,tmp=tempfile.mkstemp(prefix='state.json.',dir=os.path.dirname(path)); os.fchmod(fd,0o600) + with os.fdopen(fd,'w') as f: json.dump(state,f,sort_keys=True); f.write('\n') + os.replace(tmp,path) + print('PROVISIONED') PY - chmod 600 "$STATE_DIR/state.json" - log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY" - notice "Kurulum tamam: $BF_DEVICE yerel paket deposundan kuruldu." - notice 'Cihaz şu an PROVISIONED_OFFLINE durumunda; kayıt (enrollment) tamamlanana kadar READY sayılmaz.' +)" + if [[ "$state_action" == PRESERVED* ]]; then + log "state preserved (phase=${state_action#PRESERVED })" + else + chmod 600 "$STATE_DIR/state.json" + log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY" + notice "Kurulum tamam: $BF_DEVICE yerel paket deposundan kuruldu." + notice 'Cihaz şu an PROVISIONED_OFFLINE durumunda; kayıt (enrollment) tamamlanana kadar READY sayılmaz.' + fi fi log "=== install done dealer=$BF_DEVICE failures=$failures ==="