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
|
# 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.
|
# the parent directory, and verifies the result. Never echoes the body.
|
||||||
bf_write_secret_file() {
|
bf_write_secret_file() {
|
||||||
local dest="${1:-}" tmp=""
|
local dest="${1:-}" tmp="" parent="" node=""
|
||||||
if [[ -z "$dest" ]]; then
|
if [[ -z "$dest" ]]; then
|
||||||
bf_warn 'bf_write_secret_file: destination required'
|
bf_warn 'bf_write_secret_file: destination required'
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ -L "$dest" ]]; then
|
# Refuse both a symlinked destination and a destination reached through a
|
||||||
bf_warn "refusing to write through a symlink: $dest"
|
# 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
|
return 1
|
||||||
fi
|
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
|
if [[ -z "$tmp" ]]; then
|
||||||
bf_warn 'cannot create a temporary staging file'
|
bf_warn 'cannot create a temporary staging file'
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
if ! cat > "$tmp"; then
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"; trap - INT TERM HUP
|
||||||
bf_warn 'cannot stage the credential body'
|
bf_warn 'cannot stage the credential body'
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if ! install -d -m "$BF_SECRETS_DIR_MODE" -- "$(dirname "$dest")"; then
|
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")"
|
bf_warn "cannot create the credential directory: $(dirname "$dest")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if ! install -m "$BF_CREDENTIALS_MODE" -- "$tmp" "$dest"; then
|
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"
|
bf_warn "cannot write the credential file: $dest"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"; trap - INT TERM HUP
|
||||||
if [[ "$(bf_file_mode "$dest")" != "$BF_CREDENTIALS_MODE" ]]; then
|
if [[ "$(bf_file_mode "$dest")" != "$BF_CREDENTIALS_MODE" ]]; then
|
||||||
bf_warn "credential file mode is not $BF_CREDENTIALS_MODE: $dest"
|
bf_warn "credential file mode is not $BF_CREDENTIALS_MODE: $dest"
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- operation_wave in approved_operation_waves
|
- 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
|
- operation_wave in groups
|
||||||
- groups[operation_wave] | length > 0
|
- groups[operation_wave] | length > 0
|
||||||
- ansible_play_hosts_all | length > 0
|
- ansible_play_hosts_all | length > 0
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,8 @@
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- operation_wave in approved_operation_waves
|
- 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
|
- operation_wave in groups
|
||||||
- groups[operation_wave] | length > 0
|
- groups[operation_wave] | length > 0
|
||||||
- ansible_play_hosts_all | length > 0
|
- ansible_play_hosts_all | length > 0
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- operation_wave in approved_operation_waves
|
- 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
|
- operation_wave in groups
|
||||||
- groups[operation_wave] | length > 0
|
- groups[operation_wave] | length > 0
|
||||||
- ansible_play_hosts_all | length > 0
|
- ansible_play_hosts_all | length > 0
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@
|
||||||
- rustdesk
|
- rustdesk
|
||||||
tasks:
|
tasks:
|
||||||
- name: Query package versions
|
- 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
|
register: pkg_out
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- operation_wave in approved_operation_waves
|
- 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
|
- operation_wave in groups
|
||||||
- groups[operation_wave] | length > 0
|
- groups[operation_wave] | length > 0
|
||||||
- ansible_play_hosts_all | length > 0
|
- ansible_play_hosts_all | length > 0
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
---
|
---
|
||||||
# Restart the RDP service (xrdp).
|
# 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.
|
# Service name is overridable via rdp_service_name.
|
||||||
# No Semaphore Pro feature is used.
|
# No Semaphore Pro feature is used.
|
||||||
- name: Restart RDP service
|
- name: Restart RDP service
|
||||||
hosts: blueforce
|
hosts: "{{ operation_wave | default('invalid-wave', true) }}"
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
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') }}"
|
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:
|
tasks:
|
||||||
- name: Restart RDP
|
- name: Restart RDP
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
|
|
@ -16,7 +36,8 @@
|
||||||
state: restarted
|
state: restarted
|
||||||
|
|
||||||
- name: Verify RDP is active
|
- 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
|
register: rdp_state
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,15 @@
|
||||||
that:
|
that:
|
||||||
- release_wave == 'production' or ansible_limit | default('') | regex_search('(^|[:,])' ~ release_wave ~ '($|[:,])')
|
- 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."
|
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:
|
tasks:
|
||||||
- name: Reboot host with grace period
|
- name: Reboot host with grace period
|
||||||
ansible.builtin.reboot:
|
ansible.builtin.reboot:
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,8 @@
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
- operation_wave in approved_operation_waves
|
- 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
|
- operation_wave in groups
|
||||||
- groups[operation_wave] | length > 0
|
- groups[operation_wave] | length > 0
|
||||||
- ansible_play_hosts_all | length > 0
|
- ansible_play_hosts_all | length > 0
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,15 @@
|
||||||
- script_stat.stat.executable
|
- script_stat.stat.executable
|
||||||
|
|
||||||
- name: Execute approved canonical script
|
- 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:
|
ansible.builtin.command:
|
||||||
argv: "{{ [canonical_script.stdout] + (script_args | default('') | split()) }}"
|
argv: "{{ [canonical_script.stdout] + (script_args | default('') | split()) }}"
|
||||||
register: script_out
|
register: script_out
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|
||||||
- name: Show script output
|
- name: Show script output
|
||||||
|
# Intentional: script output stays operator-visible; do NOT add no_log here.
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: "{{ inventory_hostname }} exit={{ script_out.rc }}\n{{ script_out.stdout | default('') }}{{ script_out.stderr | default('') }}"
|
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.
|
# 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.
|
# Service name is overridable via rustdesk_service_name.
|
||||||
# No Semaphore Pro feature is used.
|
# No Semaphore Pro feature is used.
|
||||||
- name: Restart RustDesk service
|
- name: Restart RustDesk service
|
||||||
hosts: blueforce
|
hosts: "{{ operation_wave | default('invalid-wave', true) }}"
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
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') }}"
|
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:
|
tasks:
|
||||||
- name: Restart RustDesk
|
- name: Restart RustDesk
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
|
|
@ -16,7 +36,8 @@
|
||||||
state: restarted
|
state: restarted
|
||||||
|
|
||||||
- name: Verify RustDesk is active
|
- 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
|
register: rustdesk_state
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
---
|
---
|
||||||
# Restart a single service by name (parametric, allowlisted).
|
# 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.
|
# Only names in service_allowlist are accepted; everything else fails closed.
|
||||||
# No Semaphore Pro feature is used.
|
# No Semaphore Pro feature is used.
|
||||||
- name: Restart allowlisted service
|
- name: Restart allowlisted service
|
||||||
hosts: blueforce
|
hosts: "{{ operation_wave | default('invalid-wave', true) }}"
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
vars:
|
||||||
|
approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production]
|
||||||
|
production_override: false
|
||||||
service_name: ""
|
service_name: ""
|
||||||
service_allowlist:
|
service_allowlist:
|
||||||
- blueforce-agent
|
- blueforce-agent
|
||||||
|
|
@ -16,6 +18,24 @@
|
||||||
- ssh
|
- ssh
|
||||||
- systemd-journald
|
- systemd-journald
|
||||||
- cron
|
- 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:
|
tasks:
|
||||||
- name: Validate service_name is provided
|
- name: Validate service_name is provided
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
---
|
---
|
||||||
# Restart the WireGuard tunnel interface.
|
# 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.
|
# Uses wg_interface from group_vars and defaults to wg0.
|
||||||
# No Semaphore Pro feature is used.
|
# No Semaphore Pro feature is used.
|
||||||
- name: Restart WireGuard tunnel
|
- name: Restart WireGuard tunnel
|
||||||
hosts: blueforce
|
hosts: "{{ operation_wave | default('invalid-wave', true) }}"
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
vars:
|
||||||
|
approved_operation_waves: [lab, pilot_1, pilot_2, wave_1, wave_2, production]
|
||||||
|
production_override: false
|
||||||
wireguard_restart_interface: "{{ wg_interface | default('wg0') }}"
|
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:
|
tasks:
|
||||||
- name: Restart WireGuard service
|
- name: Restart WireGuard service
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
|
|
@ -16,7 +36,8 @@
|
||||||
state: restarted
|
state: restarted
|
||||||
|
|
||||||
- name: Verify tunnel state
|
- name: Verify tunnel state
|
||||||
ansible.builtin.command: "wg show {{ wireguard_restart_interface }}"
|
ansible.builtin.command:
|
||||||
|
argv: [wg, show, "{{ wireguard_restart_interface }}"]
|
||||||
register: wg_out
|
register: wg_out
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,16 +158,16 @@ REPORT_OPERATIONS: Tuple[Operation, ...] = (
|
||||||
|
|
||||||
# 2.2 Fleet maintenance -- destructive rows ask a second, explicit question.
|
# 2.2 Fleet maintenance -- destructive rows ask a second, explicit question.
|
||||||
MAINTENANCE_OPERATIONS: Tuple[Operation, ...] = (
|
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),
|
PROMPT_NONE, "xrdp servisini yeniden başlatır", CATEGORY_MAINTENANCE),
|
||||||
Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "bf-rustdesk-restart.yml",
|
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),
|
CATEGORY_MAINTENANCE),
|
||||||
Operation("wireguard-restart", "WireGuard servisini yeniden başlat",
|
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),
|
"wg-quick@wg0 servisini yeniden başlatır", CATEGORY_MAINTENANCE),
|
||||||
Operation("service-restart", "İzinli bir servisi yeniden başlat", "bf-service-restart.yml",
|
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),
|
CATEGORY_MAINTENANCE),
|
||||||
Operation("gui-off", "Grafik arayüzü kapat (headless)", "bf-gui-off.yml", "wave", True,
|
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),
|
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
|
if any(marker in field for marker in FAILURE_MARKERS
|
||||||
for field in (phase, fleet, enrollment)):
|
for field in (phase, fleet, enrollment)):
|
||||||
return "HATA"
|
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"
|
return "HAZIR"
|
||||||
if any(field.endswith("ENROLLED") and not field.startswith(("NOT_", "UN"))
|
if any(field.endswith("ENROLLED") and not field.startswith(("NOT_", "UN"))
|
||||||
for field in (phase, fleet, enrollment)):
|
for field in (phase, fleet, enrollment)):
|
||||||
|
|
@ -968,14 +969,14 @@ OPERATIONS: tuple[Operation, ...] = (
|
||||||
"Offline provisioning yerel ön koşulları.", "bf-offline-ready-check.yml"),
|
"Offline provisioning yerel ön koşulları.", "bf-offline-ready-check.yml"),
|
||||||
# --- Cihaz islemleri: bakim (yikici olanlar isaretli) -------------------
|
# --- Cihaz islemleri: bakim (yikici olanlar isaretli) -------------------
|
||||||
Operation("rdp-restart", "xRDP servisini yeniden başlat", "Cihaz işlemleri", True,
|
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"),
|
"bf-rdp-restart.yml"),
|
||||||
Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "Cihaz işlemleri",
|
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,
|
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,
|
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"]),
|
"bf-service-restart.yml", options=_FLEET_OPTIONS["service-restart"]),
|
||||||
Operation("gui-off", "Yerel grafik oturumu kapat (headless)", "Cihaz işlemleri", True,
|
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"),
|
"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",),
|
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(
|
steps.append(
|
||||||
GateStepInfo(
|
GateStepInfo(
|
||||||
kind="token",
|
kind="token",
|
||||||
|
|
@ -1845,6 +1846,8 @@ class CatalogueBackend:
|
||||||
if check_mode and op_id in CHECK_CAPABLE_LOCAL_OPS:
|
if check_mode and op_id in CHECK_CAPABLE_LOCAL_OPS:
|
||||||
commands = [tuple(argv) + ("--check",) for argv in commands]
|
commands = [tuple(argv) + ("--check",) for argv in commands]
|
||||||
notes.append("--check: yalnız denetim yapılır, değişiklik uygulanmaz.")
|
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)"
|
label = target.label if target is not None and target.value else "bu cihaz (yerel)"
|
||||||
return CommandPlan(
|
return CommandPlan(
|
||||||
commands=tuple(commands),
|
commands=tuple(commands),
|
||||||
|
|
@ -1925,17 +1928,6 @@ class ModuleBackend:
|
||||||
"tools-support-bundle": ("bf-support-bundle", ("--out-dir", "/var/tmp")),
|
"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):
|
def __init__(self, ops_module, runner_module, ctx: GuiContext):
|
||||||
self._ops = ops_module
|
self._ops = ops_module
|
||||||
self._runner = runner_module
|
self._runner = runner_module
|
||||||
|
|
@ -2222,7 +2214,7 @@ class ModuleBackend:
|
||||||
return CommandPlan(
|
return CommandPlan(
|
||||||
commands=(tuple(argv),),
|
commands=(tuple(argv),),
|
||||||
destructive=bool(raw_op.destructive),
|
destructive=bool(raw_op.destructive),
|
||||||
requires_root=False,
|
requires_root=bool(raw_op.destructive),
|
||||||
operation_id=operation.id,
|
operation_id=operation.id,
|
||||||
target_label=str(getattr(resolved, "label", target.label)),
|
target_label=str(getattr(resolved, "label", target.label)),
|
||||||
notes=tuple(notes),
|
notes=tuple(notes),
|
||||||
|
|
@ -4305,7 +4297,7 @@ def is_root() -> bool:
|
||||||
of refusing to launch.
|
of refusing to launch.
|
||||||
"""
|
"""
|
||||||
geteuid = getattr(os, "geteuid", None)
|
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 = (
|
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:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
args = parse_args(argv)
|
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)
|
ctx = GuiContext.detect(repo=args.repo, inventory=args.inventory, dry_run=args.dry_run)
|
||||||
backend, note = load_backend(ctx)
|
backend, note = load_backend(ctx)
|
||||||
|
|
||||||
|
|
@ -4360,14 +4355,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||||
if problem:
|
if problem:
|
||||||
print(problem, file=sys.stderr)
|
print(problem, file=sys.stderr)
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
try:
|
try:
|
||||||
from bfos.ui_surfaces import launch_surface
|
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(
|
launch_surface(
|
||||||
root, backend, ctx,
|
root, backend, ctx,
|
||||||
"install" if args.install else "portal" if args.portal or args.full else None,
|
"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.root = root
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
|
self._alive = True
|
||||||
|
self._after_id: str | None = None
|
||||||
self.styles = theme.apply_theme(root)
|
self.styles = theme.apply_theme(root)
|
||||||
super().__init__(root, style=self.styles["frame"])
|
super().__init__(root, style=self.styles["frame"])
|
||||||
self.pack(fill="both", expand=True)
|
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:
|
def clear(self) -> None:
|
||||||
for child in self.winfo_children():
|
for child in self.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
|
|
@ -244,7 +257,16 @@ class InstallerSurface(SurfaceFrame):
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.status_var.set(f"Plan üretilemedi: {exc}")
|
self.status_var.set(f"Plan üretilemedi: {exc}")
|
||||||
return
|
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ı.")
|
self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.")
|
||||||
return
|
return
|
||||||
self.status_var.set("Kurulum çalışıyor…")
|
self.status_var.set("Kurulum çalışıyor…")
|
||||||
|
|
@ -280,19 +302,63 @@ class InstallerSurface(SurfaceFrame):
|
||||||
self.dealer_entry.focus_set()
|
self.dealer_entry.focus_set()
|
||||||
|
|
||||||
def _append(self, text: str) -> None:
|
def _append(self, text: str) -> None:
|
||||||
|
try:
|
||||||
self.output.configure(state="normal")
|
self.output.configure(state="normal")
|
||||||
self.output.insert("end", text)
|
self.output.insert("end", text)
|
||||||
self.output.see("end")
|
self.output.see("end")
|
||||||
self.output.configure(state="disabled")
|
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:
|
def _drain_output(self) -> None:
|
||||||
|
if not self._alive:
|
||||||
|
return
|
||||||
|
drained: list[str] = []
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
self._append(self.output_queue.get_nowait())
|
drained.append(self.output_queue.get_nowait())
|
||||||
self.status_var.set("Kurulum çıktısı güncelleniyor…")
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
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:
|
def _back(self) -> None:
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
@ -312,10 +378,12 @@ class RemotePortalSurface(SurfaceFrame):
|
||||||
self.preferences = load_preferences()
|
self.preferences = load_preferences()
|
||||||
self.status_var = tk.StringVar(value="Bir cihaz seçin")
|
self.status_var = tk.StringVar(value="Bir cihaz seçin")
|
||||||
self.output_queue: queue.Queue[str] = queue.Queue()
|
self.output_queue: queue.Queue[str] = queue.Queue()
|
||||||
|
self._run_done = False
|
||||||
self._render()
|
self._render()
|
||||||
root.bind("<Configure>", self._on_resize)
|
root.bind("<Configure>", self._on_resize)
|
||||||
self.search_var.trace_add("write", lambda *_: self._render_devices())
|
self.search_var.trace_add("write", lambda *_: self._render_devices())
|
||||||
self._render_devices()
|
self._render_devices()
|
||||||
|
self._drain_output()
|
||||||
|
|
||||||
def _render(self) -> None:
|
def _render(self) -> None:
|
||||||
self.title_row("Uzaktan yönetim portalı", "Envanter, sağlık ve kontrollü uzaktan operasyonlar · " +
|
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 = ttk.Frame(body, style=self.styles["frame"])
|
||||||
metrics.grid(row=0, column=1, sticky="ew", pady=(0, theme.SP_4))
|
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)
|
metrics.columnconfigure(col, weight=1)
|
||||||
card = ttk.Frame(metrics, style=self.styles["panel"])
|
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=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))
|
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 = 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)
|
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)
|
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], ...]:
|
def _metrics(self) -> tuple[tuple[str, str], ...]:
|
||||||
groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {}
|
groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {}
|
||||||
hosts = sorted({host for values in groups.values() for host in values})
|
hosts = sorted({host for values in groups.values() for host in values})
|
||||||
return (("TOPLAM CİHAZ", str(len(hosts))), ("ÇEVRİMİÇİ", "—"),
|
# parse_inventory yalnız host adlarını verir; çevrimiçi/işlem durumu
|
||||||
("ÇALIŞAN İŞLEM", "yok"), ("UYARI", "yok"))
|
# 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:
|
def _on_resize(self, event) -> None:
|
||||||
if event.widget is not self.root or not hasattr(self, "detail"):
|
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():
|
if query and query not in host.lower():
|
||||||
continue
|
continue
|
||||||
wave = next((name for name, values in groups.items() if host in values), "—")
|
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:
|
def _select_device(self, _event=None) -> None:
|
||||||
selected = self.tree.selection()
|
selected = self.tree.selection()
|
||||||
|
|
@ -425,24 +506,163 @@ class RemotePortalSurface(SurfaceFrame):
|
||||||
if operation is None:
|
if operation is None:
|
||||||
messagebox.showinfo("Operasyon seçin", "Salt-okunur bir operasyon seçin.")
|
messagebox.showinfo("Operasyon seçin", "Salt-okunur bir operasyon seçin.")
|
||||||
return
|
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}")
|
self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}")
|
||||||
# The existing backend remains the only command authority.
|
# The existing backend remains the only command authority.
|
||||||
try:
|
try:
|
||||||
plan = self.backend.build_command(operation, Target("dealer", self.selected_host.replace("bf-", "")), {}, check_mode=True)
|
plan = self.backend.build_command(operation, target, {}, check_mode=True)
|
||||||
if not messagebox.askyesno("Salt-okunur operasyon", "Komut çalıştırılsın mı?\n\n" + "\n".join(plan.lines), default="no"):
|
|
||||||
self.status_var.set("İptal edildi")
|
|
||||||
return
|
|
||||||
def worker() -> None:
|
|
||||||
try:
|
|
||||||
result = self.backend.run(plan)
|
|
||||||
self.root.after(0, lambda: self.status_var.set(
|
|
||||||
f"Tamamlandı: çıkış kodu {result.exit_code}"))
|
|
||||||
except Exception as exc:
|
|
||||||
message = str(exc)
|
|
||||||
self.root.after(0, lambda: self.status_var.set(f"Hata: {message}"))
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.status_var.set(f"Hata: {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:
|
def _back(self) -> None:
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
@ -465,7 +685,12 @@ class RemotePortalSurface(SurfaceFrame):
|
||||||
return
|
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"):
|
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
|
return
|
||||||
|
try:
|
||||||
saved = save_dealer(value.get().strip())
|
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_dealer = saved["dealer_id"]
|
||||||
self.ctx.active_user = saved["active_user"]
|
self.ctx.active_user = saved["active_user"]
|
||||||
dialog.destroy()
|
dialog.destroy()
|
||||||
|
|
|
||||||
|
|
@ -189,10 +189,12 @@ class SegmentedControl:
|
||||||
self._paint()
|
self._paint()
|
||||||
|
|
||||||
def _paint(self) -> None:
|
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():
|
for key, button in self._buttons.items():
|
||||||
if key == self._value:
|
if key == self._value:
|
||||||
button.configure(style=self.styles["accent_button"],
|
button.configure(style=self.styles["accent_button"],
|
||||||
state="disabled")
|
state="normal")
|
||||||
else:
|
else:
|
||||||
button.configure(style=self.styles["button"], state="normal")
|
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/.
|
# 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.
|
# Safety: Idempotent under logrotate; validate with logrotate -d before deploying.
|
||||||
# Ref: docs/15-LOGGING.md, K-14.
|
# 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
|
weekly
|
||||||
rotate 7
|
rotate 7
|
||||||
size 10M
|
size 10M
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,6 @@
|
||||||
# Ref: docs/08-WIREGUARD-AND-NETWORK.md and docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md, K-05.
|
# Ref: docs/08-WIREGUARD-AND-NETWORK.md and docs/16-POWER-LOSS-AND-AUTO-RECOVERY.md, K-05.
|
||||||
[Service]
|
[Service]
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=30s
|
||||||
|
StartLimitIntervalSec=10min
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
|
||||||
|
|
@ -112,11 +112,14 @@ read_dealer_id_file() {
|
||||||
printf '%s' "$value"
|
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() {
|
marker_device_id() {
|
||||||
[[ -f "$MARKER" ]] || return 0
|
[[ -f "$MARKER" && -r "$MARKER" ]] || return 0
|
||||||
tr -d '[:space:]' < "$MARKER" || true
|
local value
|
||||||
|
value="$(tr -d '[:space:]' < "$MARKER" || true)"
|
||||||
|
valid_dealer_id "$value" || return 0
|
||||||
|
printf '%s' "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_marker() {
|
write_marker() {
|
||||||
|
|
@ -272,10 +275,22 @@ resolve_installer() {
|
||||||
# explicitly requested automation mode. A previous partial run is resumed.
|
# explicitly requested automation mode. A previous partial run is resumed.
|
||||||
run_installer() {
|
run_installer() {
|
||||||
local -a args=(--offline --dealer-id "$DEALER_ID" --yes)
|
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
|
if [[ -s "$STATE_DIR/install-state" ]]; then
|
||||||
args+=(--resume)
|
args+=(--resume)
|
||||||
log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting"
|
log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
if [[ "${BF_FIRSTBOOT_TEST_MODE:-0}" == 1 ]]; then
|
if [[ "${BF_FIRSTBOOT_TEST_MODE:-0}" == 1 ]]; then
|
||||||
echo "TEST: would run $INSTALLER ${args[*]}"
|
echo "TEST: would run $INSTALLER ${args[*]}"
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,10 @@ TTYPath=/dev/tty1
|
||||||
TTYReset=yes
|
TTYReset=yes
|
||||||
TTYVHangup=yes
|
TTYVHangup=yes
|
||||||
# Upper bound so a console that nobody answers can never hold the boot job open. The
|
# 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).
|
# wizard's own input read is bounded too (BF_FIRSTBOOT_INPUT_TIMEOUT). The installer is
|
||||||
TimeoutStartSec=45min
|
# 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
|
RemainAfterExit=yes
|
||||||
# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes
|
# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes
|
||||||
# and similar sandboxing are deliberately absent: the installer legitimately writes /etc,
|
# and similar sandboxing are deliberately absent: the installer legitimately writes /etc,
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,13 @@ fi
|
||||||
NOW="$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)"
|
NOW="$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
json_escape() { # stdin -> JSON string body
|
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"
|
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}"
|
[[ -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 '"')")"
|
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
|
JSON="$(cat <<EOF
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -76,15 +77,24 @@ def main() -> int:
|
||||||
if old_sudo.exists():
|
if old_sudo.exists():
|
||||||
shutil.move(old_sudo, new_sudo)
|
shutil.move(old_sudo, new_sudo)
|
||||||
moved_sudo = True
|
moved_sudo = True
|
||||||
text = new_sudo.read_text(encoding="utf-8").replace(old_user, new_user)
|
# Scope the rename to full-token occurrences of the old login name (a
|
||||||
new_sudo.write_text(text, encoding="utf-8")
|
# 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))
|
run("visudo", "-cf", str(new_sudo))
|
||||||
|
|
||||||
if old_ssh.exists():
|
if old_ssh.exists():
|
||||||
shutil.move(old_ssh, new_ssh)
|
shutil.move(old_ssh, new_ssh)
|
||||||
moved_ssh = True
|
moved_ssh = True
|
||||||
text = new_ssh.read_text(encoding="utf-8").replace(f"AllowUsers {old_user}", f"AllowUsers {new_user}")
|
# Replace only the full AllowUsers token, not substrings of other names.
|
||||||
new_ssh.write_text(text, encoding="utf-8")
|
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")
|
run("sshd", "-t")
|
||||||
|
|
||||||
tmp = IDENTITY_FILE.with_suffix(".tmp")
|
tmp = IDENTITY_FILE.with_suffix(".tmp")
|
||||||
|
|
@ -96,6 +106,7 @@ def main() -> int:
|
||||||
print(f"OK: {old_id} -> {new_id} ({new_user})")
|
print(f"OK: {old_id} -> {new_id} ({new_user})")
|
||||||
return 0
|
return 0
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
rollback_errors = []
|
||||||
try:
|
try:
|
||||||
if IDENTITY_FILE.read_text(encoding="utf-8").strip() == new_id:
|
if IDENTITY_FILE.read_text(encoding="utf-8").strip() == new_id:
|
||||||
IDENTITY_FILE.write_text(old_id + "\n", encoding="utf-8")
|
IDENTITY_FILE.write_text(old_id + "\n", encoding="utf-8")
|
||||||
|
|
@ -112,9 +123,28 @@ def main() -> int:
|
||||||
run("usermod", "-l", old_user, new_user)
|
run("usermod", "-l", old_user, new_user)
|
||||||
if old_hostname:
|
if old_hostname:
|
||||||
run("hostnamectl", "set-hostname", old_hostname)
|
run("hostnamectl", "set-hostname", old_hostname)
|
||||||
except Exception:
|
except Exception as rollback_exc:
|
||||||
pass
|
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}")
|
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)
|
print(f"HATA: kimlik geçişi geri alınmaya çalışıldı: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
|
||||||
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
|
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
|
||||||
STATE_FILE="$STATE_DIR/install-state"
|
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)
|
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
|
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
|
# 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
|
printf ' sistemde hiçbir değişiklik yapılmadı.\n' >&2
|
||||||
exit 1
|
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; }
|
state_of() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true; }
|
||||||
|
|
||||||
# ------------------------------------------------------- bundle discovery -----
|
# ------------------------------------------------------- bundle discovery -----
|
||||||
|
|
@ -221,7 +222,7 @@ validate_offline_repository() {
|
||||||
# in a temporary directory and is removed on exit.
|
# in a temporary directory and is removed on exit.
|
||||||
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
||||||
CHECK_TMP="$(mktemp -d)"
|
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"
|
printf 'deb [trusted=yes] file:%s ./\n' "$repo" > "$CHECK_TMP/offline.list"
|
||||||
BF_OFFLINE_APT_CONFIG="$CHECK_TMP/apt.conf"
|
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"
|
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'
|
[[ -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)."
|
[[ "$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.'
|
[[ "$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
|
# 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
|
# 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
|
fi
|
||||||
|
|
||||||
RUN=(); for m in "${MODULES[@]}"; do
|
RUN=(); for m in "${MODULES[@]}"; do
|
||||||
if [[ -n "$ONLY" ]]; then [[ "$m" == "$ONLY" || "$m" == "$ONLY"* ]] && RUN+=("$m"); continue; fi
|
# Exact match only: a bare prefix ("1") must never also select 11-18. A pure
|
||||||
[[ -n "$FROM" && "${m%%-*}" < "$FROM" ]] && continue
|
# 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
|
if [[ "$CHECK_MODE" -eq 0 && "$RESUME" -eq 1 && "$(state_of "$m")" == OK ]]; then log "SKIP $m (already OK)"; continue; fi
|
||||||
RUN+=("$m")
|
RUN+=("$m")
|
||||||
done
|
done
|
||||||
|
|
@ -316,17 +325,34 @@ done
|
||||||
if [[ "$CHECK_MODE" -eq 0 && "$BF_OFFLINE" -eq 1 && "$failures" -eq 0 ]]; then
|
if [[ "$CHECK_MODE" -eq 0 && "$BF_OFFLINE" -eq 1 && "$failures" -eq 0 ]]; then
|
||||||
install -d -m 700 "$STATE_DIR"
|
install -d -m 700 "$STATE_DIR"
|
||||||
umask 077
|
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
|
import json, os, sys, tempfile, uuid
|
||||||
path, dealer = sys.argv[1:]
|
path, dealer = sys.argv[1:]
|
||||||
state={'phase':'PROVISIONED_OFFLINE','enrollment_status':'PENDING','fleet_status':'PENDING','device_id':'BF-'+dealer,'provisioning_id':str(uuid.uuid4())}
|
try:
|
||||||
fd,tmp=tempfile.mkstemp(prefix='state.json.',dir=os.path.dirname(path)); os.fchmod(fd,0o600)
|
with open(path, encoding='utf-8') as f:
|
||||||
with os.fdopen(fd,'w') as f: json.dump(state,f,sort_keys=True); f.write('\n')
|
existing = json.load(f)
|
||||||
os.replace(tmp,path)
|
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
|
PY
|
||||||
|
)"
|
||||||
|
if [[ "$state_action" == PRESERVED* ]]; then
|
||||||
|
log "state preserved (phase=${state_action#PRESERVED })"
|
||||||
|
else
|
||||||
chmod 600 "$STATE_DIR/state.json"
|
chmod 600 "$STATE_DIR/state.json"
|
||||||
log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY"
|
log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY"
|
||||||
notice "Kurulum tamam: $BF_DEVICE yerel paket deposundan kuruldu."
|
notice "Kurulum tamam: $BF_DEVICE yerel paket deposundan kuruldu."
|
||||||
notice 'Cihaz şu an PROVISIONED_OFFLINE durumunda; kayıt (enrollment) tamamlanana kadar READY sayılmaz.'
|
notice 'Cihaz şu an PROVISIONED_OFFLINE durumunda; kayıt (enrollment) tamamlanana kadar READY sayılmaz.'
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
log "=== install done dealer=$BF_DEVICE failures=$failures ==="
|
log "=== install done dealer=$BF_DEVICE failures=$failures ==="
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue