aim-web2.1.0rc9

This commit is contained in:
admin_rb
2026-09-22 19:23:17 +02:00
parent d095887d2e
commit 3dfc80b782
438 changed files with 31613 additions and 1510 deletions
+890
View File
@@ -0,0 +1,890 @@
#!/usr/bin/env python3
"""Root-only, staged replacement of the WebGUI add-on. Never runs pip for AIM.
Run from a freshly unpacked release directory, not the active add-on directory.
Linux + systemd + Python >=3.11. See docs/DEPLOYMENT.md before use.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
import fcntl
import grp
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
import tomllib
from urllib.request import Request, ProxyHandler, build_opener
from urllib.parse import urlsplit
from fetch_assets import prepare
SERVICE = 'aim-web.service'
PREFIX = Path('/opt/aim-web')
STATE = Path('/var/lib/aim/webgui')
BACKUPS = Path('/var/backups/aim-web')
UNIT = Path('/etc/systemd/system') / SERVICE
META = PREFIX / 'deployment.json'
PENDING = PREFIX / 'pending.json'
CLI_LINK = Path('/usr/local/bin/aim-web')
EXECUTOR_UNIT = UNIT.with_name('aim-web-executor.service')
EXECUTOR_STATE = Path('/var/lib/aim-web-executor')
LEGACY_FILES = (
Path('/usr/local/libexec/aim-web-key-export'),
Path('/etc/sudoers.d/aim-web-key-export'),
Path('/etc/systemd/system/aim-web-worker.service.d/10-key-export-capabilities.conf'),
)
def executor_service(action):
if EXECUTOR_UNIT.is_file():
run(['systemctl', action, EXECUTOR_UNIT.name])
def unit_text(user, group, config, command, *, executor=False, executor_group=None, supplementary_group=None, executor_local_home=None):
description = 'AIM WebGUI core executor (AIM remains separately managed)' if executor else 'AIM WebGUI ' + command
state = EXECUTOR_STATE if executor else STATE
runtime = 'RuntimeDirectory=aim-web-executor\nRuntimeDirectoryMode=0711\n' if executor else ''
# Native core takes its own inventory locks. DAC permissions still decide write
# access. The add-on does not chown or otherwise modify the inventory tree.
executor_home = EXECUTOR_STATE
staging = executor_home / '.ansible/tmp'
executor_local_tmp = Path(executor_local_home) / '.ansible/tmp' if executor and executor_local_home is not None else None
writable = f'{state} {staging} {executor_local_tmp} -/etc/ansible/inventories' if executor else str(state)
after = 'After=network.target\n' if executor else 'After=network.target aim-web-executor.service\nWants=aim-web-executor.service\n'
executor_env = f'Environment=HOME={executor_home}\n' if executor else ''
executor_pre = f'ExecStartPre={PREFIX}/current/bin/aim-web --config {config} core-staging-check\n' if executor else ''
service_group = executor_group if executor and executor_group is not None else group
supplementary = f'SupplementaryGroups={supplementary_group}\n' if executor and supplementary_group is not None else ''
return f'''[Unit]
Description={description}
{after}
[Service]
Type=simple
User={user}
Group={service_group}
{supplementary}WorkingDirectory=/
ExecStart={PREFIX}/current/bin/aim-web --config {config} {command}
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PYTHONNOUSERSITE=1
{executor_env}{executor_pre}UMask=0077
Restart=on-failure
RestartSec=3
KillMode=control-group
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome={'read-only' if executor else 'true'}
ReadWritePaths={writable}
{runtime}RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true
LimitCORE=0
[Install]
WantedBy=multi-user.target
'''
def provision_executor_staging(executor_user):
account=pwd.getpwnam(executor_user)
base=EXECUTOR_STATE / '.ansible'
staging=base / 'tmp'
local_base=Path(account.pw_dir) / '.ansible'
local_staging=local_base / 'tmp'
for path in (EXECUTOR_STATE,base,staging,local_base,local_staging):
path.mkdir(mode=0o700,parents=True,exist_ok=True)
os.chown(path,account.pw_uid,account.pw_gid)
path.chmod(0o700)
def require_owner_mode(path: Path, uid: int, gid: int, mode: int, *, kind: str = 'path'):
if path.is_symlink() or not path.exists():
raise ValueError(f'Managed {kind} is missing or a symlink: {path}')
st=path.stat()
actual=st.st_mode & 0o777
if st.st_uid!=uid or st.st_gid!=gid or actual!=mode:
raise ValueError(
f'Managed {kind} has unexpected ownership/mode: {path}; '
f'expected uid={uid} gid={gid} mode={mode:04o}, got '
f'uid={st.st_uid} gid={st.st_gid} mode={actual:04o}.')
def validate_managed_permissions(service_user: str, executor_user: str, config: Path):
web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user)
require_owner_mode(STATE,web.pw_uid,web.pw_gid,0o700,kind='WebGUI state directory')
require_owner_mode(config,0,web.pw_gid,0o640,kind='WebGUI configuration')
require_owner_mode(EXECUTOR_STATE,executor.pw_uid,executor.pw_gid,0o700,kind='executor state directory')
require_owner_mode(EXECUTOR_STATE/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor Ansible directory')
require_owner_mode(EXECUTOR_STATE/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor controller-local staging directory')
require_owner_mode(Path(executor.pw_dir)/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local Ansible directory')
require_owner_mode(Path(executor.pw_dir)/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local staging directory')
for path in (UNIT,worker_unit(),EXECUTOR_UNIT):
require_owner_mode(path,0,0,0o644,kind='systemd unit')
def validate_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path):
web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user)
require_owner_mode(socket_path.parent,executor.pw_uid,executor.pw_gid,0o711,kind='executor runtime directory')
require_owner_mode(socket_path,executor.pw_uid,web.pw_gid,0o660,kind='executor socket')
def wait_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path, *, timeout: float = 10.0):
"""Wait for the Type=simple executor to bind and permission its managed socket.
systemctl start returns after the process is launched, not after Executor.run()
has completed capability negotiation and listener.bind(). Treat a missing socket
during that short window as startup-in-progress, not as a migration failure.
"""
deadline=time.monotonic()+timeout
last=None
while True:
try:
validate_executor_runtime_permissions(service_user,executor_user,socket_path)
return
except ValueError as exc:
last=exc
active=subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name]).returncode==0
if not active:
raise ValueError('Executor service exited before its managed socket became ready.') from last
if time.monotonic()>=deadline:
raise ValueError(f'Executor managed socket did not become ready within {timeout:g}s: {socket_path}') from last
time.sleep(0.1)
def validate_legacy_files():
for path in LEGACY_FILES:
if path.is_symlink():
raise ValueError(f'Refusing a symlink at legacy bridge: {path}')
if not path.exists():
continue
text = path.read_text()
if path.name == 'aim-web-key-export':
if 'key' not in text or ('aim-web' not in text and 'private' not in text.lower()):
raise ValueError(f'Unrecognized legacy bridge; review manually: {path}')
elif 'CapabilityBoundingSet=CAP_SETUID CAP_SETGID' not in text:
raise ValueError(f'Unexpected worker capability override; review manually: {path}')
# Other unit overrides can silently retain the old identity/capabilities.
for name in ('aim-web.service','aim-web-worker.service','aim-web-executor.service'):
directory = UNIT.parent / (name + '.d')
if directory.exists():
unexpected = [p for p in directory.glob('*.conf') if p not in LEGACY_FILES]
if unexpected:
raise ValueError('Review and temporarily move unmanaged unit drop-ins before migration: ' + ', '.join(map(str,unexpected)))
def restore_extra(snapshot, record):
for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)):
saved=snapshot/f'extra-{number}'
if saved.exists():
path.parent.mkdir(parents=True,exist_ok=True)
atomic_bytes(path,saved.read_bytes(),record['extra_modes'][str(path)])
os.chown(path,0,0)
else:
path.unlink(missing_ok=True)
def run(args, *, capture=False, **kwargs):
return subprocess.run([str(a) for a in args], check=True, text=True, capture_output=capture, **kwargs)
def atomic_bytes(path: Path, data: bytes, mode=0o600):
if path.is_symlink():
raise ValueError(f'Refusing a symlink at managed file: {path}')
fd, name = tempfile.mkstemp(prefix='.aim-web-', dir=path.parent)
temp = Path(name)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temp.chmod(mode)
os.replace(temp, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temp.unlink(missing_ok=True)
def write_json(path, value):
atomic_bytes(path, (json.dumps(value, indent=2) + '\n').encode())
def sqlite_backup(source: Path, destination: Path):
"""Create a consistent SQLite checkpoint without importing any WebGUI runtime."""
if not source.is_file():
raise ValueError(f'Authentication database is missing: {source}')
destination.unlink(missing_ok=True)
src = sqlite3.connect(f'file:{source}?mode=ro', uri=True)
dst = sqlite3.connect(destination)
try:
src.backup(dst)
dst.commit()
finally:
dst.close()
src.close()
destination.chmod(0o600)
def current_env() -> Path | None:
link = PREFIX / 'current'
return link.resolve() if link.is_symlink() else None
def switch_env(env: Path):
link = PREFIX / '.current-next'
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(env)
os.replace(link, PREFIX / 'current')
def ensure_cli_link():
"""Expose the active release on PATH without copying a versioned executable."""
target = PREFIX / 'current/bin/aim-web'
if CLI_LINK.exists() and not CLI_LINK.is_symlink():
raise ValueError(f'Refusing to overwrite an unmanaged CLI file: {CLI_LINK}')
if CLI_LINK.is_symlink():
if Path(os.readlink(CLI_LINK)) != target:
raise ValueError(f'Refusing to replace an unrelated CLI symlink: {CLI_LINK}')
return
CLI_LINK.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
temporary = CLI_LINK.parent / ('.aim-web-link-' + new_id())
try:
temporary.symlink_to(target)
os.replace(temporary, CLI_LINK)
finally:
temporary.unlink(missing_ok=True)
def remove_managed_cli_link():
target = PREFIX / 'current/bin/aim-web'
if CLI_LINK.is_symlink() and Path(os.readlink(CLI_LINK)) == target:
CLI_LINK.unlink()
def worker_unit() -> Path:
return UNIT.with_name('aim-web-worker.service')
def worker_active() -> bool:
return worker_unit().is_file() and subprocess.run(
['systemctl', 'is-active', '--quiet', 'aim-web-worker.service'], check=False).returncode == 0
def worker_service(action):
if worker_unit().is_file():
run(['systemctl', action, 'aim-web-worker.service'])
def active() -> bool:
return subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False).returncode == 0
def service(action):
run(['systemctl', action, SERVICE])
def web_config_value(config: dict, section: str, key: str, legacy: str, default):
values = config.get(section, {})
if isinstance(values, dict) and key in values:
return values[key]
return config.get(legacy, default)
def safe_absolute(path: Path) -> Path:
# Protect systemd syntax and prevent unexpectedly following operator symlinks.
if not path.is_absolute() or not re.fullmatch(r'/[A-Za-z0-9_./-]+', str(path)):
raise ValueError('Deployment paths must be absolute and contain only letters, digits, /, _, . and -.')
if path.resolve() != path:
raise ValueError(f'Use a canonical path without symlinks or dot components: {path}')
return path
def release_order(version: str) -> tuple[int, ...]:
match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:rc(\d+))?', version)
if not match:
raise ValueError('Use independent x.y.z or x.y.zrcN add-on versions.')
major, minor, patch, rc = match.groups()
return (int(major), int(minor), int(patch), int(rc is None), int(rc or 0))
def new_id() -> str:
return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
@dataclass
class Deployment:
scripts: Path
user: str
required_gid: int
@property
def target(self):
return self.scripts / 'addons/webgui'
@property
def config(self):
return self.scripts / 'config/webgui.toml'
def as_user(self, command, *, capture=False):
account = pwd.getpwnam(self.user)
groups = list(set(os.getgrouplist(self.user, account.pw_gid) + [self.required_gid]))
def drop():
os.setgroups(groups)
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
os.umask(0o077)
env = {'PATH':'/usr/sbin:/usr/bin:/sbin:/bin', 'HOME':str(STATE), 'LANG':'C.UTF-8',
'PYTHONDONTWRITEBYTECODE':'1', 'PYTHONNOUSERSITE':'1'}
return run(command, preexec_fn=drop, cwd='/', env=env, capture=capture)
def as_executor(self, user, command, *, capture=False):
account=pwd.getpwnam(user)
web=pwd.getpwnam(self.user)
def drop():
# Match the managed systemd identity: preserve the executor account's
# normal primary GID and add only the WebGUI group needed for the
# release-managed config/socket boundary. No /etc/group mutation.
os.setgroups(list(set(os.getgrouplist(user,account.pw_gid) + [web.pw_gid])))
os.setgid(account.pw_gid)
os.setuid(account.pw_uid)
os.umask(0o077)
environment={'PATH':'/usr/local/bin:/usr/bin:/bin','HOME':str(EXECUTOR_STATE),'LANG':'C.UTF-8',
'PYTHONDONTWRITEBYTECODE':'1','PYTHONNOUSERSITE':'1'}
return run(command,preexec_fn=drop,cwd='/',env=environment,capture=capture)
def cli(self, env, *args, capture=False):
return self.as_user([env / 'bin/aim-web', '--config', self.config, *args], capture=capture)
def snapshot(self, old: dict | None) -> Path:
stamp = BACKUPS / new_id()
stamp.mkdir(mode=0o700)
data = {'previous':old, 'was_active':active(), 'worker_active':worker_active(), 'worker_present':worker_unit().exists(), 'config_present':self.config.exists(),
'unit_present':UNIT.exists(), 'database_present':STATE.joinpath('webgui.sqlite3').exists()}
if self.target.exists():
shutil.copytree(self.target, stamp / 'source', symlinks=True)
if self.config.exists():
shutil.copy2(self.config, stamp / 'webgui.toml')
if UNIT.exists():
shutil.copy2(UNIT, stamp / 'aim-web.service')
if worker_unit().exists():
shutil.copy2(worker_unit(), stamp / 'aim-web-worker.service')
if data['database_present']:
# Use SQLite's online backup API directly. Snapshotting must not depend on
# the previous WebGUI runtime being able to parse the current config.
sqlite_backup(STATE / 'webgui.sqlite3', stamp / 'webgui.sqlite3')
os.chown(stamp / 'webgui.sqlite3', 0, 0)
data['executor_active'] = EXECUTOR_UNIT.exists() and subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name],check=False).returncode==0
data['extra_modes']={}
for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)):
if path.exists():
if path.is_symlink():raise ValueError(f'Unexpected symlink: {path}')
shutil.copy2(path,stamp/f'extra-{number}')
data['extra_modes'][str(path)] = path.stat().st_mode & 0o777
write_json(stamp / 'snapshot.json', data)
return stamp
def restore_db(self, snapshot: Path):
source = snapshot / 'webgui.sqlite3'
if not source.is_file():
raise ValueError('This snapshot has no database to restore.')
target = STATE / 'webgui.sqlite3'
temporary = STATE / '.restore.sqlite3'
temporary.unlink(missing_ok=True)
shutil.copyfile(source, temporary)
account = pwd.getpwnam(self.user)
os.chown(temporary, account.pw_uid, account.pw_gid)
temporary.chmod(0o600)
# Service must be stopped. Preserve failed data in the snapshot before replacement.
for suffix in ('', '-wal', '-shm', '-journal'):
file = Path(str(target) + suffix)
if file.exists():
shutil.copy2(file, snapshot / ('pre-restore-' + new_id() + suffix + '.sqlite3'))
if suffix:
file.unlink()
os.replace(temporary, target)
def restore(self, snapshot: Path, *, database: bool, config: bool = False):
record = json.loads((snapshot / 'snapshot.json').read_text())
old = record['previous']
worker_service('stop')
executor_service('stop')
restore_extra(snapshot,record)
if old is None:
# An early first-install failure may not have created/loaded a unit.
if UNIT.exists() or active():
service('stop')
# A failed first install preserves private state for a deliberate retry.
if self.target.exists():
shutil.rmtree(self.target)
(PREFIX / 'current').unlink(missing_ok=True)
remove_managed_cli_link()
META.unlink(missing_ok=True)
subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
UNIT.unlink(missing_ok=True)
worker_unit().unlink(missing_ok=True)
if config:
self.config.unlink(missing_ok=True)
run(['systemctl', 'daemon-reload'])
return
service('stop')
if database:
self.restore_db(snapshot)
staged = self.target.parent / ('.webgui-restore-' + new_id())
shutil.copytree(snapshot / 'source', staged, symlinks=True)
displaced = self.target.parent / ('.webgui-displaced-' + new_id())
if self.target.exists():
os.replace(self.target, displaced)
os.replace(staged, self.target)
if displaced.exists():
shutil.rmtree(displaced)
switch_env(Path(old['venv']))
ensure_cli_link()
if config and record['config_present']:
atomic_bytes(self.config, (snapshot / 'webgui.toml').read_bytes(), 0o640)
os.chown(self.config, 0, pwd.getpwnam(self.user).pw_gid)
if record['unit_present']:
atomic_bytes(UNIT, (snapshot / 'aim-web.service').read_bytes(), 0o644)
if record.get('worker_present'):
atomic_bytes(worker_unit(), (snapshot / 'aim-web-worker.service').read_bytes(), 0o644)
else:
if worker_unit().exists():
run(['systemctl', 'disable', 'aim-web-worker.service'])
worker_unit().unlink()
write_json(META, old)
run(['systemctl', 'daemon-reload'])
# Revoking sessions also avoids restoring live session tokens from backups.
self.as_user([Path(old['venv']) / 'bin/python', '-c',
'from aim_webgui.auth.service import Auth; from aim_webgui.config import Settings; '
'from pathlib import Path; import sys; a=Auth(Settings.load(Path(sys.argv[1]))); '
'db=a.store.connect(); db.execute("DELETE FROM sessions"); db.commit(); db.close()', self.config])
if release_order(old['version'])[0] < 2:
# The core was separately upgraded to 3.3.0rc8. The legacy 1.x adapter
# cannot be declared healthy here. Restored history/code remains stopped.
subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service'],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('Restored legacy WebGUI but left services STOPPED and disabled: coordinate a separate AIM core rollback before restarting. AIM was not changed.',file=sys.stderr)
return
# Source and state restoration must not restart an adapter whose exact
# independently managed Core contract is no longer available. Probe using
# the restored package/config and the executor identity, never root.
if not self.restored_core_compatible(old):
subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],
check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('Restored add-on source/config/state; services remain STOPPED and disabled. '
'The restored adapter does not qualify the currently installed Core. '
'Coordinate the independent Core rollback, then explicitly re-enable services. '
'No Core files were changed.',file=sys.stderr)
return
if record.get('executor_active'):
executor_service('start')
self.cli(Path(old['venv']), 'check')
if record['was_active']:
service('start')
self.health()
if record.get('worker_active'):
worker_service('start')
def restored_core_compatible(self, old):
try:
self.as_executor(old.get('executor_user','svc_bf-ansible'),
[Path(old['venv'])/'bin/python','-B','-c',
'from dataclasses import replace; from pathlib import Path; import sys; '
'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; '
'CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio"))',self.config],capture=True)
return True
except (OSError,ValueError,subprocess.SubprocessError):
return False
def health(self):
with self.config.open('rb') as stream:
config = tomllib.load(stream)
host = web_config_value(config, 'server', 'host', 'host', '127.0.0.1')
port = web_config_value(config, 'server', 'port', 'port', 8080)
# A wildcard listener is not a routable health-check destination.
check_host = '127.0.0.1' if host in {'0.0.0.0', '::'} else host
authority = f'[{check_host}]' if ':' in check_host else check_host
url = f'http://{authority}:{port}/readyz'
origin = urlsplit(web_config_value(config, 'server', 'public_url', 'public_url', 'http://127.0.0.1:8080'))
opener = build_opener(ProxyHandler({}))
for _ in range(12):
try:
req = Request(url, headers={'Host':origin.netloc})
with opener.open(req, timeout=10) as response:
if response.status == 200 and json.load(response).get('status') == 'ready':
return
except Exception:
pass
time.sleep(.5)
raise RuntimeError('Readiness check failed. Inspect journalctl -u aim-web.service.')
def verify_release(source):
"""Verify the ZIP payload manifest before staging/provisioning any resource.
The separately verified ZIP digest checks the expected release file;
this manifest detects extraction damage, not publisher signatures.
"""
manifest=source/'MANIFEST.sha256'
if not manifest.is_file() or manifest.is_symlink():
raise ValueError('Missing release MANIFEST.sha256. Use a fresh release ZIP.')
seen=set()
for line in manifest.read_text(encoding='utf-8').splitlines():
if not line: continue
digest,sep,name=line.partition(' ')
rel=Path(name)
normalized=rel.as_posix()
if not sep or not re.fullmatch('[0-9a-f]{64}',digest) or not name or rel.is_absolute() or '..' in rel.parts or normalized in seen:
raise ValueError('Invalid release manifest entry.')
seen.add(normalized)
path=source/rel
if any(p.is_symlink() for p in (path,*path.parents)) or not path.is_file():
raise ValueError('Release manifest references an unsafe or missing file.')
if hashlib.sha256(path.read_bytes()).hexdigest()!=digest:
raise ValueError('Release integrity mismatch: '+name)
if not {'pyproject.toml','deploy/deploy.py','src/aim_webgui/__init__.py'}<=seen:
raise ValueError('Release manifest does not cover required files.')
def install(args):
scripts = safe_absolute(args.aim_scripts)
source = Path(__file__).resolve().parents[1]
verify_release(source)
target = scripts / 'addons/webgui'
if source == target:
raise ValueError('Unpack the new ZIP in a separate staging directory; do not update from the active source.')
if not scripts.is_dir():
raise ValueError('Deploy the separately managed AIM core first.')
core_launcher = args.aimctl.absolute()
if not core_launcher.is_file() or not os.access(core_launcher,os.X_OK):
raise ValueError('The configured aimctl launcher is missing or not executable. Deploy AIM 3.3.0rc8 first; this installer never creates it.')
safe_absolute(args.core_config)
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}',args.executor_user):raise ValueError('Invalid executor account name.')
try: executor_account=pwd.getpwnam(args.executor_user)
except KeyError: raise ValueError('Provision and authorize the existing non-root AIM execution/key-owning account first.') from None
if executor_account.pw_uid==0 or args.executor_user==args.service_user:
raise ValueError('Managed topology needs distinct non-root web and execution accounts.')
validate_legacy_files()
if any(p.exists() for p in LEGACY_FILES) and not args.migrate_core:
raise ValueError('Legacy key-export/capability files exist. Review migration and pass --migrate-core to retire only these backed-up files.')
with (source / 'pyproject.toml').open('rb') as stream:
project = tomllib.load(stream)['project']
version = project['version']
if not re.fullmatch(r'[0-9A-Za-z.+-]+', version):
raise ValueError('Invalid add-on version.')
previous = json.loads(META.read_text()) if META.exists() else None
if args.command == 'update' and previous is None:
raise ValueError('No managed add-on installation exists. Use install first.')
if args.command == 'install' and previous:
raise ValueError('Add-on already installed. Use update; existing accounts will be retained.')
if previous and release_order(previous['version'])[0] < 2 and not args.migrate_core:
raise ValueError('Major core integration migration: pass --migrate-core after reviewing MIGRATION-3.1.md. Old queued work is stopped; schema changes require a backup.')
if previous and (previous['scripts'] != str(scripts) or previous['user'] != args.service_user):
raise ValueError('Update must retain the installed AIM scripts path and service identity.')
if previous and release_order(version) < release_order(previous['version']):
raise ValueError('Downgrades use rollback, not update.')
if previous and previous['version'] == version:
raise ValueError('This add-on version is already installed. Published versions are immutable; use a new release number.')
if target.exists() and (not previous or not (target / '.aim-web-managed').is_file()):
raise ValueError('Target is not a recognized managed WebGUI directory. Back it up and move it aside manually.')
if not previous and UNIT.exists():
raise ValueError('An unmanaged aim-web.service already exists; refusing to overwrite it.')
if target.is_symlink():
raise ValueError('The active add-on source must be a real directory, not a symlink.')
for path in source.rglob('*'):
if path.is_symlink():
raise ValueError(f'Release contains an unexpected symlink: {path}')
if not target.parent.exists():
target.parent.mkdir(parents=True, mode=0o755)
target.parent.chmod(0o755)
stage = Path(tempfile.mkdtemp(prefix='.webgui-stage-', dir=target.parent))
environment = PREFIX / 'venvs' / (version + '-' + new_id())
snapshot = None
deployment = None
activated = False
try:
shutil.copytree(source, stage, dirs_exist_ok=True,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.pytest_cache', '.credentials', '*.egg-info', 'build', 'dist', 'wheelhouse', 'offline-assets', '*.zip'))
# Network/offline artifacts are prepared BEFORE stopping the running service.
prepare(stage / 'src/aim_webgui/static/vendor', args.assets_dir,
reuse=target / 'src/aim_webgui/static/vendor' if previous else None)
environment.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
environment.parent.chmod(0o755)
# Runtime code is root-owned but readable/executable by the service account.
os.umask(0o022)
run([args.python, '-m', 'venv', environment])
pip = [environment / 'bin/python', '-m', 'pip', 'install', '--no-compile', '--no-cache-dir']
if args.wheelhouse:
pip.extend(['--no-index', '--find-links', args.wheelhouse])
run([*pip, '-c', stage / 'constraints.txt', 'setuptools'])
run([*pip, '--no-build-isolation', '-c', stage / 'constraints.txt', stage])
run([environment / 'bin/python', '-m', 'pip', 'check'])
identity = run([environment / 'bin/python', '-B', '-c',
'import aim_webgui,importlib.metadata; '
'print(aim_webgui.__version__); print(importlib.metadata.version("aim-webgui"))'], capture=True)
if identity.stdout.splitlines() != [version, version]:
raise ValueError('Candidate package identity does not match this release; nothing was activated.')
os.umask(0o077)
# Do not parse or import private core configuration. Public protocol probe follows.
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', args.service_user):
raise ValueError('Use a local service account name, not a shell expression.')
try:
account = pwd.getpwnam(args.service_user)
except KeyError:
shell = shutil.which('nologin') or '/usr/sbin/nologin'
run(['useradd','--system','--user-group','--home-dir',STATE,'--shell',shell,args.service_user])
account = pwd.getpwnam(args.service_user)
if account.pw_uid == 0:
raise ValueError('The web service must not run as root.')
if not STATE.parent.exists():
STATE.parent.mkdir(parents=True, mode=0o755)
STATE.parent.chmod(0o755)
STATE.mkdir(mode=0o700, exist_ok=True)
if STATE.is_symlink():
raise ValueError('State directory must not be a symlink.')
os.chown(STATE, account.pw_uid, account.pw_gid)
STATE.chmod(0o700)
required_gid = account.pw_gid
deployment = Deployment(scripts, args.service_user, required_gid)
if not deployment.config.parent.exists():
deployment.config.parent.mkdir(parents=True, mode=0o755)
deployment.config.parent.chmod(0o755)
# Stage and parse the candidate config without changing the installed config.
text=(stage/'deploy/webgui.example.toml').read_text().replace('/etc/ansible/scripts',str(scripts))
text=text.replace('command = ["/usr/local/bin/aimctl"]',f'command = [{json.dumps(str(core_launcher))}]')
text=text.replace('config = "/etc/ansible/scripts/aim.yml"',f'config = {json.dumps(str(args.core_config))}')
text=text.replace('executor_user = "svc_bf-ansible"',f'executor_user = {json.dumps(args.executor_user)}')
text=text.replace('client_user = "aim-web"',f'client_user = {json.dumps(args.service_user)}')
release_config=tomllib.loads(text)
if release_config['state']['state_dir']!=str(STATE):raise ValueError('Unexpected managed state location.')
candidate=stage/'candidate.toml'
candidate.write_text(text);candidate.chmod(0o640);os.chown(candidate,0,account.pw_gid);stage.chmod(0o755)
deployment.as_user([environment/'bin/aim-web','--config',candidate,'check','--without-db','--without-core'])
# Test public capabilities and authorization in the EXISTING core environment,
# as the eventual executor identity. No dependency installation or core writes.
probe=deployment.as_executor(args.executor_user,[environment/'bin/python','-B','-c',
'from dataclasses import replace; from pathlib import Path; import sys; '
'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; '
'a=CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio")); '
'print(a.version); print("Authorized customer listing:",len(a.customers()))',candidate],capture=True)
print(probe.stdout.strip())
candidate.unlink()
snapshot=deployment.snapshot(previous)
write_json(PENDING,{'backup':snapshot.name,'candidate':str(environment),'phase':'before-stop',
'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid})
if previous:
worker_service('stop');service('stop');executor_service('stop')
sqlite_backup(STATE/'webgui.sqlite3',snapshot/'webgui.sqlite3')
os.chown(snapshot/'webgui.sqlite3',0,0)
atomic_bytes(deployment.config,text.encode(),0o640);os.chown(deployment.config,0,account.pw_gid)
# Neither group memberships nor inventory/key ownership are touched.
if EXECUTOR_STATE.exists() and (EXECUTOR_STATE.is_symlink() or EXECUTOR_STATE.stat().st_uid!=executor_account.pw_uid):
raise ValueError('Existing executor state directory has an unexpected owner or type.')
EXECUTOR_STATE.mkdir(mode=0o700,exist_ok=True);os.chown(EXECUTOR_STATE,executor_account.pw_uid,executor_account.pw_gid);EXECUTOR_STATE.chmod(0o700)
provision_executor_staging(args.executor_user)
if previous:deployment.cli(environment,'db','migrate')
else:deployment.cli(environment,'init')
deployment.cli(environment,'check','--without-core')
for directory in list(stage.rglob('__pycache__')) + list(stage.glob('src/*.egg-info')) + [stage / 'build', stage / 'dist']:
if directory.is_dir():
shutil.rmtree(directory)
# Source is replaced wholesale. No stale files, git, patch hunks or core overlay.
for item in stage.rglob('*'):
if not item.is_symlink():
item.chmod(0o755 if item.is_dir() else 0o644)
(stage / '.aim-web-managed').write_text('aim-webgui ' + version + '\n')
(stage / '.credentials').symlink_to(STATE / '.credentials')
stage.chmod(0o755)
if target.exists():
displaced = target.parent / ('.webgui-old-' + new_id())
os.replace(target, displaced)
else:
displaced = None
os.replace(stage, target)
activated = True
if displaced:
shutil.rmtree(displaced)
switch_env(environment)
ensure_cli_link()
atomic_bytes(UNIT,unit_text(args.service_user,account.pw_gid,deployment.config,'serve').encode(),0o644)
atomic_bytes(worker_unit(),unit_text(args.service_user,account.pw_gid,deployment.config,'worker').encode(),0o644)
atomic_bytes(EXECUTOR_UNIT,unit_text(args.executor_user,account.pw_gid,deployment.config,'executor',executor=True,executor_group=executor_account.pw_gid,supplementary_group=account.pw_gid,executor_local_home=executor_account.pw_dir).encode(),0o644)
for managed_unit in (UNIT,worker_unit(),EXECUTOR_UNIT): os.chown(managed_unit,0,0);managed_unit.chmod(0o644)
validate_managed_permissions(args.service_user,args.executor_user,deployment.config)
# Approved major migration retires the known old bridge and elevated worker
# override; snapshot restoration restores matching legacy files if needed.
for path in LEGACY_FILES:path.unlink(missing_ok=True)
write_json(META, {'version':version,'scripts':str(scripts),'user':args.service_user,
'required_gid':required_gid,'venv':str(environment),'backup':snapshot.name,
'executor_user':args.executor_user,'core_version':'3.3.0rc8','api_version':'1.0'})
write_json(PENDING, {'backup':snapshot.name,'candidate':str(environment),'phase':'activated',
'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid})
run(['systemctl','daemon-reload'])
run(['systemctl','enable',EXECUTOR_UNIT.name])
executor_service('start')
wait_executor_runtime_permissions(args.service_user,args.executor_user,Path(release_config['core']['socket']))
run(['systemctl','enable',SERVICE])
service('start')
deployment.health()
if release_config.get('execution', {}).get('enabled', False):
run(['systemctl', 'enable', 'aim-web-worker.service'])
worker_service('start')
else:
worker_service('stop')
run(['systemctl', 'disable', 'aim-web-worker.service'])
deployment.as_user([environment / 'bin/python', '-B', '-c',
'import sys; from pathlib import Path; from aim_webgui.config import Settings; '
'from aim_webgui.db.store import Store,audit; s=Store(Settings.load(Path(sys.argv[1])).database); '
'db=s.connect(); audit(db,"deployer","release-installed",sys.argv[2]); db.commit(); db.close()',
deployment.config, version])
PENDING.unlink()
print(f'Installed AIM WebGUI {version}; separately managed AIM 3.3.0rc8 was not modified.')
print(f'Add-on source: {target}')
print(f'Configuration: {deployment.config}')
print(f'Rollback snapshot: {snapshot.name}')
if (STATE / '.credentials').exists():
print(f'Initial admin credentials (local file only): {(target / ".credentials").as_uri()}')
print(f'Read locally: sudo cat {STATE}/.credentials')
print(f'CLI: {CLI_LINK} -> {PREFIX}/current/bin/aim-web')
print('Service: systemctl status aim-web.service')
except BaseException:
if snapshot and deployment and PENDING.exists():
print('Deployment failed. Attempting to restore the prior add-on; AIM was not modified.', file=sys.stderr)
try:
deployment.restore(snapshot, database=bool(previous), config=True)
PENDING.unlink(missing_ok=True)
except Exception:
print(f'Automatic recovery did not complete. Keep service stopped; recovery snapshot: {snapshot}', file=sys.stderr)
raise
finally:
if stage.exists():
shutil.rmtree(stage)
# Failed venvs are left for diagnosis; never delete one referenced by a snapshot.
def rollback(args):
if not META.exists():
raise ValueError('No active deployment metadata. Recover using docs/DEPLOYMENT.md and pending.json.')
latest = json.loads(META.read_text())
if not args.backup or not re.fullmatch(r'[0-9]{8}T[0-9]{12}Z', args.backup):
raise ValueError('Use --backup with the exact snapshot identifier printed during deployment.')
selected = BACKUPS / args.backup
record = json.loads((selected / 'snapshot.json').read_text())
if not record.get('previous'):
raise ValueError('This is the first-install checkpoint, not an earlier installed version.')
if record['previous']['scripts'] != latest['scripts']:
raise ValueError('Snapshot is for a different installation.')
deployment = Deployment(Path(latest['scripts']), latest['user'], latest['required_gid'])
if not args.restore_auth_db:
# Validate the rollback runtime against the configuration stored with that release.
old_env = Path(record['previous']['venv'])
schema = deployment.as_user([old_env / 'bin/python', '-B', '-c',
'from aim_webgui import SCHEMA_VERSION; print(SCHEMA_VERSION)'], capture=True)
with sqlite3.connect((STATE / 'webgui.sqlite3').as_uri() + '?mode=ro', uri=True) as db:
current_schema = db.execute('PRAGMA user_version').fetchone()[0]
if current_schema != int(schema.stdout.strip()):
raise ValueError('Rollback crosses a database schema boundary. Use --restore-auth-db explicitly after backing up current data.')
with tempfile.TemporaryDirectory(prefix='.rollback-config-', dir=PREFIX) as directory:
check_dir = Path(directory)
check_dir.chmod(0o755)
check_config = check_dir / 'webgui.toml'
shutil.copyfile(selected / 'webgui.toml', check_config)
check_config.chmod(0o640)
os.chown(check_config, 0, pwd.getpwnam(latest['user']).pw_gid)
deployment.as_user([old_env / 'bin/aim-web', '--config', check_config, 'check', '--without-db', '--without-core'])
current_snapshot = deployment.snapshot(latest)
write_json(PENDING, {'backup':current_snapshot.name,'phase':'manual-rollback',
'scripts':latest['scripts'],'user':latest['user'],'required_gid':latest['required_gid']})
try:
deployment.restore(selected, database=args.restore_auth_db, config=True)
PENDING.unlink(missing_ok=True)
print('Rollback completed. Release configuration restored. All WebGUI sessions revoked.')
print('Auth database restored from snapshot.' if args.restore_auth_db else 'Current users and passwords retained.')
print('Undo-rollback snapshot:', current_snapshot.name)
except BaseException:
deployment.restore(current_snapshot, database=args.restore_auth_db, config=True)
PENDING.unlink(missing_ok=True)
raise
def recover():
if not PENDING.is_file():
raise ValueError('No interrupted deployment journal exists.')
pending = json.loads(PENDING.read_text())
snapshot = BACKUPS / pending['backup']
record = json.loads((snapshot / 'snapshot.json').read_text())
deployment = Deployment(Path(pending['scripts']), pending['user'], pending['required_gid'])
deployment.restore(snapshot, database=bool(record['previous']), config=True)
PENDING.unlink()
print('Interrupted deployment recovered. AIM was not changed.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['install','update','rollback','recover'])
parser.add_argument('--aim-scripts', type=Path, default=Path('/etc/ansible/scripts'))
parser.add_argument('--service-user', default='aim-web')
parser.add_argument('--executor-user',default='svc_bf-ansible',help='Existing authorized non-root key-owning AIM account; never created or modified.')
parser.add_argument('--aimctl',type=Path,default=Path('/usr/local/bin/aimctl'))
parser.add_argument('--core-config',type=Path,default=Path('/etc/ansible/scripts/aim.yml'))
parser.add_argument('--migrate-core',action='store_true',help='Acknowledge 1.x to 2.x API/state/service migration; read MIGRATION-3.1.md first.')
parser.add_argument('--python', default=sys.executable)
parser.add_argument('--wheelhouse', type=Path, help='Offline wheels for this Python/OS/architecture, including build dependencies.')
parser.add_argument('--assets-dir', type=Path, help='Offline pinned bootstrap.min.css and htmx.min.js.')
parser.add_argument('--backup', help='Rollback snapshot identifier.')
parser.add_argument('--restore-auth-db', action='store_true', help='DESTRUCTIVE: discard account changes since selected snapshot.')
args = parser.parse_args()
if os.geteuid() != 0:
parser.exit(1, 'Run deployment with sudo/root. Runtime will use an unprivileged account.\n')
if not Path('/run/systemd/system').is_dir():
parser.exit(1, 'Managed deployment requires Linux/systemd. See manual installation instructions.\n')
os.umask(0o077)
for location in (PREFIX, BACKUPS, STATE):
safe_absolute(location)
PREFIX.mkdir(mode=0o755, parents=True, exist_ok=True)
if PREFIX.stat().st_uid != 0:
parser.exit(1, 'The managed /opt/aim-web directory must be root-owned.\n')
PREFIX.chmod(0o755)
BACKUPS.mkdir(mode=0o700, parents=True, exist_ok=True)
if BACKUPS.stat().st_uid != 0:
parser.exit(1, 'The WebGUI backup directory must be root-owned.\n')
BACKUPS.chmod(0o700)
lock = os.open(PREFIX / '.deploy.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if PENDING.exists() and args.command != 'recover':
raise ValueError('Interrupted deployment detected in /opt/aim-web/pending.json. Recover before retrying.')
if args.command == 'recover':
recover()
elif args.command == 'rollback':
rollback(args)
else:
install(args)
except Exception as exc:
parser.exit(1, f'Deployment stopped: {exc}\n')
finally:
os.close(lock)
if __name__ == '__main__':
main()
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fetch pinned upstream assets ONCE; browsers only load local copies.
Supply --from-directory for air-gapped deployment. Integrity checks are identical.
No npm, Node, CDN requests from the browser, or mutable 'latest' URLs.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import os
from pathlib import Path
import tempfile
from urllib.request import urlopen
ASSETS = {
'bootstrap.min.css': (
'https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css',
'sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB'),
'htmx.min.js': (
'https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js',
'H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V'),
}
def valid(data: bytes, expected: str) -> bool:
return base64.b64encode(hashlib.sha384(data).digest()).decode('ascii') == expected
def prepare(destination: Path, offline: Path | None = None, *, reuse: Path | None = None) -> None:
destination.mkdir(parents=True, exist_ok=True)
for name, (url, integrity) in ASSETS.items():
target = destination / name
if target.is_file() and valid(target.read_bytes(), integrity):
print('Verified local asset:', name)
continue
if offline:
data = (offline / name).read_bytes()
elif reuse and (reuse / name).is_file() and not (reuse / name).is_symlink():
candidate = (reuse / name).read_bytes()
if valid(candidate, integrity):
data = candidate
print('Reusing verified installed asset:', name)
else:
with urlopen(url, timeout=30) as response:
data = response.read(2_000_001)
else:
with urlopen(url, timeout=30) as response:
data = response.read(2_000_001)
if len(data) > 2_000_000 or not valid(data, integrity):
raise ValueError(f'Upstream integrity mismatch: {name}. No asset was installed.')
fd, filename = tempfile.mkstemp(prefix='.asset-', dir=destination)
temporary = Path(filename)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
temporary.chmod(0o644)
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
print('Installed verified local asset:', name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--from-directory', type=Path)
parser.add_argument('--destination', type=Path, default=Path(__file__).resolve().parents[1] / 'src/aim_webgui/static/vendor')
args = parser.parse_args()
try:
prepare(args.destination, args.from_directory)
except Exception as exc:
parser.exit(1, f'Assets not prepared: {exc}\nUse --from-directory with the exact pinned upstream files for offline installation.\n')
if __name__ == '__main__':
main()
@@ -0,0 +1,12 @@
# NPM header reference only; not installed automatically.
# Default 2.0 path: HTTPS 192.168.20.46:8443 -> host nginx -> loopback 8080.
# See docs/EXECUTION.md for separate upstream CA verification requirements.
# Reference only. Nginx Proxy Manager normally generates the server block.
# AIM WebGUI does not require WebSockets.
# Configure webgui.toml public_url to the exact HTTPS browser origin.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
@@ -0,0 +1,65 @@
# AIM WebGUI 2.1.0rc9 release-managed configuration
# Replaced on every managed install/update/rollback.
# AIM core configuration is never modified.
[server]
host = "0.0.0.0"
port = 8080
public_url = "https://aim.desq-gaming.de"
[proxy]
proxy_headers = true
forwarded_allow_ips = ["192.168.20.3"]
[session]
session_hours = 8
idle_minutes = 30
[aim]
scripts_path = "/etc/ansible/scripts"
# Public core protocol, not an Ansible executable or private source import.
[core]
transport = "unix"
command = ["/usr/local/bin/aimctl"]
config = "/etc/ansible/scripts/aim.yml"
socket = "/run/aim-web-executor/core.sock"
executor_user = "svc_bf-ansible"
client_user = "aim-web"
# Dedicated writable process HOME for native caches; SSH trust uses effective SSH configuration.
home = "/var/lib/aim-web-executor"
[state]
state_dir = "/var/lib/aim/webgui"
# Alternative browsing-only direct NPM -> application profile; do not use it to carry infrastructure credentials.
# This release does not create, replace or renew the separately installed TLS certificates.
[execution]
enabled = false
playbooks = [
"checkmk_install_agent",
"checkmk_update_scripts_config",
"checkmk_read_windows_config",
"checkmk_cleanup_scripts",
"debug_test_connection",
"debug_show_disk_usage",
"debug_detect_host_roles",
"maintenance_export_event_logs",
"maintenance_start_stopped_services",
"maintenance_patch_os",
"maintenance_reboot_hosts",
"sophos_apply_baseline",
"sophos_apply_customer",
"pfsense_apply_baseline",
]
max_hosts = 25
timeout_seconds = 1800
require_approval = false
# WebGUI policy only; core addons.execution_enabled is a separate operator opt-in.
# Direct NPM -> application HTTP is browsing-only until authenticated transport is separately designed.
transport_verified = false
window_start_hour = 0
window_end_hour = 24
[credentials]
enabled = false
@@ -0,0 +1,75 @@
# AIM WebGUI 2.1.0rc9 release-managed configuration
# Replaced on every managed install/update/rollback.
# AIM core configuration is never modified.
[server]
host = "127.0.0.1"
port = 8080
public_url = "https://aim.desq-gaming.de"
[proxy]
proxy_headers = true
forwarded_allow_ips = ["127.0.0.1"]
[session]
session_hours = 8
idle_minutes = 30
[aim]
scripts_path = "/etc/ansible/scripts"
# Public core protocol, not an Ansible executable or private source import.
[core]
transport = "unix"
command = ["/usr/local/bin/aimctl"]
config = "/etc/ansible/scripts/aim.yml"
socket = "/run/aim-web-executor/core.sock"
executor_user = "svc_bf-ansible"
client_user = "aim-web"
# Dedicated writable process HOME for native caches; SSH trust uses effective SSH configuration.
home = "/var/lib/aim-web-executor"
[state]
state_dir = "/var/lib/aim/webgui"
# Browser -> NPM 192.168.20.3 -> HTTPS 192.168.20.46:8443 -> local nginx -> 127.0.0.1:8080.
# This release does not create, replace or renew the separately installed TLS certificates.
[execution]
enabled = true
playbooks = [
"checkmk_install_agent",
"checkmk_update_scripts_config",
"checkmk_read_windows_config",
"checkmk_cleanup_scripts",
"debug_test_connection",
"debug_show_disk_usage",
"debug_detect_host_roles",
"maintenance_export_event_logs",
"maintenance_start_stopped_services",
"maintenance_patch_os",
"maintenance_reboot_hosts",
"sophos_apply_baseline",
"sophos_apply_customer",
"pfsense_apply_baseline",
]
max_hosts = 25
timeout_seconds = 1800
require_approval = false
# WebGUI policy only; core addons.execution_enabled is a separate operator opt-in.
# Operator attestation for this deployment profile: backend CA trust has been established.
transport_verified = true
window_start_hour = 0
window_end_hour = 24
[credentials]
enabled = true
# Retained public metadata, not raw Ansible stdout/stderr. Deleted with the job.
[journal]
max_events = 20000
max_bytes = 8388608
[reports]
max_bytes = 16777216
# Expanded configuration content retention requires explicit operator opt-in.
retain_configuration = false