Switches the operator path from bash-only to a Python console, keeps the bash
menu as a fallback, and makes the offline package set travel with the repository
so the person installing a device never downloads anything.
bfos/ (stdlib only, no textual/rich: those would need a download, which defeats
the offline goal):
- simple mode offers three choices (Kur / Durum / Çıkış); the detailed mode with
all 21 fleet operations and the local installer is reachable from the menu or
with --full, so a first-time operator is not buried in options
- gate parity with the bash console: scope read from the playbook file and
refused on mismatch, production needs a typed token plus a separate approval,
release-gated runs need their wave name typed, destructive runs need a second
approval, "all" walks wave by wave and stops at the first failure
- Turkish throughout; audit log at /var/log/blueforce-console.log (600, falls
back to the user state dir) with a single redaction choke point
- bfos/ui_gui.py adds an optional tkinter window over the same operations layer;
headless and missing-tkinter cases explain themselves in Turkish
admin/bf-bootstrap.sh: links the Python console as `bf` (primary) and keeps
`bf-menu` (fallback), reports both, and recommends `sudo bf`.
Offline packages are committable now (this was the blocking contradiction):
- .gitignore ignored `Packages`, `Packages.gz` and `packages.lock.tsv` everywhere,
so a built bundle could never be committed and the installer would always have
been told to download. provisioning/offline-repo/built/ is now allowed through
while scratch output stays untracked
- provisioning/offline-repo/{add-packages.sh,build-index.sh,repo-manifest.yaml}
plus a Turkish step-by-step README for the repository owner
- the over-broad `*token*` ignore rule is narrowed to `*[-_.]token*` so ordinary
filenames are not silently excluded while token files still are
Tests: run-all.sh now runs the Python suites too (previously it collected only
*.sh, so 212 Python assertions were silently skipped) and executes from the repo
root. 11/11 pass, exit 0.
Two test expectations were corrected rather than the code: a read-only
wave-scoped report legitimately asks for no gate token (the bash console never
did either), so that assertion now checks the command carries operation_wave and
operation_confirmed, and a new test asserts release-gated runs still require
their token. The bootstrap completion contract moved from `sudo bf-menu` to
`sudo bf`.
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Command execution and the audit log for the bfos console.
|
|
|
|
Separation of duties:
|
|
|
|
* :mod:`bfos.operations` decides *what* may run and produces an argv list,
|
|
* this module decides *whether* it runs (confirmation, dry-run, check mode),
|
|
executes it with :mod:`subprocess`, reports the exit code, and writes the
|
|
audit record.
|
|
|
|
Guarantees kept from the bash console (``admin/bf-menu``):
|
|
|
|
* the command is always printed **before** it is confirmed and run,
|
|
* ``--dry-run`` executes nothing at all,
|
|
* the audit log is mode 600 (directories 700) and falls back to
|
|
``$XDG_STATE_HOME/blueforce/`` when ``/var/log`` is not writable,
|
|
* no secret reaches the log: a single sanitizer redacts
|
|
``password=``/``token=``/``secret=``/``api_key=``/``private_key=``/``psk=``
|
|
style values and whole PEM blocks,
|
|
* raw command output is shown on screen and never copied into the log.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, List, Optional, Sequence
|
|
|
|
from . import config
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secret redaction -- the single choke point before anything is logged
|
|
# ---------------------------------------------------------------------------
|
|
_SECRET_KEY = r"(?:password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)"
|
|
_SECRET_ASSIGN_RE = re.compile(
|
|
r"((?:" + _SECRET_KEY + r")[A-Za-z0-9_.-]*\s*[=:])\s*(\S+)",
|
|
re.IGNORECASE,
|
|
)
|
|
_PEM_LINE_RE = re.compile(r"BEGIN [A-Z0-9 ]*PRIVATE KEY")
|
|
_PEM_BLOCK_RE = re.compile(
|
|
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
|
re.DOTALL,
|
|
)
|
|
|
|
REDACTED = "<redacted>"
|
|
|
|
|
|
def redact(text: str) -> str:
|
|
"""Return TEXT with secret-looking values and PEM blocks removed.
|
|
|
|
Deliberately blunt: log records are written for every command, so the
|
|
sanitizer must be safe for text that was never meant to be a secret. A PEM
|
|
header without its matching footer (truncated line, interrupted paste) is
|
|
treated as the start of a secret: everything from the header on is dropped,
|
|
because a half-copied key is still a key.
|
|
"""
|
|
if not text:
|
|
return text
|
|
cleaned = _PEM_BLOCK_RE.sub(REDACTED, text)
|
|
header = _PEM_LINE_RE.search(cleaned)
|
|
if header:
|
|
cleaned = cleaned[:header.start()] + REDACTED
|
|
cleaned = _SECRET_ASSIGN_RE.sub(lambda match: match.group(1) + REDACTED, cleaned)
|
|
return cleaned
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audit log
|
|
# ---------------------------------------------------------------------------
|
|
def fallback_log_path(state_home: Optional[str] = None, home: Optional[str] = None) -> str:
|
|
base = state_home or os.environ.get("XDG_STATE_HOME") or os.path.join(
|
|
home or os.path.expanduser("~"), ".local", "state"
|
|
)
|
|
return os.path.join(base, *config.LOG_FALLBACK_SUBDIR)
|
|
|
|
|
|
def _prepare_log_file(path: str) -> bool:
|
|
"""Create or tighten PATH to mode 600; True when it is usable."""
|
|
directory = os.path.dirname(path) or "."
|
|
try:
|
|
if os.path.exists(path):
|
|
if not os.path.isfile(path) or not os.access(path, os.W_OK):
|
|
return False
|
|
os.chmod(path, config.LOG_FILE_MODE)
|
|
else:
|
|
if not os.path.isdir(directory):
|
|
os.makedirs(directory, mode=config.LOG_DIR_MODE, exist_ok=True)
|
|
if not os.access(directory, os.W_OK):
|
|
return False
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, config.LOG_FILE_MODE)
|
|
os.close(fd)
|
|
os.chmod(path, config.LOG_FILE_MODE)
|
|
except OSError:
|
|
return False
|
|
return _is_private(path)
|
|
|
|
|
|
def _is_private(path: str) -> bool:
|
|
"""True when only the owner can read the log (never log into a world-readable file)."""
|
|
try:
|
|
mode = os.stat(path).st_mode
|
|
except OSError:
|
|
return False
|
|
return (mode & 0o077) == 0
|
|
|
|
|
|
@dataclass
|
|
class AuditLog:
|
|
"""Append-only audit trail. Never receives raw command output."""
|
|
|
|
requested: str = config.DEFAULT_LOG_FILE
|
|
state_home: Optional[str] = None
|
|
home: Optional[str] = None
|
|
path: Optional[str] = None
|
|
ready: bool = False
|
|
warning: str = ""
|
|
|
|
def initialise(self) -> None:
|
|
for candidate in (self.requested, fallback_log_path(self.state_home, self.home)):
|
|
if not candidate:
|
|
continue
|
|
if _prepare_log_file(candidate):
|
|
self.path = candidate
|
|
self.ready = True
|
|
if candidate != self.requested:
|
|
self.warning = config.MSG_LOG_FALLBACK.format(
|
|
requested=self.requested, fallback=candidate
|
|
)
|
|
return
|
|
self.ready = False
|
|
self.warning = config.MSG_LOG_NONE
|
|
|
|
def write(self, level: str, message: str, timestamp: Optional[str] = None) -> None:
|
|
if not self.ready or not self.path:
|
|
return
|
|
stamp = timestamp or _utc_now()
|
|
record = "{0} [{1}] {2}\n".format(stamp, level, redact(message))
|
|
try:
|
|
with open(self.path, "a", encoding="utf-8") as handle:
|
|
handle.write(record)
|
|
except OSError:
|
|
# A failing log must never break an operator's session.
|
|
self.ready = False
|
|
|
|
|
|
def _utc_now() -> str:
|
|
import datetime
|
|
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Execution
|
|
# ---------------------------------------------------------------------------
|
|
RUN_MODE_RUN = "run"
|
|
RUN_MODE_CHECK = "check"
|
|
RUN_MODE_CANCEL = "cancel"
|
|
|
|
|
|
@dataclass
|
|
class RunResult:
|
|
argv: List[str] = field(default_factory=list)
|
|
executed: bool = False
|
|
dry_run: bool = False
|
|
check: bool = False
|
|
cancelled: bool = False
|
|
rc: Optional[int] = None
|
|
signal_name: str = ""
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.executed and self.rc == 0
|
|
|
|
|
|
def command_string(argv: Sequence[str]) -> str:
|
|
"""A copy/pasteable shell line (like ``printf %q`` in the bash console)."""
|
|
return " ".join(shlex.quote(part) for part in argv)
|
|
|
|
|
|
def default_spawn(argv: Sequence[str]) -> int:
|
|
"""Run ARGV with inherited stdio and return its exit code."""
|
|
try:
|
|
completed = subprocess.run(list(argv), check=False)
|
|
return completed.returncode
|
|
except FileNotFoundError:
|
|
return 127
|
|
except PermissionError:
|
|
return 126
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
|
|
|
|
class Runner:
|
|
"""Show, confirm and execute commands; record every decision."""
|
|
|
|
def __init__(
|
|
self,
|
|
console,
|
|
audit: Optional[AuditLog] = None,
|
|
dry_run: bool = False,
|
|
check_mode: bool = False,
|
|
spawn: Optional[Callable[[Sequence[str]], int]] = None,
|
|
) -> None:
|
|
self.console = console
|
|
self.audit = audit or AuditLog()
|
|
self.dry_run = dry_run
|
|
self.check_mode = check_mode
|
|
self._spawn = spawn or default_spawn
|
|
|
|
# -- helpers -----------------------------------------------------------
|
|
def show_command(self, argv: Sequence[str], title: str = config.MSG_COMMAND_TITLE) -> str:
|
|
printable = command_string(argv)
|
|
self.console.section(title)
|
|
self.console.write(" " + printable)
|
|
self.console.write("")
|
|
return printable
|
|
|
|
def _check_flag(self, argv: List[str]) -> List[str]:
|
|
if "--check" not in argv:
|
|
return argv + ["--check"]
|
|
return argv
|
|
|
|
def _spawn_exit_code(self, argv: Sequence[str]) -> RunResult:
|
|
try:
|
|
rc = self._spawn(argv)
|
|
except KeyboardInterrupt:
|
|
return RunResult(argv=list(argv), executed=True, rc=130, signal_name="SIGINT")
|
|
return RunResult(argv=list(argv), executed=True, rc=rc)
|
|
|
|
# -- public API --------------------------------------------------------
|
|
def run_readonly(self, argv: Sequence[str], title: str) -> RunResult:
|
|
"""Run a read-only tool: printed, not confirmed (mirrors bf_show_local_cmd)."""
|
|
self.show_command(argv, title=title)
|
|
if self.dry_run:
|
|
self.console.warn(config.MSG_DRY_RUN_WARN)
|
|
self.audit.write(config.AUDIT_DRYRUN, command_string(argv))
|
|
return RunResult(argv=list(argv), dry_run=True)
|
|
self.audit.write(config.AUDIT_RUN, command_string(argv))
|
|
result = self._spawn_exit_code(argv)
|
|
if result.rc == 0:
|
|
self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(argv))
|
|
else:
|
|
self.console.warn(config.MSG_RUN_FAIL.format(rc=result.rc))
|
|
self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(argv)))
|
|
return result
|
|
|
|
def confirm_and_run(
|
|
self,
|
|
argv: Sequence[str],
|
|
label: str,
|
|
destructive: bool = False,
|
|
check_mode: Optional[bool] = None,
|
|
) -> RunResult:
|
|
"""Print the command, ask, then run it (or cancel / dry-run)."""
|
|
self.show_command(argv)
|
|
if self.dry_run:
|
|
self.console.warn(config.MSG_DRY_RUN_WARN)
|
|
self.audit.write(config.AUDIT_DRYRUN, command_string(argv))
|
|
return RunResult(argv=list(argv), dry_run=True)
|
|
|
|
if destructive:
|
|
self.console.warn(config.MSG_DESTRUCTIVE_WARN.format(label=label))
|
|
self.console.warn(config.MSG_DESTRUCTIVE_SUB)
|
|
|
|
mode = self.console.ask_run(config.MSG_RUN_ASK)
|
|
if mode == RUN_MODE_RUN:
|
|
final = list(argv)
|
|
elif mode == RUN_MODE_CHECK:
|
|
final = self._check_flag(list(argv))
|
|
self.console.info(config.MSG_CHECK_MODE)
|
|
else:
|
|
self.console.info(config.MSG_CANCELLED_RUN)
|
|
self.audit.write(config.AUDIT_CANCEL, "operatör iptal etti :: " + command_string(argv))
|
|
return RunResult(argv=list(argv), cancelled=True)
|
|
|
|
self.audit.write(config.AUDIT_RUN, command_string(final))
|
|
result = self._spawn_exit_code(final)
|
|
result.check = "--check" in final
|
|
if result.rc == 0:
|
|
self.console.ok(config.MSG_RUN_OK)
|
|
self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(final))
|
|
else:
|
|
self.console.error(config.MSG_RUN_FAIL.format(rc=result.rc))
|
|
self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(final)))
|
|
return result
|
|
|
|
# -- checks ------------------------------------------------------------
|
|
@staticmethod
|
|
def have_ansible() -> bool:
|
|
return _which("ansible-playbook") is not None
|
|
|
|
@staticmethod
|
|
def have_python() -> bool:
|
|
return sys.version_info >= config.MIN_PYTHON
|
|
|
|
@staticmethod
|
|
def is_root() -> bool:
|
|
return os.geteuid() == 0
|
|
|
|
|
|
def _which(name: str) -> Optional[str]:
|
|
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
candidate = os.path.join(directory, name)
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
return candidate
|
|
return None
|