677 lines
40 KiB
Python
677 lines
40 KiB
Python
from pathlib import Path
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from .config import load_config
|
|
from .database import SessionLocal
|
|
from .models import Asset, SoftwareJob, JobEvent, JobDefinition
|
|
from .job_state import sync_asset_job_state
|
|
|
|
|
|
_DISPATCH_SLOT_LOCK = threading.Lock()
|
|
_NEXT_DISPATCH_MONOTONIC = 0.0
|
|
|
|
|
|
def _wait_for_dispatch_slot() -> tuple[float, float]:
|
|
"""Reserve the next global MeshCtrl dispatch slot.
|
|
|
|
Bulk operations currently create one lightweight worker thread per job.
|
|
Reserving slots here prevents all workers from opening database and
|
|
MeshCtrl connections at the same time while still allowing already
|
|
dispatched client scripts to run in parallel.
|
|
"""
|
|
global _NEXT_DISPATCH_MONOTONIC
|
|
settings = load_config().get("software", {})
|
|
try:
|
|
delay_seconds = float(settings.get("dispatch_delay_seconds", 2) or 0)
|
|
except (TypeError, ValueError):
|
|
delay_seconds = 2.0
|
|
delay_seconds = max(0.0, min(delay_seconds, 60.0))
|
|
if delay_seconds <= 0:
|
|
return 0.0, 0.0
|
|
|
|
with _DISPATCH_SLOT_LOCK:
|
|
now = time.monotonic()
|
|
reserved_at = max(now, _NEXT_DISPATCH_MONOTONIC)
|
|
wait_seconds = max(0.0, reserved_at - now)
|
|
_NEXT_DISPATCH_MONOTONIC = reserved_at + delay_seconds
|
|
|
|
if wait_seconds > 0:
|
|
time.sleep(wait_seconds)
|
|
return delay_seconds, wait_seconds
|
|
|
|
|
|
def token_hash(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def detect_platform(asset: Asset) -> str:
|
|
text = f"{asset.operating_system or ''} {asset.mesh_source_data or ''}".casefold()
|
|
if "windows" in text:
|
|
return "windows"
|
|
if "macos" in text or "mac os" in text or "darwin" in text:
|
|
return "macos"
|
|
if "android" in text:
|
|
return "android"
|
|
if any(x in text for x in ("linux", "ubuntu", "debian", "fedora", "centos", "red hat", "suse", "arch")):
|
|
return "linux"
|
|
return "unknown"
|
|
|
|
|
|
def powershell_payload(script: str) -> str:
|
|
"""Compatibility helper retained for older callers.
|
|
|
|
Since 0.5.5.0 scripts are uploaded as files and are no longer encoded into
|
|
the command line.
|
|
"""
|
|
return str(script or "")
|
|
|
|
|
|
def powershell_encoded(script: str) -> str:
|
|
return powershell_payload(script)
|
|
|
|
|
|
def windows_inventory(callback_url: str, job_id: int) -> str:
|
|
script = """
|
|
$ErrorActionPreference='Stop'
|
|
$log=Join-Path $env:ProgramData 'AssetManager\\commands.log'
|
|
New-Item -ItemType Directory -Force -Path (Split-Path $log)|Out-Null
|
|
$lines=New-Object System.Collections.Generic.List[string]
|
|
function L([string]$m){$x="$(Get-Date -Format o) job=JOBID $m";Add-Content -Path $log -Value $x -Encoding UTF8;$lines.Add($x)}
|
|
function Send-Callback([byte[]]$Payload){
|
|
$lastError=$null
|
|
for($attempt=1;$attempt -le 3;$attempt++){
|
|
try{
|
|
Invoke-RestMethod -Method Post -Uri 'CALLBACK' -ContentType 'application/json; charset=utf-8' -Body $Payload -TimeoutSec 60|Out-Null
|
|
if($attempt -gt 1){L ("callback accepted on attempt $attempt of 3")}
|
|
return
|
|
}catch{
|
|
$lastError=$_.Exception
|
|
L ("callback attempt $attempt of 3 failed: "+$_.Exception.Message)
|
|
if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}
|
|
}
|
|
}
|
|
throw ("callback failed after 3 attempts: "+$lastError.Message)
|
|
}
|
|
L 'software inventory started'
|
|
try {
|
|
$paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')
|
|
$software=Get-ItemProperty $paths -ErrorAction SilentlyContinue|Where-Object {$_.DisplayName}|ForEach-Object {
|
|
[PSCustomObject]@{name=[string]$_.DisplayName;version=[string]$_.DisplayVersion;publisher=[string]$_.Publisher;install_date=[string]$_.InstallDate;architecture=$(if($_.PSPath -like '*WOW6432Node*'){'x86'}else{'x64'})}
|
|
}|Sort-Object name,version -Unique
|
|
L ("software inventory completed; entries="+$software.Count)
|
|
$body=@{status='success';exit_code=0;completed=$true;software=$software;log=($lines -join "`n")}|ConvertTo-Json -Depth 6 -Compress
|
|
$bodyBytes=[System.Text.Encoding]::UTF8.GetBytes($body)
|
|
L 'sending callback'
|
|
Send-Callback $bodyBytes
|
|
L 'callback accepted'
|
|
}catch{
|
|
L ("software inventory failed: "+$_.Exception.Message)
|
|
$body=@{status='failed';exit_code=1;completed=$true;message=$_.Exception.Message;log=($lines -join "`n")}|ConvertTo-Json -Depth 4 -Compress
|
|
$bodyBytes=[System.Text.Encoding]::UTF8.GetBytes($body)
|
|
try{
|
|
Send-Callback $bodyBytes
|
|
}catch{
|
|
L ("callback failed: "+$_.Exception.Message)
|
|
}
|
|
exit 1
|
|
}
|
|
""".replace("JOBID", str(job_id)).replace("CALLBACK", callback_url)
|
|
return script
|
|
|
|
|
|
def unix_python_payload(platform: str, callback_url: str, job_id: int) -> str:
|
|
if platform == "macos":
|
|
collection = """
|
|
raw=subprocess.check_output(["system_profiler","SPApplicationsDataType","-json"],text=True,errors="replace")
|
|
data=json.loads(raw)
|
|
for x in data.get("SPApplicationsDataType",[]):
|
|
items.append({"name":x.get("_name",""),"version":x.get("version",""),"publisher":str(x.get("signed_by","")),"architecture":x.get("arch_kind",""),"install_date":None})
|
|
"""
|
|
log_path = "/Library/Logs/AssetManager-commands.log"
|
|
else:
|
|
collection = """
|
|
if os.path.exists("/usr/bin/dpkg-query"):
|
|
out=subprocess.check_output(["dpkg-query","-W","-f=${binary:Package}\\t${Version}\\t${Maintainer}\\n"],text=True,errors="replace")
|
|
for line in out.splitlines():
|
|
p=line.split("\\t");items.append({"name":p[0],"version":p[1] if len(p)>1 else "","publisher":p[2] if len(p)>2 else "","architecture":"","install_date":None})
|
|
elif os.path.exists("/usr/bin/rpm"):
|
|
out=subprocess.check_output(["rpm","-qa","--qf","%{NAME}\\t%{VERSION}-%{RELEASE}\\t%{VENDOR}\\t%{ARCH}\\n"],text=True,errors="replace")
|
|
for line in out.splitlines():
|
|
p=line.split("\\t");items.append({"name":p[0],"version":p[1] if len(p)>1 else "","publisher":p[2] if len(p)>2 else "","architecture":p[3] if len(p)>3 else "","install_date":None})
|
|
elif os.path.exists("/sbin/apk") or os.path.exists("/usr/sbin/apk"):
|
|
out=subprocess.check_output(["apk","info","-v"],text=True,errors="replace")
|
|
for line in out.splitlines():items.append({"name":line,"version":"","publisher":"","architecture":"","install_date":None})
|
|
"""
|
|
log_path = "/var/log/assetmanager-commands.log"
|
|
|
|
py = f"""import json,subprocess,urllib.request,datetime,os,time
|
|
log={log_path!r}
|
|
lines=[]
|
|
def add(m):
|
|
x=datetime.datetime.now(datetime.timezone.utc).isoformat()+" job={job_id} "+m
|
|
lines.append(x)
|
|
try:
|
|
with open(log,"a",encoding="utf-8") as f:f.write(x+"\\n")
|
|
except Exception:pass
|
|
add("software inventory started")
|
|
items=[]
|
|
try:
|
|
{textwrap_indent(collection, 4)}
|
|
add("software inventory completed; entries="+str(len(items)))
|
|
payload={{"status":"success","exit_code":0,"completed":True,"software":items,"log":"\\n".join(lines)}}
|
|
except Exception as e:
|
|
add("software inventory failed: "+str(e))
|
|
payload={{"status":"failed","exit_code":1,"completed":True,"message":str(e),"log":"\\n".join(lines)}}
|
|
callback_data=json.dumps(payload).encode()
|
|
last_error=None
|
|
for attempt in range(1,4):
|
|
req=urllib.request.Request({callback_url!r},data=callback_data,headers={{"Content-Type":"application/json"}},method="POST")
|
|
try:
|
|
urllib.request.urlopen(req,timeout=60).read()
|
|
break
|
|
except Exception as exc:
|
|
last_error=exc
|
|
add("callback attempt "+str(attempt)+" of 3 failed: "+str(exc))
|
|
if attempt < 3: time.sleep(2*attempt)
|
|
else:
|
|
raise RuntimeError("callback failed after 3 attempts: "+str(last_error))
|
|
"""
|
|
return py
|
|
|
|
|
|
def textwrap_indent(value: str, spaces: int) -> str:
|
|
prefix = " " * spaces
|
|
return "\n".join(prefix + line if line.strip() else line for line in value.strip().splitlines())
|
|
|
|
|
|
|
|
def _replace_job_placeholders(script: str, asset: Asset, callback_url: str, job_id: int, parameters: dict | None = None) -> str:
|
|
values={
|
|
'AssetName':asset.name or '', 'Hostname':asset.hostname or '', 'IPAddress':asset.ip_address or '',
|
|
'SerialNumber':asset.serial_number or '', 'MeshNodeId':asset.mesh_node_id or '',
|
|
'Manufacturer':asset.manufacturer or '', 'Model':asset.model or '',
|
|
'Department':asset.department or '', 'Location':asset.location or '',
|
|
'CurrentUser':asset.assigned_to or '',
|
|
'CallbackUrl':callback_url, 'JobId':str(job_id),
|
|
}
|
|
for key,value in values.items(): script=script.replace('{{'+key+'}}',str(value))
|
|
for key,value in (parameters or {}).items():
|
|
script=script.replace('{{Param.'+str(key)+'}}',str(value)).replace('{{'+str(key)+'}}',str(value))
|
|
return script
|
|
|
|
def build_registry_user_script(configuration: dict | None) -> str:
|
|
"""Build PowerShell user code for a declarative Windows Registry job."""
|
|
config=configuration or {}
|
|
entries=config.get("registry_entries") if isinstance(config,dict) else []
|
|
backup=bool(config.get("registry_backup")) if isinstance(config,dict) else False
|
|
hive_map={
|
|
"HKLM":"Registry::HKEY_LOCAL_MACHINE",
|
|
"HKCU":"Registry::HKEY_CURRENT_USER",
|
|
"HKCR":"Registry::HKEY_CLASSES_ROOT",
|
|
"HKU":"Registry::HKEY_USERS",
|
|
"HKCC":"Registry::HKEY_CURRENT_CONFIG",
|
|
}
|
|
type_map={"REG_SZ":"String","REG_EXPAND_SZ":"ExpandString","REG_MULTI_SZ":"MultiString","REG_DWORD":"DWord","REG_QWORD":"QWord","REG_BINARY":"Binary"}
|
|
lines=["Write-JobLog 'Registry job started'", "$registryResults = @()"]
|
|
if backup:
|
|
lines += [
|
|
"$backupRoot = Join-Path $env:ProgramData ('AssetManager\\RegistryBackups\\Job-' + '{{JobId}}')",
|
|
"New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null",
|
|
"Write-JobLog (\"Registry backup directory: $backupRoot\")",
|
|
]
|
|
for index,item in enumerate(entries if isinstance(entries,list) else [],1):
|
|
if not isinstance(item,dict): continue
|
|
action=str(item.get('action') or 'set')
|
|
hive=str(item.get('hive') or 'HKLM').upper()
|
|
path=str(item.get('path') or '').strip().strip('\\')
|
|
name=str(item.get('name') or '')
|
|
value_type=str(item.get('value_type') or 'REG_SZ').upper()
|
|
value=str(item.get('value') or '')
|
|
set_value=bool(item.get('set_value', True))
|
|
only_if_missing=bool(item.get('only_if_missing'))
|
|
permissions_enabled=bool(item.get('permissions_enabled'))
|
|
if hive not in hive_map or not path: continue
|
|
ps_path=hive_map[hive]+'\\'+path
|
|
reg_path=hive+'\\'+path
|
|
q=lambda x: "'"+x.replace("'","''")+"'"
|
|
lines.append(f"# Registry entry {index}")
|
|
lines.append(f"$registryPath = {q(ps_path)}")
|
|
lines.append(f"$registryValueName = {q(name)}")
|
|
if backup:
|
|
safe_name=str(index).zfill(3)+'-'+hive+'-'+path.replace('\\','_').replace(':','_')+'.reg'
|
|
lines += [
|
|
f"$backupFile = Join-Path $backupRoot {q(safe_name)}",
|
|
f"& reg.exe export {q(reg_path)} $backupFile /y 2>$null | Out-Null",
|
|
]
|
|
if action=='delete_key':
|
|
lines.append("if (Test-Path -LiteralPath $registryPath) { Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction Stop; Write-JobLog (\"Registry key deleted: $registryPath\"); $registryResults += @{path=$registryPath; action='delete_key'; status='changed'} } else { Write-JobLog (\"Registry key already absent: $registryPath\"); $registryResults += @{path=$registryPath; action='delete_key'; status='unchanged'} }")
|
|
continue
|
|
if action=='delete_value':
|
|
lines += [
|
|
"$existing = Get-ItemProperty -LiteralPath $registryPath -Name $registryValueName -ErrorAction SilentlyContinue",
|
|
"if ($null -ne $existing) { Remove-ItemProperty -LiteralPath $registryPath -Name $registryValueName -Force -ErrorAction Stop; Write-JobLog (\"Registry value deleted: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='delete_value'; status='changed'} } else { Write-JobLog (\"Registry value already absent: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='delete_value'; status='unchanged'} }"
|
|
]
|
|
elif action=='set':
|
|
lines.append("if (-not (Test-Path -LiteralPath $registryPath)) { New-Item -Path $registryPath -Force | Out-Null; Write-JobLog (\"Registry key created: $registryPath\") }")
|
|
if set_value:
|
|
property_type=type_map.get(value_type,'String')
|
|
lines.append(f"$registryRawValue = {q(value)}")
|
|
if value_type=='REG_MULTI_SZ':
|
|
lines.append("$registryTypedValue = @($registryRawValue -split \"`r?`n\")")
|
|
elif value_type=='REG_BINARY':
|
|
lines.append("$registryTypedValue = [byte[]](($registryRawValue -replace '[^0-9A-Fa-f]','' -split '(?<=\\G..)(?=.)') | Where-Object { $_ } | ForEach-Object { [Convert]::ToByte($_,16) })")
|
|
elif value_type=='REG_DWORD':
|
|
lines.append("$registryTypedValue = if ($registryRawValue -match '^0x') {[Convert]::ToUInt32($registryRawValue.Substring(2),16)} else {[Convert]::ToUInt32($registryRawValue)}")
|
|
elif value_type=='REG_QWORD':
|
|
lines.append("$registryTypedValue = if ($registryRawValue -match '^0x') {[Convert]::ToUInt64($registryRawValue.Substring(2),16)} else {[Convert]::ToUInt64($registryRawValue)}")
|
|
else:
|
|
lines.append("$registryTypedValue = $registryRawValue")
|
|
lines.append("$existingProperty = Get-ItemProperty -LiteralPath $registryPath -Name $registryValueName -ErrorAction SilentlyContinue")
|
|
if only_if_missing:
|
|
lines.append("if ($null -ne $existingProperty) { Write-JobLog (\"Registry value exists; skipped: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='set'; status='skipped'} } else {")
|
|
indent=' '
|
|
else:
|
|
indent=''
|
|
lines += [
|
|
indent+f"New-ItemProperty -LiteralPath $registryPath -Name $registryValueName -PropertyType {property_type} -Value $registryTypedValue -Force -ErrorAction Stop | Out-Null",
|
|
indent+"Write-JobLog (\"Registry value set: $registryPath\\$registryValueName\")",
|
|
indent+"$registryResults += @{path=$registryPath; name=$registryValueName; action='set'; status='changed'; type='"+value_type+"'}",
|
|
]
|
|
if only_if_missing: lines.append("}")
|
|
else:
|
|
lines.append("Write-JobLog (\"Registry key ensured without changing a value: $registryPath\")")
|
|
lines.append("$registryResults += @{path=$registryPath; action='ensure_key'; status='ready'}")
|
|
if permissions_enabled:
|
|
principal=str(item.get('principal') or 'S-1-5-32-545').strip()
|
|
rights=str(item.get('rights') or 'FullControl').strip()
|
|
access_type='Deny' if str(item.get('access_type') or 'Allow').lower()=='deny' else 'Allow'
|
|
inherit_subkeys=bool(item.get('inherit_subkeys', True))
|
|
replace_existing=bool(item.get('replace_existing', True))
|
|
lines += [
|
|
"if (-not (Test-Path -LiteralPath $registryPath)) { New-Item -Path $registryPath -Force | Out-Null; Write-JobLog (\"Registry key created for permissions: $registryPath\") }",
|
|
f"$registryPrincipal = {q(principal)}",
|
|
f"$registryRightsName = {q(rights)}",
|
|
f"$registryAccessTypeName = {q(access_type)}",
|
|
"try { $registryIdentity = New-Object System.Security.Principal.SecurityIdentifier($registryPrincipal) } catch { $registryIdentity = $registryPrincipal }",
|
|
"try { $registryRights = [System.Enum]::Parse([System.Security.AccessControl.RegistryRights], $registryRightsName, $true) } catch { throw \"Invalid RegistryRights '$registryRightsName' for $registryPath\" }",
|
|
"$registryAccessType = [System.Enum]::Parse([System.Security.AccessControl.AccessControlType], $registryAccessTypeName, $true)",
|
|
"$registryInheritance = " + ("[System.Security.AccessControl.InheritanceFlags]::ContainerInherit" if inherit_subkeys else "[System.Security.AccessControl.InheritanceFlags]::None"),
|
|
"$registryPropagation = [System.Security.AccessControl.PropagationFlags]::None",
|
|
"Write-JobLog (\"Registry ACL target: $registryPath; exists=$([bool](Test-Path -Path $registryPath))\")",
|
|
"$registryAcl = Get-Acl -Path $registryPath -ErrorAction Stop",
|
|
"$registryRule = New-Object System.Security.AccessControl.RegistryAccessRule($registryIdentity, $registryRights, $registryInheritance, $registryPropagation, $registryAccessType)",
|
|
("$registryAcl.SetAccessRule($registryRule)" if replace_existing else "$registryAcl.AddAccessRule($registryRule) | Out-Null"),
|
|
"Set-Acl -Path $registryPath -AclObject $registryAcl -ErrorAction Stop",
|
|
"$registryAclAfter = Get-Acl -Path $registryPath -ErrorAction Stop",
|
|
"$registryMatchedRules = @($registryAclAfter.Access | Where-Object { try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $registryIdentity.Value } catch { $_.IdentityReference.Value -eq $registryPrincipal -or $_.IdentityReference.Value -like ('*\\' + $registryPrincipal) } })",
|
|
"Write-JobLog (\"Registry permission set: $registryPath; identity=$registryPrincipal; rights=$registryRightsName; type=$registryAccessTypeName; matching_rules=$($registryMatchedRules.Count)\")",
|
|
"$registryResults += @{path=$registryPath; action='set_permissions'; status='changed'; principal=$registryPrincipal; rights=$registryRightsName; access_type=$registryAccessTypeName; matching_rules=$registryMatchedRules.Count}",
|
|
]
|
|
lines += ["$JobResult['registry_entries'] = $registryResults", "Write-JobLog ('Registry job completed; entries=' + $registryResults.Count)"]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _runtime_wrap_script(interpreter: str, user_script: str) -> str:
|
|
# Job definitions contain user code only. The technical execution wrapper
|
|
# is always generated from the current interpreter template at runtime.
|
|
source=str(user_script or "")
|
|
root=Path(__file__).resolve().parent / "templates" / "jobs"
|
|
files={"powershell":"powershell_wrapper.ps1","python":"python_wrapper.py.txt","bash":"bash_wrapper.sh","cmd":"cmd_wrapper.cmd"}
|
|
markers={"powershell":(" # --- Eigene Befehle hier einfügen ---"," # --- Ende eigene Befehle ---"),"python":(" # --- Eigene Befehle hier einfügen ---"," # --- Ende eigene Befehle ---"),"bash":("# --- Eigene Befehle hier einfügen ---","# --- Ende eigene Befehle ---"),"cmd":("rem --- Eigene Befehle hier einfügen ---","rem --- Ende eigene Befehle ---")}
|
|
filename=files.get(interpreter)
|
|
if not filename: raise RuntimeError(f"Unbekannter Interpreter: {interpreter}")
|
|
wrapper=(root/filename).read_text(encoding="utf-8").replace("\r\n","\n")
|
|
start,end=markers[interpreter]; block=start+"\n\n"+end
|
|
indent={"powershell":" ","python":" ","bash":"","cmd":""}[interpreter]
|
|
commands="\n".join(indent+line if line.strip() else line for line in str(user_script or "").splitlines())
|
|
return wrapper.replace(block,start+"\n"+commands+"\n"+end,1)
|
|
|
|
def _definition_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]:
|
|
"""Return (resolved script/command, interpreter, upload_required)."""
|
|
if definition_id is not None:
|
|
definition=db.query(JobDefinition).filter(JobDefinition.id==definition_id,JobDefinition.enabled.is_(True)).first()
|
|
else:
|
|
definition=db.query(JobDefinition).filter(JobDefinition.system_key==f'software_inventory_{platform}',JobDefinition.enabled.is_(True)).first()
|
|
if not definition:
|
|
if definition_id is not None:
|
|
raise RuntimeError('Jobdefinition wurde nicht gefunden oder ist deaktiviert.')
|
|
return '', '', False
|
|
if definition.platform not in {platform, 'all'}:
|
|
raise RuntimeError(f'Jobdefinition passt nicht zur Plattform {platform}.')
|
|
effective_parameters=((db.get(SoftwareJob,job_id).parameters or {}).get('effective_parameters') or {}) if db.get(SoftwareJob,job_id) else {}
|
|
if definition.job_kind=='registry':
|
|
script=_runtime_wrap_script('powershell', build_registry_user_script(definition.configuration))
|
|
interpreter='powershell'
|
|
elif definition.source_type=='inline':
|
|
interpreter=definition.interpreter
|
|
script=_runtime_wrap_script(interpreter, definition.inline_script or '')
|
|
elif definition.source_type=='mounted':
|
|
interpreter=definition.interpreter
|
|
path=Path(definition.source_path or '')
|
|
if not path.is_file(): raise RuntimeError(f'Skriptdatei nicht gefunden: {path}')
|
|
script=_runtime_wrap_script(interpreter, path.read_text(encoding='utf-8',errors='replace'))
|
|
elif definition.source_type=='unc':
|
|
path=_replace_job_placeholders(definition.source_path or '',asset,callback_url,job_id,effective_parameters)
|
|
arguments=_replace_job_placeholders(definition.arguments or '',asset,callback_url,job_id,effective_parameters)
|
|
if definition.interpreter=='powershell': command=f'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{path}" {arguments}'.strip()
|
|
elif definition.interpreter=='cmd': command=f'cmd.exe /d /s /c ""{path}" {arguments}"'.strip()
|
|
elif definition.interpreter=='python': command=f'python3 "{path}" {arguments}'.strip()
|
|
else: command=f'bash "{path}" {arguments}'.strip()
|
|
return command, definition.interpreter, False
|
|
else:
|
|
raise RuntimeError(f'Unbekannte Skriptquelle: {definition.source_type}')
|
|
script=_replace_job_placeholders(script,asset,callback_url,job_id,effective_parameters)
|
|
return script, interpreter, True
|
|
|
|
|
|
def _job_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]:
|
|
configured=_definition_execution_artifacts(db,platform,callback_url,job_id,asset,definition_id)
|
|
if configured[0]:
|
|
return configured
|
|
if platform == 'windows':
|
|
return windows_inventory(callback_url, job_id), 'powershell', True
|
|
if platform in {'linux','macos'}:
|
|
return unix_python_payload(platform, callback_url, job_id), 'python', True
|
|
raise HTTPException(400, 'Für dieses Betriebssystem ist kein sicherer MeshCtrl-Handler verfügbar.')
|
|
|
|
|
|
def _script_extension(interpreter: str) -> str:
|
|
return {'powershell':'.ps1','cmd':'.cmd','python':'.py','bash':'.sh'}.get(interpreter,'.txt')
|
|
|
|
|
|
def _remote_job_paths(platform: str, job_id: int, attempt: int, interpreter: str) -> tuple[str, str]:
|
|
suffix=f'{job_id}-{attempt}'
|
|
filename='run'+_script_extension(interpreter)
|
|
if platform == 'windows':
|
|
directory=f'C:\\ProgramData\\AssetManager\\Jobs\\{suffix}'
|
|
return directory, directory+'\\'+filename
|
|
directory=f'/var/lib/assetmanager/jobs/{suffix}'
|
|
return directory, directory+'/'+filename
|
|
|
|
|
|
def _meshctrl_base_args(cfg: dict, asset: Asset, password: str) -> list[str]:
|
|
args=['node',str(cfg.get('meshctrl_path') or '/opt/meshcentral/node_modules/meshcentral/meshctrl.js')]
|
|
args += ['--url',str(cfg.get('url') or ''),'--loginuser',str(cfg.get('username') or ''),'--loginpass',password]
|
|
tenant=str(cfg.get('tenant') or '').strip()
|
|
if tenant:
|
|
args += ['--domain',tenant]
|
|
return args
|
|
|
|
|
|
def _run_meshctrl(cfg: dict, asset: Asset, password: str, action_args: list[str], timeout: int) -> subprocess.CompletedProcess:
|
|
base=_meshctrl_base_args(cfg,asset,password)
|
|
args=base[:2]+action_args+base[2:]
|
|
return subprocess.run(args,capture_output=True,text=True,timeout=timeout,check=False)
|
|
|
|
|
|
def _prepare_remote_directory(
|
|
cfg: dict,
|
|
asset: Asset,
|
|
password: str,
|
|
platform: str,
|
|
remote_dir: str,
|
|
timeout: int,
|
|
remote_file: str | None = None,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Create the job directory and remove a stale target file.
|
|
|
|
MeshCtrl's Upload action does not overwrite an existing file reliably on
|
|
all Windows agents. Failed jobs intentionally retain their directories, so
|
|
a repeated dispatch must explicitly remove a previous run.ps1 before the
|
|
upload starts.
|
|
"""
|
|
if platform == 'windows':
|
|
command=(
|
|
"$p='"+remote_dir.replace("'","''")+"';"
|
|
"New-Item -ItemType Directory -Force -Path $p|Out-Null;"
|
|
)
|
|
if remote_file:
|
|
command += "Remove-Item -LiteralPath '"+remote_file.replace("'","''")+"' -Force -ErrorAction SilentlyContinue;"
|
|
command += "& icacls.exe $p /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C|Out-Null"
|
|
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout)
|
|
command=f"mkdir -p '{remote_dir}'"
|
|
if remote_file:
|
|
command += f" && rm -f -- '{remote_file}'"
|
|
command += f" && chmod 700 '{remote_dir}'"
|
|
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout)
|
|
|
|
|
|
def _upload_script(cfg: dict, asset: Asset, password: str, local_file: str, remote_dir: str, timeout: int) -> subprocess.CompletedProcess:
|
|
return _run_meshctrl(cfg,asset,password,['Upload','--id',asset.mesh_node_id,'--file',local_file,'--target',remote_dir],timeout)
|
|
|
|
|
|
def _uploaded_script_launch_spec(platform: str, interpreter: str, remote_file: str) -> tuple[str, str, list[str]]:
|
|
"""Return the exact remote command, shell label and MeshCtrl shell flags.
|
|
|
|
Windows PowerShell files are deliberately launched through MeshCtrl's
|
|
Windows Command Prompt mode (type 0). The command then starts powershell
|
|
itself with ExecutionPolicy Bypass. Adding MeshCtrl's --powershell flag
|
|
would switch to type 2 and cause the agent to execute the .ps1 inside the
|
|
PowerShell host, which is precisely what must be avoided here.
|
|
"""
|
|
if platform == 'windows' and interpreter == 'powershell':
|
|
# Important: do not add MeshCtrl's --powershell option here. Without
|
|
# that option MeshCtrl uses the normal Windows command prompt, which
|
|
# then starts PowerShell with the same command that was tested manually.
|
|
escaped_file = remote_file.replace('"', '""')
|
|
return (
|
|
f'powershell -ExecutionPolicy Bypass -File "{escaped_file}"',
|
|
'Windows Command Prompt',
|
|
[],
|
|
)
|
|
if platform == 'windows' and interpreter == 'cmd':
|
|
return f'cmd.exe /d /s /c ""{remote_file}""', 'Windows Command Prompt', []
|
|
if interpreter == 'python':
|
|
return f"python3 '{remote_file}'", 'System shell', []
|
|
return f"chmod 700 '{remote_file}' && bash '{remote_file}'", 'System shell', []
|
|
|
|
|
|
def _launch_uploaded_script(cfg: dict, asset: Asset, password: str, platform: str, interpreter: str, remote_file: str, timeout: int) -> subprocess.CompletedProcess:
|
|
command,_shell_label,meshctrl_shell_flags=_uploaded_script_launch_spec(platform,interpreter,remote_file)
|
|
action=['RunCommand','--id',asset.mesh_node_id,'--run',command]+meshctrl_shell_flags+['--reply']
|
|
if platform == 'windows' and interpreter == 'powershell' and '--powershell' in action:
|
|
raise RuntimeError('Interner Dispatcherfehler: Dateibasierte PowerShell-Jobs dürfen MeshCtrl nicht im PowerShell-Modus starten.')
|
|
return _run_meshctrl(cfg,asset,password,action,timeout)
|
|
|
|
|
|
def _cleanup_remote_directory(cfg: dict, asset: Asset, password: str, platform: str, remote_dir: str, timeout: int) -> subprocess.CompletedProcess:
|
|
if platform == 'windows':
|
|
command="Remove-Item -LiteralPath '"+remote_dir.replace("'","''")+"' -Recurse -Force -ErrorAction SilentlyContinue"
|
|
action=['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply']
|
|
else:
|
|
action=['RunCommand','--id',asset.mesh_node_id,'--run',f"rm -rf -- '{remote_dir}'",'--reply']
|
|
return _run_meshctrl(cfg,asset,password,action,timeout)
|
|
|
|
|
|
def _add_job_event(db, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None) -> None:
|
|
db.add(JobEvent(
|
|
job_id=job.id,
|
|
attempt=int(job.attempt_count or 0),
|
|
event_type=event_type,
|
|
status=status if status is not None else job.status,
|
|
message=(message or "")[:4000] or None,
|
|
))
|
|
|
|
|
|
def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
|
# Reserve the dispatch slot before opening a database session. With large
|
|
# bulk selections this keeps waiting workers from occupying the connection
|
|
# pool while they wait for their configured MeshCtrl start interval.
|
|
dispatch_delay_seconds, dispatch_wait_seconds = _wait_for_dispatch_slot()
|
|
db = SessionLocal()
|
|
temp_path: str | None = None
|
|
temp_dir: str | None = None
|
|
try:
|
|
job = db.get(SoftwareJob, job_id)
|
|
asset = db.get(Asset, job.asset_id) if job else None
|
|
if not job or not asset or not asset.mesh_node_id:
|
|
if job:
|
|
job.status='failed'
|
|
job.message='Asset besitzt keine MeshCentral Node-ID.'
|
|
job.finished_at=datetime.utcnow()
|
|
sync_asset_job_state(db, job)
|
|
db.commit()
|
|
return
|
|
callback_url=f"{callback_base}/api/software-jobs/{job.id}/callback?token={quote(token)}"
|
|
definition_id=None
|
|
if job.job_type=='job_definition':
|
|
try: definition_id=int((job.parameters or {}).get('definition_id'))
|
|
except (TypeError,ValueError): raise RuntimeError('Jobdefinition fehlt oder ist ungültig.')
|
|
payload,interpreter,upload_required=_job_execution_artifacts(db,job.platform,callback_url,job.id,asset,definition_id)
|
|
job.attempt_count=(job.attempt_count or 0)+1
|
|
attempt=job.attempt_count
|
|
remote_dir,remote_file=_remote_job_paths(job.platform,job.id,attempt,interpreter)
|
|
job.resolved_script=payload
|
|
launch_command=None
|
|
launch_shell=None
|
|
if upload_required:
|
|
launch_command,launch_shell,_launch_flags=_uploaded_script_launch_spec(job.platform,interpreter,remote_file)
|
|
job.command_preview=f'UPLOAD -> {remote_file}\nSHELL -> {launch_shell}\nEXECUTE -> {launch_command}'
|
|
else:
|
|
job.command_preview=payload
|
|
job.status='sending'
|
|
_add_job_event(db,job,'sending','sending','Jobdatei wird über MeshCentral übertragen.')
|
|
sync_asset_job_state(db, job)
|
|
db.commit()
|
|
|
|
cfg=load_config().get('meshcentral',{})
|
|
password_env=str(cfg.get('password_env') or 'MESHCENTRAL_PASSWORD')
|
|
password=os.getenv(password_env,'')
|
|
if not password: raise RuntimeError(f'MeshCentral-Passwortvariable {password_env} ist nicht gesetzt.')
|
|
timeout=max(30,int(cfg.get('timeout_seconds') or 600))
|
|
job_parameters = job.parameters if isinstance(job.parameters, dict) else {}
|
|
diagnostics=[
|
|
'--- File dispatcher diagnostics ---',
|
|
f'Dispatch interval configured: {dispatch_delay_seconds:g} seconds',
|
|
f'Dispatch queue wait: {dispatch_wait_seconds:.3f} seconds',
|
|
f'Job type (technical): {job.job_type}',
|
|
f'Package (technical): {job.package.name if job.package else job.package_id}',
|
|
f'Creation mode: {job_parameters.get("_creation_mode") or "single"}',
|
|
f'Bulk batch id: {job_parameters.get("_bulk_batch_id") or "-"}',
|
|
f'Bulk position: {job_parameters.get("_bulk_position") or "-"}/{job_parameters.get("_bulk_total") or "-"}',
|
|
f'Mesh node id: {asset.mesh_node_id}',
|
|
f'Interpreter: {interpreter}',
|
|
f'Upload required: {upload_required}',
|
|
f'Resolved script characters: {len(payload)}',
|
|
f'Resolved script SHA-256: {hashlib.sha256(payload.encode("utf-8")).hexdigest()}',
|
|
]
|
|
if job.job_type == 'job_definition':
|
|
diagnostics += [
|
|
f'Definition id: {job_parameters.get("definition_id") or "-"}',
|
|
f'Definition name: {job_parameters.get("definition_name") or "-"}',
|
|
f'Definition kind: {job_parameters.get("definition_job_kind") or "-"}',
|
|
f'Definition revision: {job_parameters.get("definition_revision") or "-"}',
|
|
f'Definition interpreter: {job_parameters.get("definition_interpreter") or interpreter}',
|
|
f'Definition source type: {job_parameters.get("definition_source_type") or "-"}',
|
|
]
|
|
|
|
if upload_required:
|
|
suffix=_script_extension(interpreter)
|
|
temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-')
|
|
temp_path=str(Path(temp_dir)/('run'+suffix))
|
|
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
|
|
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
|
|
|
|
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
|
|
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
|
|
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
|
|
|
|
upload_results=[]
|
|
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
|
upload_results.append(uploaded)
|
|
diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}']
|
|
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
|
|
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
|
if not upload_ok:
|
|
# MeshCtrl can return code 0 together with "Upload error".
|
|
# Recreate the directory, remove any stale target and retry once.
|
|
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file)
|
|
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
|
|
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
|
upload_results.append(uploaded)
|
|
diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}']
|
|
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
|
|
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
|
if not upload_ok:
|
|
combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip())
|
|
raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}')
|
|
|
|
diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False']
|
|
started=datetime.utcnow()
|
|
result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)
|
|
finished=datetime.utcnow()
|
|
diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
|
|
else:
|
|
started=datetime.utcnow()
|
|
action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply']
|
|
if job.platform=='windows' and interpreter=='powershell': action += ['--powershell']
|
|
result=_run_meshctrl(cfg,asset,password,action,timeout)
|
|
finished=datetime.utcnow()
|
|
diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
|
|
|
|
now=datetime.utcnow()
|
|
# MeshCtrl itself returns code 0 even for some remote PowerShell parser
|
|
# errors. Only delete the remote files after a completed successful
|
|
# callback, never merely because the MeshCtrl process exited cleanly.
|
|
db.expire_all()
|
|
callback_state=db.get(SoftwareJob,job.id)
|
|
cleanup_result=None
|
|
callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success')
|
|
diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}']
|
|
if upload_required and callback_success:
|
|
cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120))
|
|
diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}']
|
|
elif upload_required:
|
|
diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.']
|
|
|
|
db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False)
|
|
db.commit()
|
|
terminal_states={'success','failed','partial','timeout','cancelled'}
|
|
pending=db.query(SoftwareJob).filter(SoftwareJob.id==job.id,SoftwareJob.callback_completed_at.is_(None),SoftwareJob.status.notin_(terminal_states))
|
|
if result.returncode==0:
|
|
message='Skriptdatei ausgeführt; Callback wird erwartet.'
|
|
if upload_required and not callback_success:
|
|
message+=' Jobdateien bleiben bis zu einem bestätigten erfolgreichen Callback erhalten.'
|
|
if upload_required and cleanup_result is not None and cleanup_result.returncode!=0:
|
|
message+=' Die lokale Jobdatei konnte nicht automatisch gelöscht werden.'
|
|
pending.update({SoftwareJob.status:'sent',SoftwareJob.message:message},synchronize_session=False)
|
|
else:
|
|
pending.update({SoftwareJob.status:'failed',SoftwareJob.message:f'MeshCtrl-Ausführungsfehler {result.returncode}; Jobdateien bleiben zur Diagnose erhalten.',SoftwareJob.finished_at:now},synchronize_session=False)
|
|
db.expire_all()
|
|
refreshed=db.get(SoftwareJob,job.id)
|
|
if refreshed:
|
|
_add_job_event(db,refreshed,'meshctrl_result',refreshed.status,refreshed.message)
|
|
sync_asset_job_state(db, refreshed)
|
|
db.commit()
|
|
except Exception as exc:
|
|
job=db.get(SoftwareJob,job_id)
|
|
if job:
|
|
job.status='failed'
|
|
job.message=str(exc)
|
|
job.finished_at=datetime.utcnow()
|
|
if 'diagnostics' in locals() and diagnostics:
|
|
job.meshctrl_stdout='\n'.join(diagnostics)[-30000:]
|
|
_add_job_event(db,job,'error','failed',str(exc))
|
|
sync_asset_job_state(db, job)
|
|
db.commit()
|
|
finally:
|
|
if temp_path:
|
|
try: os.unlink(temp_path)
|
|
except OSError: pass
|
|
if temp_dir:
|
|
try: os.rmdir(temp_dir)
|
|
except OSError: pass
|
|
db.close()
|
|
|