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.
This commit is contained in:
emirhanmamak-blueforce 2026-09-17 17:06:58 +03:00
parent 8cb0c72065
commit 503f4df2ae
24 changed files with 541 additions and 114 deletions

View file

@ -165,37 +165,64 @@ bf_has_generated_password() {
# Writes stdin to <dest> atomically, forces mode 0600 on the file and 0700 on
# the parent directory, and verifies the result. Never echoes the body.
bf_write_secret_file() {
local dest="${1:-}" tmp=""
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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('') }}"

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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),

View file

@ -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,

View file

@ -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("<Configure>", 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()

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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[*]}"

View file

@ -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,

View file

@ -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 <<EOF
{

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
@ -76,15 +77,24 @@ def main() -> 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"(?<![\w-]){re.escape(old_user)}(?![\w-])")
sudo_lines = new_sudo.read_text(encoding="utf-8").splitlines(keepends=True)
new_sudo.write_text("".join(sudo_token.sub(new_user, line) for line in sudo_lines), 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")
# Replace only the full AllowUsers token, not substrings of other names.
ssh_token = re.compile(rf"(?<![\w-]){re.escape(old_user)}(?![\w-])")
out_lines = []
for line in new_ssh.read_text(encoding="utf-8").splitlines(keepends=True):
if line.lstrip().startswith("AllowUsers"):
line = ssh_token.sub(new_user, line)
out_lines.append(line)
new_ssh.write_text("".join(out_lines), encoding="utf-8")
run("sshd", "-t")
tmp = IDENTITY_FILE.with_suffix(".tmp")
@ -96,6 +106,7 @@ def main() -> 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

View file

@ -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 ==="