diff --git a/Borealis.ps1 b/Borealis.ps1 index 2ad258b..808c9c3 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -121,6 +121,40 @@ function Write-AgentLog { "[$ts] $Message" | Out-File -FilePath $path -Append -Encoding UTF8 } +$script:Utf8CodePageChanged = $false + +function Ensure-SystemUtf8CodePage { + param([string]$LogName = 'Install.log') + + $codePageKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' + $target = '65001' + try { + $props = Get-ItemProperty -Path $codePageKey -ErrorAction Stop + $currentAcp = ($props.ACP | ForEach-Object { $_.ToString() }) + $currentOem = ($props.OEMCP | ForEach-Object { $_.ToString() }) + Write-AgentLog -FileName $LogName -Message ("[UTF8] Detected ACP={0} OEMCP={1}" -f $currentAcp,$currentOem) + } catch { + Write-AgentLog -FileName $LogName -Message ("[UTF8] Failed to read code page info: {0}" -f $_.Exception.Message) + return + } + + if ($currentAcp -eq $target -and $currentOem -eq $target) { + Write-AgentLog -FileName $LogName -Message '[UTF8] System code pages already set to 65001 (UTF-8).' + return + } + + Write-AgentLog -FileName $LogName -Message '[UTF8] Updating system code pages to UTF-8 (65001). Requires reboot to finalize.' + try { + Set-ItemProperty -Path $codePageKey -Name 'ACP' -Value $target -Force + Set-ItemProperty -Path $codePageKey -Name 'OEMCP' -Value $target -Force + try { Set-ItemProperty -Path $codePageKey -Name 'MACCP' -Value $target -Force } catch {} + $script:Utf8CodePageChanged = $true + Write-AgentLog -FileName $LogName -Message '[UTF8] Code page registry values updated successfully.' + } catch { + Write-AgentLog -FileName $LogName -Message ("[UTF8] Failed to update code pages: {0}" -f $_.Exception.Message) + } +} + # Forcefully remove legacy and current Borealis services and tasks function Remove-BorealisServicesAndTasks { param([string]$LogName) @@ -428,6 +462,7 @@ function InstallOrUpdate-BorealisAgent { $env:PATH = '{0};{1}' -f (Split-Path $pythonExe), $env:PATH Write-Host "Cleaning previous agent tasks/processes..." -ForegroundColor Yellow Remove-BorealisServicesAndTasks -LogName 'Install.log' + Ensure-SystemUtf8CodePage -LogName 'Install.log' Write-Host "Deploying Borealis Agent..." -ForegroundColor Blue # Resolve all paths relative to the script directory to avoid CWD issues @@ -479,6 +514,27 @@ function InstallOrUpdate-BorealisAgent { if (Test-Path $agentRequirements) { & $venvPython -m pip install --disable-pip-version-check -q -r $agentRequirements | Out-Null } + + $stubSource = Join-Path $agentSourceRoot 'fcntl_stub.py' + if (Test-Path $stubSource) { + $stubDest = Join-Path $venvFolderPath 'Lib\site-packages\fcntl.py' + Write-AgentLog -FileName 'Install.log' -Message '[UTF8] Ensuring Windows fcntl shim is installed.' + Copy-Item $stubSource $stubDest -Force + } + + $termiosSource = Join-Path $agentSourceRoot 'termios_stub.py' + if (Test-Path $termiosSource) { + $termiosDest = Join-Path $venvFolderPath 'Lib\site-packages\termios.py' + Write-AgentLog -FileName 'Install.log' -Message '[UTF8] Ensuring Windows termios shim is installed.' + Copy-Item $termiosSource $termiosDest -Force + } + + $siteCustomSource = Join-Path $agentSourceRoot 'sitecustomize.py' + if (Test-Path $siteCustomSource) { + $siteCustomDest = Join-Path $venvFolderPath 'Lib\site-packages\sitecustomize.py' + Write-AgentLog -FileName 'Install.log' -Message '[UTF8] Ensuring sitecustomize shim is installed.' + Copy-Item $siteCustomSource $siteCustomDest -Force + } } Run-Step "Configure Agent Settings" { @@ -512,6 +568,11 @@ function InstallOrUpdate-BorealisAgent { Write-Host "`nConfiguring Borealis Agent (tasks)..." -ForegroundColor Blue Write-Host "====================================================================================" Ensure-AgentTasks -ScriptRoot $scriptDir + if ($script:Utf8CodePageChanged) { + $msg = 'System code pages set to UTF-8. A reboot is required before Ansible can run.' + Write-AgentLog -FileName 'Install.log' -Message ("[UTF8] {0}" -f $msg) + Write-Host "`n$msg" -ForegroundColor Yellow + } } # ---------------------- Main ---------------------- diff --git a/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 b/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 index 3494662..7071726 100644 --- a/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 +++ b/Data/Agent/Roles/Borealis.WinRM.Localhost.psm1 @@ -35,7 +35,10 @@ function Ensure-LocalhostWinRMHttps { # Harden auth and encryption try { winrm set winrm/config/service/auth @{Basic="false"; Kerberos="true"; Negotiate="true"; CredSSP="false"} | Out-Null } catch {} try { winrm set winrm/config/service @{AllowUnencrypted="false"} | Out-Null } catch {} + try { winrm set winrm/config/service @{AllowFreshCredentialsWhenNTLMOnly="true"} | Out-Null } catch {} + try { winrm set winrm/config/service @{AllowCredSspAuthentication="false"} | Out-Null } catch {} try { winrm set winrm/config/service @{IPv4Filter="127.0.0.1"} | Out-Null } catch {} + try { New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name 'LocalAccountTokenFilterPolicy' -PropertyType DWord -Value 1 -Force | Out-Null } catch {} } function Ensure-BorealisServiceUser { @@ -62,6 +65,18 @@ function Ensure-BorealisServiceUser { Add-LocalGroupMember -Group "Administrators" -Member $localName -ErrorAction SilentlyContinue } } catch {} + $legacy = 'svcBorealisAnsibleRunner' + if ($localName -ne $legacy) { + try { + $legacyUser = Get-LocalUser -Name $legacy -ErrorAction SilentlyContinue + if ($legacyUser) { + try { Remove-LocalGroupMember -Group "Administrators" -Member $legacy -ErrorAction SilentlyContinue } catch {} + try { Disable-LocalUser -Name $legacy -ErrorAction SilentlyContinue } catch {} + try { Remove-LocalUser -Name $legacy -ErrorAction SilentlyContinue } catch {} + try { cmd /c "net user $legacy /DELETE" | Out-Null } catch {} + } + } catch {} + } } function Write-LocalInventory { diff --git a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py index 3a3152f..be9e18b 100644 --- a/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py +++ b/Data/Agent/Roles/role_PlaybookExec_SYSTEM.py @@ -7,12 +7,15 @@ import time import json import socket import subprocess +from typing import Optional try: import winrm # type: ignore except Exception: winrm = None +DEFAULT_SERVICE_ACCOUNT = '.\\svcBorealis' +LEGACY_SERVICE_ACCOUNTS = {'.\\svcBorealisAnsibleRunner', 'svcBorealisAnsibleRunner'} ROLE_NAME = 'playbook_exec_system' ROLE_CONTEXTS = ['system'] @@ -166,21 +169,24 @@ class Role: payload = { 'agent_id': self.ctx.agent_id, 'hostname': socket.gethostname(), - 'username': '.\\svcBorealisAnsibleRunner', + 'username': DEFAULT_SERVICE_ACCOUNT, } self._ansible_log(f"[checkin] POST {url} agent_id={self.ctx.agent_id}") timeout = aiohttp.ClientTimeout(total=15) async with aiohttp.ClientSession(timeout=timeout) as sess: async with sess.post(url, json=payload) as resp: js = await resp.json() - u = (js or {}).get('username') or '.\\svcBorealisAnsibleRunner' + u = (js or {}).get('username') or DEFAULT_SERVICE_ACCOUNT p = (js or {}).get('password') or '' + if u in LEGACY_SERVICE_ACCOUNTS: + self._ansible_log(f"[checkin] legacy service username {u!r}; requesting rotate", error=True) + return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT) self._svc_creds = {'username': u, 'password': p} self._ansible_log(f"[checkin] received user={u} pw_len={len(p)}") return self._svc_creds except Exception: self._ansible_log(f"[checkin] failed agent_id={self.ctx.agent_id}", error=True) - return {'username': '.\\svcBorealisAnsibleRunner', 'password': ''} + return {'username': DEFAULT_SERVICE_ACCOUNT, 'password': ''} def _normalize_playbook_content(self, content: str) -> str: try: @@ -202,21 +208,28 @@ class Role: except Exception: return content - async def _rotate_service_creds(self) -> dict: + async def _rotate_service_creds(self, reason: str = 'bad_credentials', force_username: Optional[str] = None) -> dict: try: import aiohttp url = self._server_base().rstrip('/') + '/api/agent/service-account/rotate' payload = { 'agent_id': self.ctx.agent_id, - 'reason': 'bad_credentials', + 'reason': reason, } + if force_username: + payload['username'] = force_username self._ansible_log(f"[rotate] POST {url} agent_id={self.ctx.agent_id}") timeout = aiohttp.ClientTimeout(total=15) async with aiohttp.ClientSession(timeout=timeout) as sess: async with sess.post(url, json=payload) as resp: js = await resp.json() - u = (js or {}).get('username') or '.\\svcBorealisAnsibleRunner' + u = (js or {}).get('username') or force_username or DEFAULT_SERVICE_ACCOUNT p = (js or {}).get('password') or '' + if u in LEGACY_SERVICE_ACCOUNTS and force_username != DEFAULT_SERVICE_ACCOUNT: + self._ansible_log(f"[rotate] legacy username {u!r} returned; retrying with default", error=True) + return await self._rotate_service_creds(reason='legacy_username', force_username=DEFAULT_SERVICE_ACCOUNT) + if u in LEGACY_SERVICE_ACCOUNTS: + u = DEFAULT_SERVICE_ACCOUNT self._svc_creds = {'username': u, 'password': p} self._ansible_log(f"[rotate] received user={u} pw_len={len(p)}") return self._svc_creds @@ -384,7 +397,7 @@ try {{ self._ansible_log(f"[runner] prepared playbook={play_abs} bytes={len(_norm.encode('utf-8'))}") # WinRM service account credentials creds = await self._fetch_service_creds() - user = creds.get('username') or '.\\svcBorealisAnsibleRunner' + user = creds.get('username') or DEFAULT_SERVICE_ACCOUNT pwd = creds.get('password') or '' # Converge endpoint state (listener + user) self._ensure_winrm_and_user(user, pwd) @@ -392,7 +405,7 @@ try {{ pre_ok = self._winrm_preflight(user, pwd) if not pre_ok: # rotate and retry once - creds = await self._rotate_service_creds() + creds = await self._rotate_service_creds(reason='winrm_preflight_failure') user = creds.get('username') or user pwd = creds.get('password') or '' self._ensure_winrm_and_user(user, pwd) @@ -508,7 +521,7 @@ try {{ # If authentication failed on first pass, rotate password and try once more if auth_failed: try: - newc = await self._rotate_service_creds() + newc = await self._rotate_service_creds(reason='auth_failed_retry') user2 = newc.get('username') or user pwd2 = newc.get('password') or '' self._ensure_winrm_and_user(user2, pwd2) @@ -551,11 +564,11 @@ try {{ if os.name == 'nt': try: creds = await self._fetch_service_creds() - user = creds.get('username') or '.\\svcBorealisAnsibleRunner' + user = creds.get('username') or DEFAULT_SERVICE_ACCOUNT pwd = creds.get('password') or '' self._ensure_winrm_and_user(user, pwd) if not self._winrm_preflight(user, pwd): - creds = await self._rotate_service_creds() + creds = await self._rotate_service_creds(reason='winrm_preflight_failure') user = creds.get('username') or user pwd = creds.get('password') or '' self._ensure_winrm_and_user(user, pwd) @@ -576,7 +589,7 @@ try {{ use_module = True if use_module: py = _venv_python() or sys.executable - base_cmd = [py, '-m', 'ansible.cli.playbook'] + base_cmd = [py, '-X', 'utf8', '-m', 'ansible.cli.playbook'] self._ansible_log(f"[cli] ansible-playbook not found; using python -m ansible.cli.playbook via {py}") else: base_cmd = [ap] @@ -593,21 +606,23 @@ try {{ self._ansible_log(f"[cli] cmd={' '.join(cmd)}") # Ensure clean, plain output and correct interpreter for localhost env = os.environ.copy() - env.setdefault('ANSIBLE_FORCE_COLOR', '0') - env.setdefault('ANSIBLE_NOCOLOR', '1') - env.setdefault('PYTHONIOENCODING', 'utf-8') - env.setdefault('PYTHONUTF8', '1') + env['ANSIBLE_FORCE_COLOR'] = '0' + env['ANSIBLE_NOCOLOR'] = '1' + env['PYTHONIOENCODING'] = 'utf-8' + env['PYTHONUTF8'] = '1' + env['ANSIBLE_STDOUT_CALLBACK'] = 'default' + env['ANSIBLE_LOCALHOST_WARNING'] = '0' + env['ANSIBLE_COLLECTIONS_PATHS'] = _collections_dir() if os.name == 'nt': - env.setdefault('LANG', 'en_US.UTF-8') - env.setdefault('ANSIBLE_STDOUT_CALLBACK', 'default') - # Help Ansible pick the correct python for localhost - env.setdefault('ANSIBLE_LOCALHOST_WARNING', '0') - # Ensure collections path is discoverable - env.setdefault('ANSIBLE_COLLECTIONS_PATHS', _collections_dir()) + env['LANG'] = 'en_US.UTF-8' + env['LC_ALL'] = 'en_US.UTF-8' + env['LC_CTYPE'] = 'en_US.UTF-8' vp = _venv_python() if vp: env.setdefault('ANSIBLE_PYTHON_INTERPRETER', vp) + self._ansible_log(f"[cli] locale env overrides: PYTHONUTF8={env.get('PYTHONUTF8')} LANG={env.get('LANG')} LC_ALL={env.get('LC_ALL')} LC_CTYPE={env.get('LC_CTYPE')}") + creationflags = 0 if os.name == 'nt': # CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW @@ -751,7 +766,7 @@ try {{ if os.name != 'nt': return creds = await self._fetch_service_creds() - user = creds.get('username') or '.\\svcBorealisAnsibleRunner' + user = creds.get('username') or DEFAULT_SERVICE_ACCOUNT pwd = creds.get('password') or '' self._ansible_log(f"[bootstrap] ensure winrm+user user={user} pw_len={len(pwd)}") self._ensure_winrm_and_user(user, pwd) @@ -759,7 +774,7 @@ try {{ self._ansible_log(f"[bootstrap] preflight_ok={ok}") if not ok: self._ansible_log("[bootstrap] preflight failed; rotating creds", error=True) - creds = await self._rotate_service_creds() + creds = await self._rotate_service_creds(reason='bootstrap_preflight_failed') user = creds.get('username') or user pwd = creds.get('password') or '' self._ensure_winrm_and_user(user, pwd) diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 62c4473..86e98ac 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -1178,7 +1178,7 @@ async def connect(): async def _svc_checkin_once(): try: url = get_server_url().rstrip('/') + "/api/agent/checkin" - payload = {"agent_id": AGENT_ID, "hostname": socket.gethostname(), "username": ".\\svcBorealisAnsibleRunner"} + payload = {"agent_id": AGENT_ID, "hostname": socket.gethostname(), "username": ".\\svcBorealis"} timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession(timeout=timeout) as session: await session.post(url, json=payload) diff --git a/Data/Agent/fcntl_stub.py b/Data/Agent/fcntl_stub.py new file mode 100644 index 0000000..fe18a72 --- /dev/null +++ b/Data/Agent/fcntl_stub.py @@ -0,0 +1,90 @@ +"""Windows compatibility layer for the POSIX fcntl module.""" +from __future__ import annotations + +import logging +import msvcrt +import os +import shutil +import struct +from typing import Any, Dict, Tuple + +log = logging.getLogger(__name__) +log.debug("Borealis fcntl shim active (Windows)") + +F_DUPFD = 0 +F_GETFD = 1 +F_SETFD = 2 +F_GETFL = 3 +F_SETFL = 4 + +LOCK_UN = 8 +LOCK_SH = 1 +LOCK_EX = 2 +LOCK_NB = 4 + +__fd_flags: Dict[int, int] = {} +__locks: Dict[int, Tuple[int, int]] = {} + + +def _winsize() -> bytes: + rows, cols = shutil.get_terminal_size(fallback=(24, 80)) + rows = max(1, int(rows)) + cols = max(1, int(cols)) + return struct.pack("HHHH", rows, cols, 0, 0) + + +def ioctl(fd: int, op: int, arg: Any = 0) -> bytes: + return _winsize() + + +def fcntl(fd: int, op: int, arg: Any = 0) -> Any: + if op == F_GETFL: + return __fd_flags.get(fd, 0) + if op == F_SETFL: + try: + __fd_flags[fd] = int(arg) + except Exception: + __fd_flags[fd] = 0 + return 0 + return arg + + +def flock(fd: int, op: int) -> None: + if op & LOCK_UN: + if fd in __locks: + _apply_lock(fd, msvcrt.LK_UNLCK, *__locks.pop(fd)) + return + + length = 1 + nb = bool(op & LOCK_NB) + mode = msvcrt.LK_LOCK if not nb else msvcrt.LK_NBLCK + try: + _apply_lock(fd, mode, 0, length) + __locks[fd] = (0, length) + except OSError: + raise + + +def lockf(fd: int, cmd: int, length: int = 0) -> None: + if length <= 0: + length = 1 + if cmd & LOCK_UN: + if fd in __locks: + _apply_lock(fd, msvcrt.LK_UNLCK, *__locks.pop(fd)) + return + nb = bool(cmd & LOCK_NB) + mode = msvcrt.LK_LOCK if not nb else msvcrt.LK_NBLCK + _apply_lock(fd, mode, 0, length) + __locks[fd] = (0, length) + + +def _apply_lock(fd: int, mode: int, offset: int, length: int) -> None: + cur = os.lseek(fd, 0, os.SEEK_CUR) + os.lseek(fd, offset, os.SEEK_SET) + try: + msvcrt.locking(fd, mode, length) + finally: + os.lseek(fd, cur, os.SEEK_SET) + + +__all__ = [name for name in globals() if name.isupper() or name in {'ioctl', 'fcntl', 'flock', 'lockf'}] diff --git a/Data/Agent/sitecustomize.py b/Data/Agent/sitecustomize.py new file mode 100644 index 0000000..03c2b86 --- /dev/null +++ b/Data/Agent/sitecustomize.py @@ -0,0 +1,25 @@ +"""Borealis controller shims executed on interpreter startup.""" +from __future__ import annotations + +import logging +import multiprocessing + +log = logging.getLogger(__name__) + +try: + multiprocessing.set_start_method('spawn') +except RuntimeError: + pass + +try: + mp_ctx = multiprocessing.get_context('fork') +except ValueError: + try: + import ansible.utils.multiprocessing as ansible_mp # type: ignore + except Exception: + log.debug('Borealis sitecustomize: ansible multiprocessing module missing; skipping fork patch') + else: + ansible_mp.context = multiprocessing.get_context('spawn') + log.debug('Borealis sitecustomize: patched ansible multiprocessing context to spawn') +else: + log.debug('Borealis sitecustomize: fork context available (%s)', mp_ctx) diff --git a/Data/Agent/termios_stub.py b/Data/Agent/termios_stub.py new file mode 100644 index 0000000..a9a463a --- /dev/null +++ b/Data/Agent/termios_stub.py @@ -0,0 +1,121 @@ +"""Windows compatibility shim for termios used by Ansible.""" +from __future__ import annotations + +import logging +from typing import Iterable, List + +log = logging.getLogger(__name__) +log.debug("Borealis termios shim active (Windows)") + +NCCS = 32 + +VINTR = 0 +VQUIT = 1 +VERASE = 2 +VKILL = 3 +VEOF = 4 +VTIME = 5 +VMIN = 6 +VSTART = 8 +VSTOP = 9 +VSUSP = 10 +VEOL = 11 +VREPRINT = 12 +VDISCARD = 13 +VWERASE = 14 +VLNEXT = 15 +VEOL2 = 16 + +IGNBRK = 0x0001 +BRKINT = 0x0002 +IGNPAR = 0x0004 +PARMRK = 0x0008 +INPCK = 0x0010 +ISTRIP = 0x0020 +INLCR = 0x0040 +IGNCR = 0x0080 +ICRNL = 0x0100 +IXON = 0x0400 +IXOFF = 0x1000 + +OPOST = 0x0001 +ONLCR = 0x0004 +OCRNL = 0x0008 +ONOCR = 0x0010 +ONLRET = 0x0020 +OFILL = 0x0040 + +CSIZE = 0x0030 +CS8 = 0x0030 +CSTOPB = 0x0040 +CREAD = 0x0080 +PARENB = 0x0100 +PARODD = 0x0200 +CLOCAL = 0x0800 + +ISIG = 0x0001 +ICANON = 0x0002 +ECHO = 0x0008 +ECHOE = 0x0010 +ECHOK = 0x0020 +ECHONL = 0x0040 +IEXTEN = 0x0200 + +TCSANOW = 0 +TCSADRAIN = 1 +TCSAFLUSH = 2 + +_default_cc: List[int] = [0] * NCCS + + +def _make_termios() -> list: + return [0, 0, 0, 0, 0, 0, _default_cc.copy()] + + +def tcgetattr(fd: int) -> list: + return _make_termios() + + +def tcsetattr(fd: int, when: int, attributes: Iterable) -> None: + return None + + +def cfgetospeed(attrs: Iterable) -> int: + return 9600 + + +def cfgetispeed(attrs: Iterable) -> int: + return 9600 + + +def cfsetospeed(attrs: Iterable, speed: int) -> None: + return None + + +def cfsetispeed(attrs: Iterable, speed: int) -> None: + return None + + +def tcflush(fd: int, queue: int) -> None: + return None + + +def tcflow(fd: int, action: int) -> None: + return None + + +def tcsendbreak(fd: int, duration: int) -> None: + return None + + +def tcdrain(fd: int) -> None: + return None + + +def tcgetsid(fd: int) -> int: + raise OSError("tcgetsid not supported on Windows") + +__all__ = [name for name in globals() if name.isupper() or name in { + 'tcgetattr', 'tcsetattr', 'cfgetospeed', 'cfgetispeed', 'cfsetospeed', + 'cfsetispeed', 'tcflush', 'tcflow', 'tcsendbreak', 'tcdrain', 'tcgetsid' +}] diff --git a/Data/Server/server.py b/Data/Server/server.py index 8844db3..0ebf6e6 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -69,6 +69,9 @@ def _write_service_log(service: str, msg: str): def _ansible_log_server(msg: str): _write_service_log('ansible', msg) +DEFAULT_SERVICE_ACCOUNT = '.\\svcBorealis' +LEGACY_SERVICE_ACCOUNTS = {'.\\svcBorealisAnsibleRunner', 'svcBorealisAnsibleRunner'} + # Borealis Python API Endpoints from Python_API_Endpoints.ocr_engines import run_ocr_on_base64 from Python_API_Endpoints.script_engines import run_powershell_script @@ -2906,6 +2909,9 @@ def _service_acct_get(conn, agent_id: str): def _service_acct_set(conn, agent_id: str, username: str, plaintext_password: str): + username = (username or '').strip() + if not username or username in LEGACY_SERVICE_ACCOUNTS: + username = DEFAULT_SERVICE_ACCOUNT enc = _encrypt_secret(plaintext_password) now_utc = _now_iso_utc() cur = conn.cursor() @@ -2929,15 +2935,17 @@ def _service_acct_set(conn, agent_id: str, username: str, plaintext_password: st } + @app.route('/api/agent/checkin', methods=['POST']) def api_agent_checkin(): payload = request.get_json(silent=True) or {} agent_id = (payload.get('agent_id') or '').strip() if not agent_id: return jsonify({'error': 'agent_id required'}), 400 - username = (payload.get('username') or '.\\svcBorealisAnsibleRunner').strip() - # Optional hostname here for future auditing/joins - # Upsert service account, creating new creds if missing + raw_username = (payload.get('username') or '').strip() + username = raw_username or DEFAULT_SERVICE_ACCOUNT + if username in LEGACY_SERVICE_ACCOUNTS: + username = DEFAULT_SERVICE_ACCOUNT try: conn = _db_conn() row = _service_acct_get(conn, agent_id) @@ -2946,17 +2954,25 @@ def api_agent_checkin(): out = _service_acct_set(conn, agent_id, username, pw) _ansible_log_server(f"[checkin] created creds agent_id={agent_id} user={out['username']} rotated={out['last_rotated_utc']}") else: - # row: agent_id, username, password_encrypted, last_rotated_utc, version + stored_username = (row[1] or '').strip() try: plain = _decrypt_secret(row[2]) except Exception: plain = '' - if not plain: + if stored_username in LEGACY_SERVICE_ACCOUNTS: + if not plain: + plain = _gen_strong_password() + out = _service_acct_set(conn, agent_id, DEFAULT_SERVICE_ACCOUNT, plain) + _ansible_log_server(f"[checkin] upgraded legacy service user for agent_id={agent_id} -> {out['username']}") + elif not plain: plain = _gen_strong_password() - out = _service_acct_set(conn, agent_id, row[1] or username, plain) + out = _service_acct_set(conn, agent_id, stored_username or username, plain) else: + eff_user = stored_username or username + if eff_user in LEGACY_SERVICE_ACCOUNTS: + eff_user = DEFAULT_SERVICE_ACCOUNT out = { - 'username': row[1] or username, + 'username': eff_user, 'password': plain, 'last_rotated_utc': row[3] or _now_iso_utc(), } @@ -2978,11 +2994,17 @@ def api_agent_service_account_rotate(): agent_id = (payload.get('agent_id') or '').strip() if not agent_id: return jsonify({'error': 'agent_id required'}), 400 - username = (payload.get('username') or '.\\svcBorealisAnsibleRunner').strip() + requested_username = (payload.get('username') or '').strip() try: conn = _db_conn() row = _service_acct_get(conn, agent_id) - user_eff = row[1] if row else username + stored_username = '' + if row: + stored_username = (row[1] or '').strip() + user_eff = requested_username or stored_username or DEFAULT_SERVICE_ACCOUNT + if user_eff in LEGACY_SERVICE_ACCOUNTS: + user_eff = DEFAULT_SERVICE_ACCOUNT + _ansible_log_server(f"[rotate] upgrading legacy service user for agent_id={agent_id}") pw_new = _gen_strong_password() out = _service_acct_set(conn, agent_id, user_eff, pw_new) conn.close() @@ -2996,7 +3018,6 @@ def api_agent_service_account_rotate(): _ansible_log_server(f"[rotate] error agent_id={agent_id} err={e}") return jsonify({'error': str(e)}), 500 - @app.route("/api/ansible/recap/report", methods=["POST"]) def api_ansible_recap_report(): """Create or update an Ansible recap row for a running/finished playbook.