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:
parent
8cb0c72065
commit
503f4df2ae
24 changed files with 541 additions and 114 deletions
|
|
@ -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"
|
||||
# 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
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/bf-creds.XXXXXX" 2>/dev/null || true)"
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('') }}"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -272,10 +275,22 @@ resolve_installer() {
|
|||
# explicitly requested automation mode. A previous partial run is resumed.
|
||||
run_installer() {
|
||||
local -a args=(--offline --dealer-id "$DEALER_ID" --yes)
|
||||
if [[ -s "$STATE_DIR/install-state" ]]; then
|
||||
# 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[*]}"
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)"
|
||||
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 ==="
|
||||
|
|
|
|||
Loading…
Reference in a new issue