software packages and deploying

This commit is contained in:
2026-09-10 21:54:55 +02:00
parent 0fb18ae4f7
commit 4567df7ebb
73 changed files with 3088 additions and 6 deletions
+1
View File
@@ -25,6 +25,7 @@ data/logs
data/backups
data/backup
data/scripts
data/software-packages
app/static/uploads/*
!app/static/uploads/.gitkeep
app/main_old.py
+2
View File
@@ -19,6 +19,8 @@ data/backup/*
!data/backup/.gitkeep
data/scripts/*
!data/scripts/.gitkeep
data/software-packages/*
!data/software-packages/.gitkeep
app/static/uploads/*
!app/static/uploads/.gitkeep
+3 -1
View File
@@ -1,4 +1,4 @@
# AssetManager 0.5.5.75
# AssetManager 0.5.5.81
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
@@ -9,6 +9,8 @@ The project is licensed under the **Apache License 2.0** and may be used, modifi
- configurable asset categories, fields, status values, images, and assignments
- asset lists, detail views, history, bulk editing, Excel import/export, duplicate merging, and tree views
- hardware and software inventory with comparison and aggregation views
- static Setup Analyzer for Windows installer technology, silent parameters, deployment-script export, and persistent software-package creation
- install, uninstall, and reinstall software-package jobs with MeshCentral file transfer and callback tracking
- job definitions and remote job execution with status tracking, callbacks, retries, and logs
- optional MeshCentral integration through the MeshCtrl command-line interface
- local users, optional LDAP/Active Directory authentication, and a protected local emergency administrator
+1
View File
@@ -18,6 +18,7 @@ AssetManager is released under the Apache License 2.0. The following components
| Passlib | Password hashing | BSD-style license; includes additional notices for bundled components |
| bcrypt | Hashing library used by `passlib[bcrypt]` | Apache-2.0 |
| openpyxl | Excel import and export | MIT |
| pefile | Static Portable Executable metadata analysis for Setup Analyzer | MIT |
| MeshCentral / MeshCtrl | Optional integration and remote jobs | Apache-2.0 |
| PostgreSQL container image | Database service | Contains PostgreSQL and operating-system packages under their own licenses |
| Python container image | Runtime base | Contains Python and operating-system packages under their own licenses |
+1 -1
View File
@@ -1 +1 @@
0.5.5.75
0.5.5.81
+14
View File
@@ -17,6 +17,7 @@ BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups"))
CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config/config.json"))
APPINFO_PATH = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json"))
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/app/static/uploads"))
SOFTWARE_PACKAGE_DIR = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages"))
BACKUP_INTERVAL_HOURS = max(1, int(os.getenv("BACKUP_INTERVAL_HOURS", "8")))
BACKUP_RETENTION_DAYS = max(1, int(os.getenv("BACKUP_RETENTION_DAYS", "3")))
BACKUP_PREFIX = "assetmanager-backup-"
@@ -101,6 +102,7 @@ def system_storage_information(log_dir: Path | None = None) -> dict:
"database_connected": True,
"config": _directory_status(CONFIG_PATH.parent),
"uploads": _directory_status(UPLOAD_DIR),
"software_packages": _directory_status(SOFTWARE_PACKAGE_DIR, create=True),
"logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", "/app/data/logs"))),
"backups": _directory_status(BACKUP_DIR, create=True),
"backup_count": len(backups),
@@ -158,6 +160,10 @@ def create_backup(created_by: str = "system", reason: str = "manual") -> dict:
target = files_dir / "uploads"
shutil.copytree(UPLOAD_DIR, target)
included.append("files/uploads/")
if SOFTWARE_PACKAGE_DIR.is_dir():
target = files_dir / "software-packages"
shutil.copytree(SOFTWARE_PACKAGE_DIR, target)
included.append("files/software-packages/")
metadata = {
"format": 1,
@@ -324,6 +330,14 @@ def restore_backup(name: str) -> dict:
else:
child.unlink()
shutil.copytree(files / "uploads", UPLOAD_DIR, dirs_exist_ok=True)
if (files / "software-packages").is_dir():
SOFTWARE_PACKAGE_DIR.mkdir(parents=True, exist_ok=True)
for child in SOFTWARE_PACKAGE_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
shutil.copytree(files / "software-packages", SOFTWARE_PACKAGE_DIR, dirs_exist_ok=True)
return _metadata(path)
+157
View File
@@ -1434,3 +1434,160 @@ BASE_TRANSLATIONS.update({
"privacy.run_id": ("Run ID", "Lauf-ID"),
"privacy.no_deletion_entries": ("No checks or deletion runs have been recorded yet.", "Noch keine Prüfläufe oder Löschläufe protokolliert."),
})
# v0.5.5.76 Setup Analyzer
BASE_TRANSLATIONS.update({
"setup_analyzer.title": ("Setup Analyzer", "Setup-Analyzer"),
"setup_analyzer.subtitle": ("Detect installer technology, silent switches and deployment settings.", "Setup-Technologie, Silent-Parameter und Deployment-Einstellungen erkennen."),
"setup_analyzer.card_title": ("Setup Analyzer", "Setup-Analyzer"),
"setup_analyzer.card_help": ("Analyze Windows installers and prepare silent deployment scripts.", "Windows-Installer analysieren und Skripte für die Silent-Installation vorbereiten."),
"setup_analyzer.back_to_software": ("Back to software control", "Zurück zur Softwaresteuerung"),
"setup_analyzer.upload_title": ("Analyze installer", "Installationsdatei analysieren"),
"setup_analyzer.static_note": ("The installer is analyzed statically and is never executed on the AssetManager server.", "Der Installer wird statisch analysiert und auf dem AssetManager-Server niemals ausgeführt."),
"setup_analyzer.file": ("File", "Datei"),
"setup_analyzer.max_upload": ("Maximum upload: {size} MB", "Maximaler Upload: {size} MB"),
"setup_analyzer.analyze": ("Analyze", "Analysieren"),
"setup_analyzer.file_info": ("File information", "Dateiinformationen"),
"setup_analyzer.size": ("Size", "Größe"),
"setup_analyzer.signature": ("Authenticode data", "Authenticode-Daten"),
"setup_analyzer.signature_present": ("Signature data present", "Signaturdaten vorhanden"),
"setup_analyzer.signature_absent": ("No signature data detected", "Keine Signaturdaten erkannt"),
"setup_analyzer.not_available": ("Not available", "Nicht verfügbar"),
"setup_analyzer.detection": ("Installer detection", "Installer-Erkennung"),
"setup_analyzer.technology": ("Technology", "Technologie"),
"setup_analyzer.confidence": ("Detection confidence", "Erkennungssicherheit"),
"setup_analyzer.command_confidence": ("Command confidence", "Sicherheit der Parameter"),
"setup_analyzer.command_confidence.high": ("High", "Hoch"),
"setup_analyzer.command_confidence.medium": ("Medium - test required", "Mittel - Test erforderlich"),
"setup_analyzer.command_confidence.low": ("Low - verify manually", "Niedrig - manuell prüfen"),
"setup_analyzer.command_confidence.none": ("No reliable command", "Kein verlässlicher Befehl"),
"setup_analyzer.detection_signals": ("Detection signals", "Erkennungsmerkmale"),
"setup_analyzer.other_candidates": ("Other detected installer candidates", "Weitere erkannte Installer-Kandidaten"),
"setup_analyzer.embedded_switches": ("Switch-like strings found in the file", "In der Datei gefundene Parameter-Strings"),
"setup_analyzer.notes": ("Notes", "Hinweise"),
"setup_analyzer.product_info": ("Product information", "Produktinformationen"),
"setup_analyzer.product_name": ("Product name", "Produktname"),
"setup_analyzer.version": ("Version", "Version"),
"setup_analyzer.manufacturer": ("Manufacturer", "Hersteller"),
"setup_analyzer.architecture": ("Architecture", "Architektur"),
"setup_analyzer.installation": ("Installation", "Installation"),
"setup_analyzer.arguments": ("Silent arguments", "Silent-Parameter"),
"setup_analyzer.recommended_command": ("Recommended command", "Empfohlener Aufruf"),
"setup_analyzer.timeout": ("Timeout in seconds", "Timeout in Sekunden"),
"setup_analyzer.run_as": ("Run as", "Ausführen als"),
"setup_analyzer.logged_on_user": ("Logged-on user", "Angemeldeter Benutzer"),
"setup_analyzer.success_codes": ("Success codes", "Erfolgscodes"),
"setup_analyzer.reboot_codes": ("Reboot codes", "Neustartcodes"),
"setup_analyzer.generated_logic": ("Generated deployment logic", "Erzeugte Deployment-Logik"),
"setup_analyzer.detection_method": ("Detection method", "Erkennungsmethode"),
"setup_analyzer.uninstall_command": ("Uninstall command", "Deinstallationsbefehl"),
"setup_analyzer.export_note": ("The deployment package contains the installer, install.ps1, uninstall.ps1, detect.ps1, package.json and analysis.json.", "Das Deployment-Paket enthält Installer, install.ps1, uninstall.ps1, detect.ps1, package.json und analysis.json."),
"setup_analyzer.export_ps": ("Export PowerShell", "PowerShell exportieren"),
"setup_analyzer.export_package": ("Export deployment package", "Deployment-Paket exportieren"),
"setup_analyzer.technical_details": ("Technical analysis", "Technische Analyse"),
"setup_analyzer.pefile": ("PE metadata support", "PE-Metadaten-Unterstützung"),
"setup_analyzer.signal.inno_data": ("Inno Setup data marker", "Inno-Setup-Datenkennung"),
"setup_analyzer.signal.inno": ("Inno Setup marker", "Inno-Setup-Kennung"),
"setup_analyzer.signal.inno_internal": ("Inno internal marker", "Interne Inno-Kennung"),
"setup_analyzer.signal.nsis_system": ("Nullsoft Install System marker", "Nullsoft-Install-System-Kennung"),
"setup_analyzer.signal.nsis_installer": ("Nullsoft installer marker", "Nullsoft-Installer-Kennung"),
"setup_analyzer.signal.nullsoft": ("Nullsoft marker", "Nullsoft-Kennung"),
"setup_analyzer.signal.nsis": ("NSIS marker", "NSIS-Kennung"),
"setup_analyzer.signal.wix_burn": ("WiX Burn marker", "WiX-Burn-Kennung"),
"setup_analyzer.signal.wix_bundle": ("WiX bundle marker", "WiX-Bundle-Kennung"),
"setup_analyzer.signal.wix_stdba": ("WiX standard bootstrapper marker", "WiX-Standard-Bootstrapper-Kennung"),
"setup_analyzer.signal.burn_engine": ("Burn engine marker", "Burn-Engine-Kennung"),
"setup_analyzer.signal.installshield": ("InstallShield marker", "InstallShield-Kennung"),
"setup_analyzer.signal.installscript": ("InstallScript marker", "InstallScript-Kennung"),
"setup_analyzer.signal.advanced_installer": ("Advanced Installer marker", "Advanced-Installer-Kennung"),
"setup_analyzer.signal.caphyon": ("Caphyon marker", "Caphyon-Kennung"),
"setup_analyzer.signal.squirrel": ("Squirrel marker", "Squirrel-Kennung"),
"setup_analyzer.signal.squirrel_releasify": ("Squirrel releasify marker", "Squirrel-Releasify-Kennung"),
"setup_analyzer.signal.7zip_sfx": ("7-Zip SFX marker", "7-Zip-SFX-Kennung"),
"setup_analyzer.signal.7zip_module": ("7-Zip SFX module marker", "7-Zip-SFX-Modulkennung"),
"setup_analyzer.signal.winrar_sfx": ("WinRAR SFX marker", "WinRAR-SFX-Kennung"),
"setup_analyzer.signal.rar_sfx": ("RAR SFX marker", "RAR-SFX-Kennung"),
"setup_analyzer.signal.msi_extension": ("MSI file extension", "MSI-Dateiendung"),
"setup_analyzer.signal.msp_extension": ("MSP file extension", "MSP-Dateiendung"),
"setup_analyzer.signal.msix": ("MSIX package", "MSIX-Paket"),
"setup_analyzer.signal.appx": ("AppX package", "AppX-Paket"),
"setup_analyzer.signal.msu": ("MSU package", "MSU-Paket"),
"setup_analyzer.signal.exe": ("Portable Executable or EXE file", "Portable-Executable- bzw. EXE-Datei"),
"setup_analyzer.signal.unknown": ("No supported installer container detected", "Kein unterstützter Installer-Container erkannt"),
"setup_analyzer.warning.installshield": ("InstallShield command lines depend on the project type. Test the generated command.", "InstallShield-Befehlszeilen hängen vom Projekttyp ab. Den erzeugten Befehl testen."),
"setup_analyzer.warning.advanced_installer": ("Advanced Installer packages can use project-specific properties. Test the generated command.", "Advanced-Installer-Pakete können projektspezifische Eigenschaften verwenden. Den erzeugten Befehl testen."),
"setup_analyzer.warning.squirrel": ("Squirrel behavior is vendor-dependent. Verify silent installation and installation scope.", "Das Verhalten von Squirrel ist herstellerabhängig. Silent-Installation und Installationsbereich prüfen."),
"setup_analyzer.warning.sfx": ("SFX archives do not provide one universal silent switch. Inspect the embedded installer or vendor documentation.", "SFX-Archive besitzen keinen einheitlichen Silent-Parameter. Eingebetteten Installer oder Herstellerdokumentation prüfen."),
"setup_analyzer.warning.unknown": ("No reliable silent command was detected. Review the installer manually before deployment.", "Es wurde kein verlässlicher Silent-Befehl erkannt. Den Installer vor der Verteilung manuell prüfen."),
"setup_analyzer.warning.msiinfo": ("Detailed MSI metadata such as ProductCode requires the optional msiinfo utility. Silent MSI command generation still works.", "Detaillierte MSI-Metadaten wie ProductCode benötigen das optionale Werkzeug msiinfo. Die Erzeugung des Silent-MSI-Befehls funktioniert trotzdem."),
"setup_analyzer.warning.pefile": ("PE metadata is limited because the pefile Python dependency is not installed.", "PE-Metadaten sind eingeschränkt, weil die Python-Abhängigkeit pefile nicht installiert ist."),
"setup_analyzer.warning.appx_context": ("MSIX/AppX installation scope depends on the execution context. Test deployment under the same account context used by the job.", "Der Installationsbereich von MSIX/AppX hängt vom Ausführungskontext ab. Die Verteilung im gleichen Kontokontext wie den späteren Job testen."),
})
# v0.5.5.77 Software packages and deployment jobs
BASE_TRANSLATIONS.update({
"setup_analyzer.suppress_browser": ("Suppress post-install browser launch", "Browser-/Webseiten-Aufruf nach dem Setup unterdrücken"),
"setup_analyzer.suppress_browser_help": ("Stops only browser processes that were newly created inside the installer process tree. Existing browser sessions are not touched.", "Beendet nur Browserprozesse, die neu aus dem Prozessbaum des Installers gestartet wurden. Bereits laufende Browser werden nicht beendet."),
"setup_analyzer.create_package": ("Create AssetManager package", "AssetManager-Softwarepaket erstellen"),
"setup_analyzer.export_note_v2": ("You can export the generated scripts or create a persistent AssetManager software package directly. The package can then be used for install, uninstall and reinstall jobs.", "Du kannst die erzeugten Skripte exportieren oder direkt ein dauerhaftes AssetManager-Softwarepaket erstellen. Das Paket kann anschließend für Installations-, Deinstallations- und Neuinstallationsjobs verwendet werden."),
"software_packages.title": ("Software packages", "Softwarepakete"),
"software_packages.subtitle": ("Persistent deployment packages created from Setup Analyzer results.", "Dauerhafte Deployment-Pakete, die aus Ergebnissen des Setup-Analyzers erstellt wurden."),
"software_packages.card_title": ("Software packages", "Softwarepakete"),
"software_packages.card_help": ("Create and start install, uninstall and reinstall jobs from prepared packages.", "Installations-, Deinstallations- und Neuinstallationsjobs aus vorbereiteten Paketen erstellen und starten."),
"software_packages.new_from_analyzer": ("New package from Setup Analyzer", "Neues Paket aus Setup-Analyzer"),
"software_packages.installer_type": ("Installer type", "Installer-Typ"),
"software_packages.storage": ("Storage", "Speicher"),
"software_packages.status": ("Package status", "Paketstatus"),
"software_packages.storage_error": ("Storage error", "Speicherfehler"),
"software_packages.enabled": ("Active", "Aktiv"),
"software_packages.disabled": ("Disabled", "Deaktiviert"),
"software_packages.none": ("No deployment packages are available yet.", "Noch keine Deployment-Pakete vorhanden."),
"software_packages.detail_subtitle": ("Create software jobs directly from this stored package.", "Softwarejobs direkt aus diesem gespeicherten Paket erstellen."),
"software_packages.back": ("Back to packages", "Zurück zu den Paketen"),
"software_packages.created": ("The software package was created successfully and is ready for deployment jobs.", "Das Softwarepaket wurde erfolgreich erstellt und kann jetzt für Deployment-Jobs verwendet werden."),
"software_packages.package_info": ("Package information", "Paketinformationen"),
"software_packages.installer_file": ("Installer file", "Installationsdatei"),
"software_packages.deployment_info": ("Deployment settings", "Deployment-Einstellungen"),
"software_packages.create_jobs": ("Create software jobs", "Softwarejobs erstellen"),
"software_packages.create_jobs_help": ("Select one or more Windows assets and the desired action. AssetManager transfers the required package files through MeshCentral and runs them as a normal software job.", "Wähle ein oder mehrere Windows-Assets und die gewünschte Aktion. AssetManager überträgt die benötigten Paketdateien über MeshCentral und führt sie als normalen Softwarejob aus."),
"software_packages.action": ("Action", "Aktion"),
"software_packages.select_all": ("Select all compatible assets", "Alle kompatiblen Assets auswählen"),
"software_packages.no_compatible_assets": ("No Windows assets with a MeshCentral node ID are available.", "Es sind keine Windows-Assets mit MeshCentral-Node-ID verfügbar."),
"software_packages.start_jobs": ("Start software jobs", "Softwarejobs starten"),
"software_packages.not_found": ("Software package not found.", "Softwarepaket nicht gefunden."),
"software_packages.storage_error_detail": ("The package storage could not be read: {error}", "Der Paketspeicher konnte nicht gelesen werden: {error}"),
"software_packages.unavailable": ("The software package is not available.", "Das Softwarepaket ist nicht verfügbar."),
"software_packages.invalid_action": ("The selected software action is not available.", "Die ausgewählte Softwareaktion ist nicht verfügbar."),
"software_packages.no_assets_selected": ("No assets were selected.", "Es wurden keine Assets ausgewählt."),
"software_packages.no_jobs_created": ("No software job could be created; {skipped} assets were skipped.", "Es konnte kein Softwarejob erstellt werden; {skipped} Assets wurden übersprungen."),
"software_packages.jobs_created": ("Created {created} software jobs; skipped {skipped} assets.", "{created} Softwarejobs erstellt; {skipped} Assets übersprungen."),
"software_packages.job_created": ('Software job "{package}" ({action}) created.', 'Softwarejob "{package}" ({action}) erstellt.'),
"software_packages.delete_title": ("Delete software package", "Softwarepaket löschen"),
"software_packages.delete_help": ("Deletes the stored package files and package definition. If software jobs reference this package, deletion is blocked until deleting those jobs is explicitly confirmed.", "Löscht die gespeicherten Paketdateien und die Paketdefinition. Wenn Softwarejobs dieses Paket verwenden, wird das Löschen blockiert, bis auch das Löschen dieser Jobs ausdrücklich bestätigt wurde."),
"software_packages.delete_jobs": ("Also delete {count} associated software jobs", "Auch {count} zugehörige Softwarejobs löschen"),
"software_packages.delete_jobs_required": ("This package is referenced by {count} software jobs. Confirm deletion of the associated jobs first.", "Dieses Paket wird von {count} Softwarejobs verwendet. Bestätige zuerst das Löschen der zugehörigen Jobs."),
"software_packages.delete_confirm": ('Really delete software package "{package}"?', 'Softwarepaket "{package}" wirklich löschen?'),
"software_packages.delete_button": ("Delete package", "Paket löschen"),
"software_packages.deleted": ('Software package "{package}" was deleted.', 'Softwarepaket "{package}" wurde gelöscht.'),
"jobs.type.software_deployment": ("Software deployment", "Softwareverteilung"),
"jobs.action.reinstall": ("Reinstall", "Neu installieren"),
})
BASE_TRANSLATIONS.update({
"setup_analyzer.run_as_help": ("AssetManager software-package jobs are executed in the MeshCentral system context.", "AssetManager-Softwarepaket-Jobs werden im Systemkontext von MeshCentral ausgeführt."),
})
# v0.5.5.80 software package process control
BASE_TRANSLATIONS.update({
"setup_analyzer.process_names": ("Processes to close before install/uninstall", "Vor Installation/Deinstallation zu beendende Prozesse"),
"setup_analyzer.process_names_help": ("Comma-, semicolon- or line-separated executable names. Known applications can be suggested automatically, for example Greenshot.exe.", "Komma-, Semikolon- oder zeilengetrennte EXE-Namen. Bei bekannten Anwendungen kann der Setup-Analyzer automatisch einen Vorschlag eintragen, z. B. Greenshot.exe."),
"software_packages.process_control": ("Process control", "Prozesssteuerung"),
"software_packages.process_control_help": ("These processes are closed before installation and uninstallation. AssetManager first requests a normal close and can then force termination if the process is still running.", "Diese Prozesse werden vor Installation und Deinstallation beendet. AssetManager fordert zuerst ein normales Beenden an und kann den Prozess anschließend zwangsweise beenden, wenn er weiterhin läuft."),
"software_packages.process_names": ("Processes to close", "Zu beendende Prozesse"),
"software_packages.process_names_help": ("Enter executable names such as Greenshot.exe. Multiple names can be separated by comma, semicolon or line break.", "EXE-Namen wie Greenshot.exe eintragen. Mehrere Namen können durch Komma, Semikolon oder Zeilenumbruch getrennt werden."),
"software_packages.process_grace_seconds": ("Grace period before force close (seconds)", "Wartezeit vor erzwungenem Beenden (Sekunden)"),
"software_packages.force_close": ("Force termination if the process is still running", "Prozess zwangsweise beenden, wenn er weiterhin läuft"),
"software_packages.process_control_saved": ("Process control was saved.", "Prozesssteuerung wurde gespeichert."),
"software_packages.process_control_error": ("Process control could not be saved: {error}", "Prozesssteuerung konnte nicht gespeichert werden: {error}"),
})
+2
View File
@@ -38,6 +38,8 @@ def job_state_key(job: SoftwareJob) -> str:
if job_type == "job_definition":
definition_id = job_definition_id(job)
return f"job_definition:{definition_id}" if definition_id else f"job_definition:job:{job.id}"
if job_type == "software_deployment":
return f"software_deployment:{job.package_id}:{job.action}"[:120]
return job_type[:120]
+357
View File
@@ -55,6 +55,8 @@ from .version import APP_VERSION
from .privacy import merge_privacy_settings, localized_privacy_settings, normalize_category, append_privacy_audit, privacy_audit_tail, PROTECTED_PRIVACY_CATEGORY_KEYS
from .privacy_retention import check_retention_category, delete_retention_category, append_deletion_audit, deletion_audit_tail, IMPLEMENTED_RETENTION_KEYS
from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information)
from .setup_analyzer import register_setup_analyzer
from .software_packages import delete_package_storage, human_size, load_package_manifest, package_execution_timeout_seconds, package_summary, update_package_process_control
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
@@ -1008,6 +1010,9 @@ def _require_admin(request: Request) -> None:
raise HTTPException(403, "Diese Funktion ist nur für Administratoren verfügbar.")
register_setup_analyzer(app, templates, _require_admin)
ASSET_ACCESS_SCOPES = {"self", "department", "location", "all"}
@@ -5873,6 +5878,257 @@ async def delete_software_inventory_entries(request: Request, db: Session = Depe
return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_success=" + quote(message), status_code=303)
@app.get("/software/packages")
def software_packages_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
packages = (
db.query(SoftwarePackage)
.filter(SoftwarePackage.package_type == "deployment")
.order_by(func.lower(SoftwarePackage.name), SoftwarePackage.id)
.all()
)
return templates.TemplateResponse(
"software_packages.html",
{
"request": request,
"package_rows": [package_summary(package) for package in packages],
"human_size": human_size,
},
)
@app.get("/software/packages/{package_id}")
def software_package_page(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment":
raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found."))
try:
manifest = load_package_manifest(package.id)
except Exception as exc:
raise HTTPException(500, _translate_request(request, "software_packages.storage_error_detail", "The package storage could not be read: {error}", error=str(exc))) from exc
candidate_assets = (
_apply_asset_access(db.query(Asset), request)
.order_by(func.lower(Asset.name), Asset.id)
.all()
)
assets = [
asset
for asset in candidate_assets
if asset.mesh_node_id and detect_platform(asset) == "windows"
]
job_count = db.query(SoftwareJob).filter(SoftwareJob.package_id == package.id).count()
return templates.TemplateResponse(
"software_package.html",
{
"request": request,
"package": package,
"manifest": manifest,
"storage_size": package_summary(package)["storage_size"],
"assets": assets,
"human_size": human_size,
"detect_platform": detect_platform,
"job_count": job_count,
},
)
@app.post("/software/packages/{package_id}/process-control")
async def software_package_process_control(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment":
raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found."))
form = await request.form()
process_names = str(form.get("process_names") or "")
try:
grace_seconds = int(str(form.get("grace_seconds") or "5"))
except ValueError:
grace_seconds = 5
force_close = str(form.get("force_close") or "").strip().lower() in {"1", "true", "yes", "on"}
try:
update_package_process_control(
package.id,
process_names,
grace_seconds=grace_seconds,
force_close=force_close,
)
except Exception as exc:
logger.exception("Could not update process control for software package %s", package.id)
message = _translate_request(
request,
"software_packages.process_control_error",
"Process control could not be saved: {error}",
error=str(exc),
)
return RedirectResponse(
f"/software/packages/{package.id}?toast_error=" + quote(message),
status_code=303,
)
message = _translate_request(
request,
"software_packages.process_control_saved",
"Process control was saved.",
)
return RedirectResponse(
f"/software/packages/{package.id}?toast_success=" + quote(message),
status_code=303,
)
@app.post("/software/packages/{package_id}/delete")
async def software_package_delete(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment":
raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found."))
form = await request.form()
delete_jobs = str(form.get("delete_jobs") or "").strip().lower() in {"1", "true", "yes", "on"}
job_ids = [row[0] for row in db.query(SoftwareJob.id).filter(SoftwareJob.package_id == package.id).all()]
if job_ids and not delete_jobs:
message = _translate_request(
request,
"software_packages.delete_jobs_required",
"This package is referenced by {count} software jobs. Confirm deletion of the associated jobs first.",
count=len(job_ids),
)
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(message),
status_code=303,
)
package_name = package.name
try:
if job_ids:
db.query(AssetJobState).filter(
AssetJobState.state_key.like(f"software_deployment:{package.id}:%")
).delete(synchronize_session=False)
db.query(JobEvent).filter(JobEvent.job_id.in_(job_ids)).delete(synchronize_session=False)
db.query(SoftwareJob).filter(SoftwareJob.id.in_(job_ids)).delete(synchronize_session=False)
db.query(SoftwarePackageParameter).filter(SoftwarePackageParameter.package_id == package.id).delete(synchronize_session=False)
db.delete(package)
db.commit()
except Exception:
db.rollback()
raise
try:
delete_package_storage(package_id)
except Exception as exc:
logger.warning("Could not remove software package storage for package %s: %s", package_id, exc)
message = _translate_request(
request,
"software_packages.deleted",
'Software package "{package}" was deleted.',
package=package_name,
)
return RedirectResponse(
"/software/packages?toast_success=" + quote(message),
status_code=303,
)
@app.post("/software/packages/{package_id}/run")
async def software_package_run(package_id: int, request: Request, db: Session = Depends(get_db)):
_require_admin(request)
package = db.get(SoftwarePackage, package_id)
if not package or package.package_type != "deployment" or not package.enabled:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(request, "software_packages.unavailable", "The software package is not available.")
),
status_code=303,
)
form = await request.form()
action = str(form.get("action") or "install").strip().lower()
if action not in {"install", "uninstall", "reinstall"}:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(request, "software_packages.invalid_action", "The selected software action is not available.")
),
status_code=303,
)
asset_ids: list[int] = []
for value in form.getlist("asset_ids"):
try:
asset_ids.append(int(value))
except (TypeError, ValueError):
continue
asset_ids = list(dict.fromkeys(asset_ids))
if not asset_ids:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(request, "software_packages.no_assets_selected", "No assets were selected.")
),
status_code=303,
)
visible_assets = (
_apply_asset_access(db.query(Asset), request)
.filter(Asset.id.in_(asset_ids))
.all()
)
visible_by_id = {asset.id: asset for asset in visible_assets}
created = 0
skipped = 0
bulk_batch_id = uuid.uuid4().hex
bulk_total = len(asset_ids)
for position, asset_id in enumerate(asset_ids, start=1):
asset = visible_by_id.get(asset_id)
if not asset:
skipped += 1
continue
try:
_create_software_deployment_job(
db,
request,
asset,
package,
action,
creation_mode="bulk" if bulk_total > 1 else "single",
bulk_batch_id=bulk_batch_id if bulk_total > 1 else None,
bulk_position=position if bulk_total > 1 else None,
bulk_total=bulk_total if bulk_total > 1 else None,
)
created += 1
except (ValueError, FileNotFoundError):
skipped += 1
if not created:
return RedirectResponse(
f"/software/packages/{package_id}?toast_error=" + quote(
_translate_request(
request,
"software_packages.no_jobs_created",
"No software job could be created; {skipped} assets were skipped.",
skipped=skipped,
)
),
status_code=303,
)
message = _translate_request(
request,
"software_packages.jobs_created",
"Created {created} software jobs; skipped {skipped} assets.",
created=created,
skipped=skipped,
)
return RedirectResponse(
"/software?toast_success=" + quote(message),
status_code=303,
)
@app.get("/software/installations")
def software_installations_page(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
@@ -6161,6 +6417,107 @@ def _create_definition_job(
return job
def _create_software_deployment_job(
db: Session,
request: Request,
asset: Asset,
package: SoftwarePackage,
action: str,
*,
creation_mode: str = "single",
bulk_batch_id: str | None = None,
bulk_position: int | None = None,
bulk_total: int | None = None,
) -> SoftwareJob:
if not asset.mesh_node_id:
raise ValueError("missing_node_id")
platform = detect_platform(asset)
if platform != "windows":
raise ValueError("platform_mismatch")
if package.package_type != "deployment" or not package.enabled:
raise ValueError("package_unavailable")
normalized_action = str(action or "").strip().lower()
if normalized_action not in {"install", "uninstall", "reinstall"}:
raise ValueError("invalid_action")
manifest = load_package_manifest(package.id)
manifest_platform = str(manifest.get("platform") or "windows").strip().lower()
if manifest_platform not in {"windows", "all"}:
raise ValueError("platform_mismatch")
timeout_seconds = package_execution_timeout_seconds(manifest)
token = secrets.token_urlsafe(32)
job = SoftwareJob(
asset_id=asset.id,
package_id=package.id,
status="created",
job_type="software_deployment",
action=normalized_action,
priority=100,
platform=platform,
parameters={
"package_name": package.name,
"product_name": str(manifest.get("name") or ""),
"product_version": str(manifest.get("version") or ""),
"installer_type": str(manifest.get("installer_type") or ""),
"package_sha256": str((manifest.get("analysis") or {}).get("sha256") or ""),
"_execution_timeout_seconds": timeout_seconds,
**_job_creation_metadata(
creation_mode=creation_mode,
bulk_batch_id=bulk_batch_id,
bulk_position=bulk_position,
bulk_total=bulk_total,
),
},
result_data={},
attempt_count=0,
max_attempts=1,
callback_token_hash=token_hash(token),
callback_expires_at=datetime.utcnow() + timedelta(
seconds=max(300, min(timeout_seconds + 300, 86400))
),
created_by=_changed_by(request),
)
db.add(job)
db.flush()
sync_asset_job_state(db, job, increment_execution=True)
_record_job_event(
db,
job,
"created",
"created",
_translate_request(
request,
"software_packages.job_created",
'Software job "{package}" ({action}) created.',
package=package.name,
action=_translate_request(
request,
f"jobs.action.{normalized_action}",
normalized_action,
),
),
_changed_by(request),
)
db.commit()
db.refresh(job)
configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/")
callback_base = configured or str(request.base_url).rstrip("/")
job_parameters = dict(job.parameters or {})
job_parameters["_callback_base_url"] = callback_base
job.parameters = job_parameters
db.commit()
threading.Thread(
target=execute_job,
args=(job.id, token, callback_base),
daemon=True,
).start()
return job
@app.post("/assets/{asset_id}/jobs/run")
def asset_run_job_definition(asset_id: int, request: Request, definition_id: int = Form(...), db: Session = Depends(get_db)):
_require_admin(request)
+948
View File
@@ -0,0 +1,948 @@
from __future__ import annotations
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import time
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from xml.etree import ElementTree
from sqlalchemy.orm import Session
from .database import get_db
from .models import SoftwarePackage
from .software_packages import build_generated_detection_script, build_generated_install_script, build_generated_uninstall_script, normalize_package_manifest, normalize_process_names, package_directory, unique_package_name, write_package_storage
from fastapi import Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
try:
import pefile
except Exception:
pefile = None
TEMP_ROOT = Path(os.getenv("SETUP_ANALYZER_TMP_DIR", "/tmp/assetmanager-setup-analyzer"))
MAX_UPLOAD_MB = max(1, int(os.getenv("SETUP_ANALYZER_MAX_UPLOAD_MB", "4096")))
RETENTION_HOURS = max(1, int(os.getenv("SETUP_ANALYZER_RETENTION_HOURS", "24")))
ALLOWED_EXTENSIONS = {".exe", ".msi", ".msp", ".msix", ".appx", ".msu"}
SCAN_CHUNK_BYTES = 4 * 1024 * 1024
SCAN_OVERLAP_BYTES = 2048
TEMP_ROOT.mkdir(parents=True, exist_ok=True)
INSTALLER_RULES: list[dict[str, Any]] = [
{
"key": "inno",
"label": "Inno Setup",
"markers": [
(b"inno setup setup data", 75, "setup_analyzer.signal.inno_data"),
(b"inno setup", 35, "setup_analyzer.signal.inno"),
(b"innosetup", 20, "setup_analyzer.signal.inno_internal"),
],
},
{
"key": "nsis",
"label": "NSIS",
"markers": [
(b"nullsoft install system", 80, "setup_analyzer.signal.nsis_system"),
(b"nullsoftinst", 55, "setup_analyzer.signal.nsis_installer"),
(b"nullsoft", 25, "setup_analyzer.signal.nullsoft"),
(b"nsis", 20, "setup_analyzer.signal.nsis"),
],
},
{
"key": "wix_burn",
"label": "WiX Burn",
"markers": [
(b"wixburn", 80, "setup_analyzer.signal.wix_burn"),
(b"wixbundle", 55, "setup_analyzer.signal.wix_bundle"),
(b"wixstdba", 45, "setup_analyzer.signal.wix_stdba"),
(b"burn engine", 30, "setup_analyzer.signal.burn_engine"),
],
},
{
"key": "installshield",
"label": "InstallShield",
"markers": [
(b"installshield", 80, "setup_analyzer.signal.installshield"),
(b"installscript", 30, "setup_analyzer.signal.installscript"),
],
},
{
"key": "advanced_installer",
"label": "Advanced Installer",
"markers": [
(b"advanced installer", 80, "setup_analyzer.signal.advanced_installer"),
(b"caphyon", 50, "setup_analyzer.signal.caphyon"),
],
},
{
"key": "squirrel",
"label": "Squirrel",
"markers": [
(b"squirrel", 55, "setup_analyzer.signal.squirrel"),
(b"releasify", 25, "setup_analyzer.signal.squirrel_releasify"),
],
},
{
"key": "7zip_sfx",
"label": "7-Zip SFX",
"markers": [
(b"7-zip sfx", 75, "setup_analyzer.signal.7zip_sfx"),
(b"7zs.sfx", 50, "setup_analyzer.signal.7zip_module"),
],
},
{
"key": "winrar_sfx",
"label": "WinRAR SFX",
"markers": [
(b"winrar sfx", 75, "setup_analyzer.signal.winrar_sfx"),
(b"rar sfx", 45, "setup_analyzer.signal.rar_sfx"),
],
},
]
SWITCH_MARKERS = [
b"/verysilent",
b"/silent",
b"/suppressmsgboxes",
b"/norestart",
b"/quiet",
b"/qn",
b"/passive",
b"/s",
b"--silent",
b"--quiet",
]
def _safe_filename(value: str | None) -> str:
name = Path(value or "setup.bin").name
name = re.sub(r"[^A-Za-z0-9._() +@-]", "_", name).strip(" .")
return name[:180] or "setup.bin"
def _human_size(size: int) -> str:
value = float(size)
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024.0 or unit == "TB":
return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}"
value /= 1024.0
return f"{size} B"
def _cleanup_old_files() -> None:
threshold = time.time() - (RETENTION_HOURS * 3600)
try:
children = list(TEMP_ROOT.iterdir())
except OSError:
return
for child in children:
try:
if child.is_dir() and child.stat().st_mtime < threshold:
shutil.rmtree(child, ignore_errors=True)
except OSError:
continue
async def _save_upload(upload: UploadFile) -> tuple[str, Path, int, str]:
_cleanup_old_files()
filename = _safe_filename(upload.filename)
extension = Path(filename).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
await upload.close()
raise HTTPException(400, "Unsupported installer file type.")
token = uuid.uuid4().hex
job_dir = TEMP_ROOT / token
job_dir.mkdir(mode=0o700, parents=True, exist_ok=False)
target = job_dir / filename
digest = hashlib.sha256()
total = 0
limit = MAX_UPLOAD_MB * 1024 * 1024
try:
with target.open("wb") as handle:
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > limit:
raise HTTPException(413, f"Maximum upload size exceeded ({MAX_UPLOAD_MB} MB).")
digest.update(chunk)
handle.write(chunk)
except Exception:
shutil.rmtree(job_dir, ignore_errors=True)
raise
finally:
await upload.close()
if total == 0:
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(400, "The uploaded installer is empty.")
return token, target, total, digest.hexdigest()
def _analysis_file(token: str) -> tuple[Path, dict[str, Any]]:
if not re.fullmatch(r"[0-9a-f]{32}", token or ""):
raise HTTPException(400, "Invalid analysis token.")
job_dir = TEMP_ROOT / token
meta_file = job_dir / "analysis.json"
if not meta_file.is_file():
raise HTTPException(404, "Analysis is no longer available.")
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise HTTPException(404, "Analysis metadata is not available.") from exc
target = job_dir / _safe_filename(meta.get("filename"))
if not target.is_file():
raise HTTPException(404, "Installer file is no longer available.")
return target, meta
def _scan_needles() -> dict[bytes, str]:
result: dict[bytes, str] = {}
markers = [marker for rule in INSTALLER_RULES for marker, _, _ in rule["markers"]]
markers.extend(SWITCH_MARKERS)
for marker in markers:
lower = marker.lower()
normalized = lower.decode("ascii", errors="ignore")
result[lower] = normalized
result[normalized.encode("utf-16le")] = normalized
return result
SCAN_NEEDLES = _scan_needles()
def _scan_file_markers(path: Path) -> set[str]:
found: set[str] = set()
tail = b""
with path.open("rb") as handle:
while True:
chunk = handle.read(SCAN_CHUNK_BYTES)
if not chunk:
break
data = (tail + chunk).lower()
for raw, normalized in SCAN_NEEDLES.items():
if normalized not in found and raw in data:
found.add(normalized)
tail = data[-SCAN_OVERLAP_BYTES:]
return found
def _pe_metadata(path: Path) -> dict[str, Any]:
result: dict[str, Any] = {
"architecture": "",
"company_name": "",
"product_name": "",
"product_version": "",
"file_version": "",
"original_filename": "",
"signature_present": None,
}
if pefile is None:
return result
try:
pe = pefile.PE(str(path), fast_load=True)
result["architecture"] = {
0x014C: "x86",
0x8664: "x64",
0xAA64: "arm64",
}.get(int(pe.FILE_HEADER.Machine), hex(int(pe.FILE_HEADER.Machine)))
security_index = pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_SECURITY"]
security = pe.OPTIONAL_HEADER.DATA_DIRECTORY[security_index]
result["signature_present"] = bool(security.VirtualAddress and security.Size)
resource_index = pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]
pe.parse_data_directories(directories=[resource_index])
values: dict[str, str] = {}
for file_info in getattr(pe, "FileInfo", []) or []:
items = file_info if isinstance(file_info, list) else [file_info]
for item in items:
key = getattr(item, "Key", b"")
if isinstance(key, bytes):
key = key.decode(errors="ignore")
if key != "StringFileInfo":
continue
for string_table in getattr(item, "StringTable", []) or []:
for raw_key, raw_value in (getattr(string_table, "entries", {}) or {}).items():
k = raw_key.decode(errors="ignore") if isinstance(raw_key, bytes) else str(raw_key)
v = raw_value.decode(errors="ignore") if isinstance(raw_value, bytes) else str(raw_value)
values[k] = v.strip()
result["company_name"] = values.get("CompanyName", "")
result["product_name"] = values.get("ProductName", "")
result["product_version"] = values.get("ProductVersion", "")
result["file_version"] = values.get("FileVersion", "")
result["original_filename"] = values.get("OriginalFilename", "")
pe.close()
except Exception:
return result
return result
def _run_parser(command: list[str], timeout: int = 20) -> str:
try:
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
except (OSError, subprocess.SubprocessError):
return ""
return completed.stdout if completed.returncode == 0 else ""
def _msi_properties(path: Path) -> dict[str, str]:
if shutil.which("msiinfo") is None:
return {}
output = _run_parser(["msiinfo", "export", str(path), "Property"])
wanted = {"ProductName", "ProductVersion", "Manufacturer", "ProductCode", "UpgradeCode", "ALLUSERS"}
result: dict[str, str] = {}
for line in output.splitlines():
parts = line.split(" ")
if len(parts) >= 2 and parts[0].strip() in wanted:
result[parts[0].strip()] = parts[1].strip()
return result
def _msix_metadata(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
try:
with zipfile.ZipFile(path, "r") as archive:
manifest_name = next((name for name in archive.namelist() if name.lower().endswith("appxmanifest.xml")), "")
if not manifest_name:
return result
root = ElementTree.fromstring(archive.read(manifest_name))
identity = next((node for node in root.iter() if node.tag.endswith("Identity")), None)
properties = next((node for node in root.iter() if node.tag.endswith("Properties")), None)
if identity is not None:
result["identity_name"] = identity.attrib.get("Name", "")
result["publisher"] = identity.attrib.get("Publisher", "")
result["version"] = identity.attrib.get("Version", "")
result["architecture"] = identity.attrib.get("ProcessorArchitecture", "")
if properties is not None:
for node in properties:
if node.tag.endswith("DisplayName") and node.text:
result["display_name"] = node.text.strip()
break
except Exception:
return {}
return result
def _detect_exe(found_markers: set[str]) -> tuple[str, str, int, list[str], list[dict[str, Any]]]:
candidates: list[dict[str, Any]] = []
for rule in INSTALLER_RULES:
score = 0
signals: list[str] = []
for marker, points, signal_key in rule["markers"]:
if marker.decode("ascii").lower() in found_markers:
score += points
signals.append(signal_key)
if score:
candidates.append({
"key": rule["key"],
"label": rule["label"],
"confidence": min(score, 99),
"signals": signals,
})
candidates.sort(key=lambda item: item["confidence"], reverse=True)
if not candidates:
return "unknown_exe", "Unknown EXE installer", 25, ["setup_analyzer.signal.exe"], []
primary = candidates[0]
return (
str(primary["key"]),
str(primary["label"]),
max(55, int(primary["confidence"])),
list(primary["signals"]),
candidates,
)
def _command_defaults(installer_type: str, filename: str, product_code: str, product_name: str) -> dict[str, Any]:
quoted = f'"{filename}"'
result: dict[str, Any] = {
"install_arguments": "",
"install_command": quoted,
"uninstall_command": "",
"success_codes": [0],
"reboot_codes": [],
"detect_method": "registry_display_name" if product_name else "manual",
"command_confidence": "none",
"warning_keys": [],
}
if installer_type == "msi":
result.update(
install_arguments="/qn /norestart",
install_command=f"msiexec.exe /i {quoted} /qn /norestart",
success_codes=[0, 1641, 3010],
reboot_codes=[1641, 3010],
detect_method="msi_product_code" if product_code else "registry_display_name",
command_confidence="high",
)
if product_code:
result["uninstall_command"] = f'msiexec.exe /x "{product_code}" /qn /norestart'
elif installer_type == "msp":
result.update(
install_arguments="/qn /norestart",
install_command=f"msiexec.exe /p {quoted} /qn /norestart",
success_codes=[0, 1641, 3010],
reboot_codes=[1641, 3010],
command_confidence="high",
)
elif installer_type == "msu":
result.update(
install_arguments="/quiet /norestart",
install_command=f"wusa.exe {quoted} /quiet /norestart",
success_codes=[0, 3010, 2359302],
reboot_codes=[3010],
command_confidence="high",
)
elif installer_type == "inno":
args = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-"
result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 3010], reboot_codes=[3010], command_confidence="high")
elif installer_type == "nsis":
args = "/S"
result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 3010], reboot_codes=[3010], command_confidence="high")
elif installer_type == "wix_burn":
args = "/quiet /norestart"
result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 1641, 3010], reboot_codes=[1641, 3010], command_confidence="high")
elif installer_type == "installshield":
args = '/s /v"/qn /norestart"'
result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 1641, 3010], reboot_codes=[1641, 3010], command_confidence="medium")
result["warning_keys"].append("setup_analyzer.warning.installshield")
elif installer_type == "advanced_installer":
args = "/exenoui /qn /norestart"
result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 1641, 3010], reboot_codes=[1641, 3010], command_confidence="medium")
result["warning_keys"].append("setup_analyzer.warning.advanced_installer")
elif installer_type == "squirrel":
args = "--silent"
result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0], command_confidence="low")
result["warning_keys"].append("setup_analyzer.warning.squirrel")
elif installer_type in {"msix", "appx"}:
result.update(
install_arguments="",
install_command=f"Add-AppxPackage -Path {quoted}",
success_codes=[0],
detect_method="appx_package",
command_confidence="medium",
)
result["warning_keys"].append("setup_analyzer.warning.appx_context")
elif installer_type in {"7zip_sfx", "winrar_sfx"}:
result["warning_keys"].append("setup_analyzer.warning.sfx")
else:
result["warning_keys"].append("setup_analyzer.warning.unknown")
return result
def analyze_file(path: Path, token: str, size: int, sha256: str) -> dict[str, Any]:
filename = path.name
extension = path.suffix.lower()
with path.open("rb") as handle:
magic = handle.read(8)
found_markers = _scan_file_markers(path)
pe = _pe_metadata(path) if extension == ".exe" or magic[:2] == b"MZ" else {}
msi = _msi_properties(path) if extension in {".msi", ".msp"} else {}
msix = _msix_metadata(path) if extension in {".msix", ".appx"} else {}
installer_type = "unknown"
installer_label = "Unknown package"
confidence = 20
signal_keys = ["setup_analyzer.signal.unknown"]
candidates: list[dict[str, Any]] = []
if extension == ".msi":
installer_type, installer_label, confidence = "msi", "Windows Installer (MSI)", 99
signal_keys = ["setup_analyzer.signal.msi_extension"]
elif extension == ".msp":
installer_type, installer_label, confidence = "msp", "Windows Installer Patch (MSP)", 99
signal_keys = ["setup_analyzer.signal.msp_extension"]
elif extension == ".msix":
installer_type, installer_label, confidence = "msix", "MSIX", 99
signal_keys = ["setup_analyzer.signal.msix"]
elif extension == ".appx":
installer_type, installer_label, confidence = "appx", "AppX", 99
signal_keys = ["setup_analyzer.signal.appx"]
elif extension == ".msu":
installer_type, installer_label, confidence = "msu", "Windows Update Standalone Package (MSU)", 99
signal_keys = ["setup_analyzer.signal.msu"]
elif extension == ".exe" or magic[:2] == b"MZ":
installer_type, installer_label, confidence, signal_keys, candidates = _detect_exe(found_markers)
product_name = msi.get("ProductName") or msix.get("display_name") or msix.get("identity_name") or pe.get("product_name") or ""
product_version = msi.get("ProductVersion") or msix.get("version") or pe.get("product_version") or pe.get("file_version") or ""
manufacturer = msi.get("Manufacturer") or msix.get("publisher") or pe.get("company_name") or ""
architecture = msix.get("architecture") or pe.get("architecture") or ""
product_code = msi.get("ProductCode", "")
upgrade_code = msi.get("UpgradeCode", "")
identity_text = f"{filename} {product_name} {manufacturer}".casefold()
if "greenshot" in identity_text:
product_name = product_name or "Greenshot"
manufacturer = manufacturer or "Greenshot"
if not product_version:
version_match = re.search(r"(?i)greenshot[-_ ]installer[-_ ](\d+(?:\.\d+){1,3})", filename)
if version_match:
product_version = version_match.group(1)
defaults = _command_defaults(installer_type, filename, product_code, product_name)
if installer_type == "inno" and "greenshot" in identity_text:
arguments = str(defaults.get("install_arguments") or "").strip()
if not re.search(r"(?i)(^|\s)/(ALLUSERS|CURRENTUSER)(?=\s|$)", arguments):
arguments += " /ALLUSERS"
if not re.search(r"(?i)(^|\s)/DIR=", arguments):
arguments += ' /DIR="C:\\Program Files\\Greenshot"'
defaults["install_arguments"] = arguments.strip()
defaults["install_command"] = f'"{filename}" {arguments.strip()}'
defaults["detect_method"] = "registry_display_name"
warning_keys = list(defaults.pop("warning_keys", []))
if extension in {".msi", ".msp"} and not msi:
warning_keys.append("setup_analyzer.warning.msiinfo")
if (extension == ".exe" or magic[:2] == b"MZ") and pefile is None:
warning_keys.append("setup_analyzer.warning.pefile")
embedded_switches = [marker.decode("ascii") for marker in SWITCH_MARKERS if marker.decode("ascii").lower() in found_markers]
identity_text = f"{filename} {product_name} {manufacturer}".casefold()
suppress_browser_default = "greenshot" in identity_text
process_names_default = "Greenshot.exe" if "greenshot" in identity_text else ""
analysis = {
"token": token,
"filename": filename,
"size": size,
"size_human": _human_size(size),
"sha256": sha256,
"extension": extension,
"installer_type": installer_type,
"installer_label": installer_label,
"confidence": confidence,
"candidates": candidates,
"product_name": product_name,
"product_version": product_version,
"manufacturer": manufacturer,
"architecture": architecture,
"product_code": product_code,
"upgrade_code": upgrade_code,
"signature_present": pe.get("signature_present") if pe else None,
"original_filename": pe.get("original_filename", "") if pe else "",
"signal_keys": signal_keys,
"embedded_switches": embedded_switches,
"suppress_browser_default": suppress_browser_default,
"process_names_default": process_names_default,
"warning_keys": warning_keys,
"analyzed_at": datetime.now(timezone.utc).isoformat(),
"msiinfo_available": shutil.which("msiinfo") is not None,
"pefile_available": pefile is not None,
**defaults,
}
(path.parent / "analysis.json").write_text(json.dumps(analysis, ensure_ascii=True, indent=2), encoding="utf-8")
return analysis
def _form_value(value: str | None, fallback: str = "") -> str:
return (value if value is not None else fallback).strip()
def _parse_codes(value: str, fallback: list[int]) -> list[int]:
result: list[int] = []
for item in re.split(r"[,; ]+", value.strip()):
if not item:
continue
try:
number = int(item)
except ValueError:
continue
if number not in result:
result.append(number)
return result or list(fallback)
def _ps_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _powershell_install(
filename: str,
installer_type: str,
install_arguments: str,
success_codes: list[int],
reboot_codes: list[int],
timeout_seconds: int = 600,
suppress_browser: bool = False,
) -> str:
success = ", ".join(str(code) for code in success_codes)
reboot = ", ".join(str(code) for code in reboot_codes) or "-999999"
timeout_seconds = max(30, min(int(timeout_seconds or 600), 86400))
lines = [
"$ErrorActionPreference = 'Stop'",
"Set-StrictMode -Version Latest",
"",
"$packageDir = $PSScriptRoot",
f"$installer = Join-Path $packageDir {_ps_quote(filename)}",
f"$arguments = {_ps_quote(install_arguments)}",
f"$successCodes = @({success})",
f"$rebootCodes = @({reboot})",
f"$timeoutSeconds = {timeout_seconds}",
"$suppressBrowser = $" + ("true" if suppress_browser else "false"),
"",
"function Stop-InstallerBrowserDescendants([int]$RootPid) {",
"\tif (-not $suppressBrowser) { return }",
"\t$browserNames = @('msedge.exe','chrome.exe','firefox.exe','brave.exe','opera.exe','iexplore.exe')",
"\ttry { $rows = @(Get-CimInstance Win32_Process -ErrorAction Stop) } catch { return }",
"\t$descendants = @($RootPid)",
"\t$changed = $true",
"\twhile ($changed) {",
"\t\t$changed = $false",
"\t\tforeach ($row in $rows) {",
"\t\t\t$pidValue = [int]$row.ProcessId",
"\t\t\t$parentValue = [int]$row.ParentProcessId",
"\t\t\tif (($descendants -contains $parentValue) -and ($descendants -notcontains $pidValue)) {",
"\t\t\t\t$descendants += $pidValue",
"\t\t\t\t$changed = $true",
"\t\t\t}",
"\t\t}",
"\t}",
"\tforeach ($row in $rows) {",
"\t\t$pidValue = [int]$row.ProcessId",
"\t\t$nameValue = ([string]$row.Name).ToLowerInvariant()",
"\t\tif (($pidValue -ne $RootPid) -and ($descendants -contains $pidValue) -and ($browserNames -contains $nameValue)) {",
"\t\t\ttry { Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue } catch {}",
"\t\t}",
"\t}",
"}",
"",
"function Wait-InstallerProcess([System.Diagnostics.Process]$Process) {",
"\ttry {",
"\t\tWait-Process -Id $Process.Id -Timeout $timeoutSeconds -ErrorAction Stop",
"\t} catch {",
"\t\ttry { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch {}",
"\t\tWrite-Error (\"Installer timeout after $timeoutSeconds seconds.\")",
"\t\texit 1460",
"\t}",
"\tif ($suppressBrowser) {",
"\t\tStart-Sleep -Milliseconds 1500",
"\t\tStop-InstallerBrowserDescendants -RootPid $Process.Id",
"\t}",
"\t$Process.Refresh()",
"\treturn [int]$Process.ExitCode",
"}",
"",
"if (-not (Test-Path -LiteralPath $installer)) {",
'\tWrite-Error "Installer not found: $installer"',
"\texit 2",
"}",
"",
]
if installer_type == "msi":
lines.extend([
"$processArguments = '/i \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -PassThru -NoNewWindow",
])
elif installer_type == "msp":
lines.extend([
"$processArguments = '/p \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -PassThru -NoNewWindow",
])
elif installer_type == "msu":
lines.extend([
"$processArguments = '\"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -PassThru -NoNewWindow",
])
elif installer_type in {"msix", "appx"}:
lines.extend(["Add-AppxPackage -Path $installer -ErrorAction Stop", "exit 0"])
return "\n".join(lines) + "\n"
else:
lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -PassThru -NoNewWindow")
lines.extend([
"$exitCode = Wait-InstallerProcess -Process $process",
'Write-Output "Installer exit code: $exitCode"',
"if ($successCodes -notcontains $exitCode) { exit $exitCode }",
"if ($rebootCodes -contains $exitCode) { exit 3010 }",
"exit 0",
])
return "\n".join(lines) + "\n"
def _powershell_detect(product_code: str, product_name: str, installer_type: str) -> str:
if product_code:
return f"""$ErrorActionPreference = 'SilentlyContinue'\n$productCode = {_ps_quote(product_code)}\n$paths = @(\n \"HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode\",\n \"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode\"\n)\nif ($paths | Where-Object {{ Test-Path -LiteralPath $_ }}) {{ exit 0 }}\nexit 1\n"""
if installer_type in {"msix", "appx"} and product_name:
return f"""$ErrorActionPreference = 'SilentlyContinue'\n$name = {_ps_quote(product_name)}\n$package = Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like \"$name*\" }} | Select-Object -First 1\nif ($null -ne $package) {{ exit 0 }}\nexit 1\n"""
if product_name:
return f"""$ErrorActionPreference = 'SilentlyContinue'\n$displayName = {_ps_quote(product_name)}\n$roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n)\n$match = Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue | Where-Object {{ $_.DisplayName -eq $displayName }} | Select-Object -First 1\nif ($null -ne $match) {{ exit 0 }}\nexit 1\n"""
return "Write-Output 'No automatic detection rule is available for this package.'\nexit 2\n"
def _powershell_uninstall(product_code: str, product_name: str, installer_type: str) -> str:
if product_code:
return f"""$ErrorActionPreference = 'Stop'\n$productCode = {_ps_quote(product_code)}\n$arguments = '/x \"' + $productCode + '\" /qn /norestart'\n$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $arguments -Wait -PassThru -NoNewWindow\nif (@(0, 1641, 3010) -notcontains [int]$process.ExitCode) {{ exit [int]$process.ExitCode }}\nif (@(1641, 3010) -contains [int]$process.ExitCode) {{ exit 3010 }}\nexit 0\n"""
if installer_type in {"msix", "appx"} and product_name:
return f"""$ErrorActionPreference = 'Stop'\n$name = {_ps_quote(product_name)}\n$packages = Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like \"$name*\" }}\nforeach ($package in $packages) {{ Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop }}\nexit 0\n"""
if product_name:
return f"""$ErrorActionPreference = 'Stop'\n$displayName = {_ps_quote(product_name)}\n$roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n)\n$entry = Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue | Where-Object {{ $_.DisplayName -eq $displayName }} | Select-Object -First 1\nif ($null -eq $entry) {{ exit 0 }}\n$command = $entry.QuietUninstallString\nif ([string]::IsNullOrWhiteSpace($command)) {{ $command = $entry.UninstallString }}\nif ([string]::IsNullOrWhiteSpace($command)) {{ Write-Error 'No uninstall command was found in the registry.'; exit 3 }}\n$process = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d', '/s', '/c', $command) -Wait -PassThru -NoNewWindow\nif (@(0, 1641, 3010) -notcontains [int]$process.ExitCode) {{ exit [int]$process.ExitCode }}\nif (@(1641, 3010) -contains [int]$process.ExitCode) {{ exit 3010 }}\nexit 0\n"""
return "Write-Error 'No automatic uninstall rule is available for this package.'\nexit 2\n"
def _safe_package_name(name: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip("-")
return cleaned[:80] or "software-package"
def _export_values(
meta: dict[str, Any],
product_name: str,
product_version: str,
manufacturer: str,
architecture: str,
install_arguments: str,
timeout_seconds: int,
run_as: str,
success_codes: str,
reboot_codes: str,
suppress_browser: bool = False,
process_names: str = "",
) -> dict[str, Any]:
return {
"product_name": _form_value(product_name, meta.get("product_name", "")),
"product_version": _form_value(product_version, meta.get("product_version", "")),
"manufacturer": _form_value(manufacturer, meta.get("manufacturer", "")),
"architecture": _form_value(architecture, meta.get("architecture", "")),
"install_arguments": _form_value(install_arguments, meta.get("install_arguments", "")),
"timeout_seconds": max(30, min(int(timeout_seconds), 86400)),
"run_as": run_as if run_as in {"system", "user"} else "system",
"success_codes": _parse_codes(success_codes, meta.get("success_codes") or [0]),
"reboot_codes": _parse_codes(reboot_codes, meta.get("reboot_codes") or []),
"suppress_browser": bool(suppress_browser),
"process_names": normalize_process_names(process_names),
}
def _build_package_manifest(
target: Path,
meta: dict[str, Any],
values: dict[str, Any],
) -> dict[str, Any]:
detection_method = str(meta.get("detect_method") or "manual")
if meta.get("product_code"):
detection_method = "msi_product_code"
elif meta.get("installer_type") in {"msix", "appx"} and values.get("product_name"):
detection_method = "appx_package"
elif values.get("product_name"):
detection_method = "registry_display_name"
return {
"schema": "assetmanager-software-package-v1",
"name": values["product_name"] or target.stem,
"version": values["product_version"],
"vendor": values["manufacturer"],
"architecture": values["architecture"],
"platform": "windows",
"installer_type": meta.get("installer_type", ""),
"installer_file": target.name,
"install": {
"script": "install.ps1",
"arguments": values["install_arguments"],
"timeout_seconds": values["timeout_seconds"],
"run_as": values["run_as"],
"success_codes": values["success_codes"],
"reboot_codes": values["reboot_codes"],
"suppress_browser": values["suppress_browser"],
},
"uninstall": {"script": "uninstall.ps1"},
"process_control": {
"process_names": values["process_names"],
"grace_seconds": 5,
"force_close": True,
},
"detection": {
"script": "detect.ps1",
"method": detection_method,
"product_code": meta.get("product_code", ""),
"display_name": values["product_name"],
"display_version": values["product_version"],
"publisher": values["manufacturer"],
},
"analysis": {
"sha256": meta.get("sha256", ""),
"confidence": meta.get("confidence", 0),
"command_confidence": meta.get("command_confidence", "none"),
},
}
def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[Request], None]) -> None:
@app.get("/software/setup-analyzer")
def setup_analyzer_page(request: Request):
require_admin(request)
return templates.TemplateResponse("setup_analyzer.html", {"request": request, "analysis": None, "max_upload_mb": MAX_UPLOAD_MB})
@app.post("/software/setup-analyzer/analyze")
async def setup_analyzer_analyze(request: Request, installer: UploadFile = File(...)):
require_admin(request)
token, path, size, sha256 = await _save_upload(installer)
analysis = analyze_file(path, token, size, sha256)
return templates.TemplateResponse("setup_analyzer.html", {"request": request, "analysis": analysis, "max_upload_mb": MAX_UPLOAD_MB})
@app.post("/software/setup-analyzer/export/powershell")
def setup_analyzer_export_powershell(
request: Request,
token: str = Form(...),
product_name: str = Form(""),
product_version: str = Form(""),
manufacturer: str = Form(""),
architecture: str = Form(""),
install_arguments: str = Form(""),
timeout_seconds: int = Form(600),
run_as: str = Form("system"),
success_codes: str = Form("0"),
reboot_codes: str = Form(""),
suppress_browser: bool = Form(False),
process_names: str = Form(""),
):
require_admin(request)
target, meta = _analysis_file(token)
values = _export_values(meta, product_name, product_version, manufacturer, architecture, install_arguments, timeout_seconds, run_as, success_codes, reboot_codes, suppress_browser, process_names)
package = _build_package_manifest(target, meta, values)
package, _profile_notes = normalize_package_manifest(package)
script = build_generated_install_script(package)
name = _safe_package_name(values["product_name"] or target.stem)
headers = {"Content-Disposition": f'attachment; filename="{name}-install.ps1"'}
return StreamingResponse(io.BytesIO(script.encode("utf-8")), media_type="text/plain", headers=headers)
@app.post("/software/setup-analyzer/export/package")
def setup_analyzer_export_package(
request: Request,
token: str = Form(...),
product_name: str = Form(""),
product_version: str = Form(""),
manufacturer: str = Form(""),
architecture: str = Form(""),
install_arguments: str = Form(""),
timeout_seconds: int = Form(600),
run_as: str = Form("system"),
success_codes: str = Form("0"),
reboot_codes: str = Form(""),
suppress_browser: bool = Form(False),
process_names: str = Form(""),
):
require_admin(request)
target, meta = _analysis_file(token)
values = _export_values(meta, product_name, product_version, manufacturer, architecture, install_arguments, timeout_seconds, run_as, success_codes, reboot_codes, suppress_browser, process_names)
package = _build_package_manifest(target, meta, values)
package, _profile_notes = normalize_package_manifest(package)
install_script = build_generated_install_script(package)
detect_script = build_generated_detection_script(package)
uninstall_script = build_generated_uninstall_script(package)
public_analysis = {key: value for key, value in meta.items() if key != "token"}
public_analysis.update(values)
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.write(target, arcname=target.name)
archive.writestr("install.ps1", install_script)
archive.writestr("uninstall.ps1", uninstall_script)
archive.writestr("detect.ps1", detect_script)
archive.writestr("package.json", json.dumps(package, ensure_ascii=True, indent=2))
archive.writestr("analysis.json", json.dumps(public_analysis, ensure_ascii=True, indent=2))
stream.seek(0)
name = _safe_package_name(values["product_name"] or target.stem)
version = _safe_package_name(values["product_version"]) if values["product_version"] else ""
filename = f"{name}-{version}.zip" if version else f"{name}.zip"
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return StreamingResponse(stream, media_type="application/zip", headers=headers)
@app.post("/software/setup-analyzer/create-package")
def setup_analyzer_create_package(
request: Request,
token: str = Form(...),
product_name: str = Form(""),
product_version: str = Form(""),
manufacturer: str = Form(""),
architecture: str = Form(""),
install_arguments: str = Form(""),
timeout_seconds: int = Form(600),
run_as: str = Form("system"),
success_codes: str = Form("0"),
reboot_codes: str = Form(""),
suppress_browser: bool = Form(False),
process_names: str = Form(""),
db: Session = Depends(get_db),
):
require_admin(request)
target, meta = _analysis_file(token)
values = _export_values(
meta,
product_name,
product_version,
manufacturer,
architecture,
install_arguments,
timeout_seconds,
run_as,
success_codes,
reboot_codes,
suppress_browser,
process_names,
)
manifest = _build_package_manifest(target, meta, values)
manifest, _profile_notes = normalize_package_manifest(manifest)
install_script = build_generated_install_script(manifest)
detect_script = build_generated_detection_script(manifest)
uninstall_script = build_generated_uninstall_script(manifest)
public_analysis = {key: value for key, value in meta.items() if key != "token"}
public_analysis.update(values)
package = SoftwarePackage(
name=unique_package_name(
db,
values["product_name"] or target.stem,
values["product_version"],
),
description=(
f"{values['manufacturer']} | {values['product_name'] or target.stem} "
f"{values['product_version']} | Setup Analyzer"
).strip(" |"),
package_type="deployment",
enabled=True,
is_system=False,
command_windows="install.ps1",
callback_timeout_minutes=max(
5,
min(((values["timeout_seconds"] + 59) // 60) + 5, 240),
),
)
db.add(package)
try:
db.flush()
manifest["assetmanager_package_id"] = package.id
write_package_storage(
package.id,
target,
install_script,
uninstall_script,
detect_script,
manifest,
public_analysis,
)
db.commit()
except Exception:
db.rollback()
if package.id:
shutil.rmtree(package_directory(package.id), ignore_errors=True)
raise
return RedirectResponse(
f"/software/packages/{package.id}?created=1",
status_code=303,
)
+81 -1
View File
@@ -16,6 +16,7 @@ from .config import load_config
from .database import SessionLocal
from .models import Asset, SoftwareJob, JobEvent, JobDefinition
from .job_state import sync_asset_job_state
from .software_packages import build_deployment_user_script, load_package_manifest, package_payload_files
_DISPATCH_SLOT_LOCK = threading.Lock()
@@ -376,6 +377,22 @@ def _definition_execution_artifacts(db, platform: str, callback_url: str, job_id
def _job_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]:
job = db.get(SoftwareJob, job_id)
if job and job.job_type == 'software_deployment':
if platform != 'windows':
raise RuntimeError('Softwarepakete werden derzeit nur fuer Windows-Assets unterstuetzt.')
manifest = load_package_manifest(job.package_id)
user_script = build_deployment_user_script(manifest, job.action)
script = _runtime_wrap_script('powershell', user_script)
script = _replace_job_placeholders(
script,
asset,
callback_url,
job_id,
job.parameters if isinstance(job.parameters, dict) else {},
)
return script, 'powershell', True
configured=_definition_execution_artifacts(db,platform,callback_url,job_id,asset,definition_id)
if configured[0]:
return configured
@@ -383,7 +400,7 @@ def _job_execution_artifacts(db, platform: str, callback_url: str, job_id: int,
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.')
raise HTTPException(400, 'Fuer dieses Betriebssystem ist kein sicherer MeshCtrl-Handler verfuegbar.')
def _script_extension(interpreter: str) -> str:
@@ -551,6 +568,12 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
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 {}
try:
execution_timeout_seconds = int(job_parameters.get('_execution_timeout_seconds') or 0)
except (TypeError, ValueError):
execution_timeout_seconds = 0
if execution_timeout_seconds > 0:
timeout = max(timeout, min(execution_timeout_seconds + 180, 86400))
diagnostics=[
'--- File dispatcher diagnostics ---',
f'Dispatch interval configured: {dispatch_delay_seconds:g} seconds',
@@ -575,6 +598,14 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
f'Definition interpreter: {job_parameters.get("definition_interpreter") or interpreter}',
f'Definition source type: {job_parameters.get("definition_source_type") or "-"}',
]
elif job.job_type == 'software_deployment':
diagnostics += [
f'Deployment action: {job.action}',
f'Deployment product: {job_parameters.get("product_name") or "-"}',
f'Deployment version: {job_parameters.get("product_version") or "-"}',
f'Installer type: {job_parameters.get("installer_type") or "-"}',
f'Execution timeout: {execution_timeout_seconds or timeout} seconds',
]
if upload_required:
suffix=_script_extension(interpreter)
@@ -607,6 +638,55 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
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()}')
if job.job_type == 'software_deployment':
payload_files = package_payload_files(job.package_id, job.action)
diagnostics += [f'Package payload files: {len(payload_files)}']
for package_file in payload_files:
remote_package_file = remote_dir + '\\' + package_file.name
package_uploads = []
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
package_uploads.append(package_uploaded)
diagnostics += [
f'--- Package upload {package_file.name} attempt 1 stdout ---', package_uploaded.stdout or '',
f'--- Package upload {package_file.name} attempt 1 stderr ---', package_uploaded.stderr or '',
f'Package upload {package_file.name} attempt 1 return code: {package_uploaded.returncode}',
]
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
if not package_ok:
package_repair = _prepare_remote_directory(
cfg,
asset,
password,
job.platform,
remote_dir,
min(timeout, 120),
remote_package_file,
)
diagnostics += [
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
f'--- Package upload {package_file.name} repair stderr ---', package_repair.stderr or '',
f'Package upload {package_file.name} repair return code: {package_repair.returncode}',
]
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
package_uploads.append(package_uploaded)
diagnostics += [
f'--- Package upload {package_file.name} attempt 2 stdout ---', package_uploaded.stdout or '',
f'--- Package upload {package_file.name} attempt 2 stderr ---', package_uploaded.stderr or '',
f'Package upload {package_file.name} attempt 2 return code: {package_uploaded.returncode}',
]
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
if not package_ok:
combined = '\n\n'.join(
((item.stdout or '') + '\n' + (item.stderr or '')).strip()
for item in package_uploads
if ((item.stdout or '') + (item.stderr or '')).strip()
)
raise RuntimeError(
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {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)
+902
View File
@@ -0,0 +1,902 @@
from __future__ import annotations
import json
import os
import re
import shutil
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from .models import SoftwarePackage
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages"))
PACKAGE_ROOT.mkdir(parents=True, exist_ok=True)
def package_directory(package_id: int) -> Path:
package_id = int(package_id)
if package_id <= 0:
raise ValueError("invalid package id")
return PACKAGE_ROOT / str(package_id)
def _safe_member_name(value: str) -> str:
name = Path(str(value or "")).name
if not name or name in {".", ".."}:
raise ValueError("invalid package member name")
return name
def _safe_package_label(value: str) -> str:
text = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
return re.sub(r"\s+", " ", text)[:180]
def unique_package_name(db: Session, product_name: str, product_version: str) -> str:
base = _safe_package_label(
f"{product_name.strip()} {product_version.strip()}".strip()
) or "Software package"
candidate = base
counter = 2
while db.query(SoftwarePackage.id).filter(SoftwarePackage.name == candidate).first() is not None:
candidate = _safe_package_label(f"{base} ({counter})")
counter += 1
return candidate
def write_package_storage(
package_id: int,
installer_path: Path,
install_script: str,
uninstall_script: str,
detect_script: str,
manifest: dict[str, Any],
analysis: dict[str, Any],
) -> Path:
target = package_directory(package_id)
temporary = PACKAGE_ROOT / f".{package_id}-{uuid.uuid4().hex}.tmp"
shutil.rmtree(temporary, ignore_errors=True)
temporary.mkdir(parents=True, exist_ok=False)
try:
installer_name = _safe_member_name(manifest.get("installer_file", installer_path.name))
shutil.copy2(installer_path, temporary / installer_name)
(temporary / "install.ps1").write_text(install_script, encoding="utf-8", newline="\n")
(temporary / "uninstall.ps1").write_text(uninstall_script, encoding="utf-8", newline="\n")
(temporary / "detect.ps1").write_text(detect_script, encoding="utf-8", newline="\n")
(temporary / "package.json").write_text(
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
(temporary / "analysis.json").write_text(
json.dumps(analysis, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
if target.exists():
shutil.rmtree(target)
temporary.replace(target)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
return target
def load_package_manifest(package_id: int) -> dict[str, Any]:
manifest_path = package_directory(package_id) / "package.json"
if not manifest_path.is_file():
raise FileNotFoundError(f"Package manifest not found: {manifest_path}")
data = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("package manifest is not an object")
if str(data.get("schema") or "") not in {
"assetmanager-software-package-v1",
"assetmanager-setup-analyzer-package-v1",
}:
raise ValueError("unsupported package manifest schema")
normalized, _notes = normalize_package_manifest(data)
if normalized != data:
manifest_path.write_text(json.dumps(normalized, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
return normalized
def package_payload_files(package_id: int, action: str) -> list[Path]:
manifest = load_package_manifest(package_id)
refresh_generated_package_runtime_scripts(package_id, manifest)
root = package_directory(package_id)
action = str(action or "").strip().lower()
if action not in {"install", "uninstall", "reinstall"}:
raise ValueError("unsupported deployment action")
names: list[str] = ["package.json"]
if action in {"install", "reinstall"}:
names.append(_safe_member_name(manifest.get("installer_file", "")))
names.append(_safe_member_name((manifest.get("install") or {}).get("script", "install.ps1")))
if action in {"uninstall", "reinstall"}:
names.append(_safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1")))
if str((manifest.get("detection") or {}).get("method") or "manual") != "manual":
names.append(_safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1")))
result: list[Path] = []
seen: set[str] = set()
for name in names:
if not name or name in seen:
continue
seen.add(name)
path = root / name
if not path.is_file():
raise FileNotFoundError(f"Package file not found: {path}")
result.append(path)
return result
def package_storage_size(package_id: int) -> int:
root = package_directory(package_id)
if not root.is_dir():
return 0
return sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
def human_size(size: int) -> str:
value = float(max(0, int(size or 0)))
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024.0 or unit == "TB":
return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}"
value /= 1024.0
return f"{int(size or 0)} B"
def package_summary(package: SoftwarePackage) -> dict[str, Any]:
manifest: dict[str, Any] = {}
error = ""
try:
manifest = load_package_manifest(package.id)
except Exception as exc:
error = str(exc)
return {
"package": package,
"manifest": manifest,
"storage_size": package_storage_size(package.id),
"storage_error": error,
}
def package_execution_timeout_seconds(manifest: dict[str, Any]) -> int:
try:
value = int((manifest.get("install") or {}).get("timeout_seconds") or 600)
except (TypeError, ValueError):
value = 600
return max(30, min(value, 86400))
def _ps_quote(value: str) -> str:
return "'" + str(value or "").replace("'", "''") + "'"
def _package_identity_text(manifest: dict[str, Any]) -> str:
parts = [
str(manifest.get("name") or ""),
str(manifest.get("vendor") or ""),
str(manifest.get("installer_file") or ""),
str((manifest.get("detection") or {}).get("display_name") or ""),
]
return " ".join(parts).casefold()
def _append_install_argument(arguments: str, value: str, pattern: str) -> str:
arguments = str(arguments or "").strip()
if re.search(pattern, arguments, flags=re.IGNORECASE):
return arguments
return (arguments + " " + value).strip()
def normalize_process_names(value: Any) -> list[str]:
if isinstance(value, str):
raw_items = re.split(r"[\r\n,;]+", value)
elif isinstance(value, (list, tuple, set)):
raw_items = [str(item or "") for item in value]
else:
raw_items = []
result: list[str] = []
seen: set[str] = set()
for raw in raw_items:
name = str(raw or "").strip().strip('"').strip("'")
name = name.replace("\\", "/").rsplit("/", 1)[-1].strip()
if not name or any(char in name for char in "*?[]"):
continue
name = re.sub(r"[\x00-\x1f]", "", name).strip()
if not name:
continue
if not name.casefold().endswith(".exe"):
name += ".exe"
name = name[:128]
key = name.casefold()
if key in seen:
continue
seen.add(key)
result.append(name)
if len(result) >= 32:
break
return result
def _process_control_values(manifest: dict[str, Any]) -> tuple[list[str], int, bool]:
control = manifest.get("process_control") or {}
if not isinstance(control, dict):
control = {}
process_names = normalize_process_names(control.get("process_names"))
try:
grace_seconds = int(control.get("grace_seconds", 5))
except (TypeError, ValueError):
grace_seconds = 5
grace_seconds = max(0, min(grace_seconds, 30))
force_close = control.get("force_close", True)
if isinstance(force_close, str):
force_close = force_close.strip().lower() in {"1", "true", "yes", "on"}
else:
force_close = bool(force_close)
return process_names, grace_seconds, force_close
def _process_control_ps_lines(manifest: dict[str, Any], phase: str) -> list[str]:
process_names, grace_seconds, force_close = _process_control_values(manifest)
if not process_names:
return []
ps_names = ", ".join(_ps_quote(name) for name in process_names)
return [
f"$configuredProcessNames = @({ps_names})",
f"$processGraceSeconds = {grace_seconds}",
"$forceCloseProcesses = $" + ("true" if force_close else "false"),
"",
"function Stop-ConfiguredPackageProcesses([string]$Phase) {",
"\tforeach ($configuredName in $configuredProcessNames) {",
"\t\t$lookupName = [System.IO.Path]::GetFileNameWithoutExtension([string]$configuredName)",
"\t\tif ([string]::IsNullOrWhiteSpace($lookupName)) { continue }",
"\t\t$running = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\tif ($running.Count -eq 0) {",
"\t\t\tWrite-Output (\"No running configured process before \" + $Phase + \": \" + [string]$configuredName)",
"\t\t\tcontinue",
"\t\t}",
"\t\tforeach ($item in $running) {",
"\t\t\tWrite-Output (\"Running process found before \" + $Phase + \": \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
"\t\t}",
"\t\t$closeRequested = $false",
"\t\tforeach ($item in $running) {",
"\t\t\ttry {",
"\t\t\t\tif ($item.MainWindowHandle -ne 0) {",
"\t\t\t\t\t$requested = $item.CloseMainWindow()",
"\t\t\t\t\tif ($requested) {",
"\t\t\t\t\t\t$closeRequested = $true",
"\t\t\t\t\t\tWrite-Output (\"Close request sent: \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
"\t\t\t\t\t}",
"\t\t\t\t}",
"\t\t\t} catch {}",
"\t\t}",
"\t\tif ($closeRequested -and $processGraceSeconds -gt 0) {",
"\t\t\t$deadline = (Get-Date).AddSeconds($processGraceSeconds)",
"\t\t\tdo {",
"\t\t\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\t\t\tif ($remaining.Count -eq 0) { break }",
"\t\t\t\tStart-Sleep -Milliseconds 400",
"\t\t\t} while ((Get-Date) -lt $deadline)",
"\t\t}",
"\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\tif ($remaining.Count -gt 0 -and $forceCloseProcesses) {",
"\t\t\tforeach ($item in $remaining) {",
"\t\t\t\tWrite-Output (\"Process still running; forcing termination: \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
"\t\t\t\ttry { Stop-Process -Id $item.Id -Force -ErrorAction Stop } catch { throw (\"Could not terminate process \" + $item.ProcessName + '.exe; PID=' + $item.Id + ': ' + $_.Exception.Message) }",
"\t\t\t}",
"\t\t\tStart-Sleep -Milliseconds 500",
"\t\t}",
"\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\tif ($remaining.Count -gt 0) {",
"\t\t\t$ids = ($remaining | Select-Object -ExpandProperty Id) -join ','",
"\t\t\tthrow (\"Configured process is still running before \" + $Phase + \": \" + [string]$configuredName + '; PID=' + $ids)",
"\t\t}",
"\t\tWrite-Output (\"Configured process stopped before \" + $Phase + \": \" + [string]$configuredName)",
"\t}",
"}",
"",
f"Stop-ConfiguredPackageProcesses -Phase {_ps_quote(phase)}",
"",
]
def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
if not isinstance(manifest, dict):
raise ValueError("package manifest is not an object")
result = json.loads(json.dumps(manifest))
notes: list[str] = []
installer_type = str(result.get("installer_type") or "").strip().lower()
identity = _package_identity_text(result)
install = result.setdefault("install", {})
if not isinstance(install, dict):
install = {}
result["install"] = install
arguments = str(install.get("arguments") or "").strip()
if installer_type == "inno" and "greenshot" in identity:
before = arguments
current_user_scope = re.search(r"(?i)(^|\s)/CURRENTUSER(?=\s|$)", arguments) is not None
if not current_user_scope:
if not re.search(r"(?i)(^|\s)/ALLUSERS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/ALLUSERS", r"(^|\s)/ALLUSERS(?=\s|$)")
if not re.search(r"(?i)(^|\s)/DIR=", arguments):
arguments = (arguments + ' /DIR="C:\\Program Files\\Greenshot"').strip()
if arguments != before:
notes.append("greenshot_machine_scope")
install["arguments"] = arguments
if not current_user_scope:
result.setdefault("deployment_profile", "greenshot-machine")
existing_process_control = result.get("process_control")
process_names_configured = isinstance(existing_process_control, dict) and "process_names" in existing_process_control
process_control = result.setdefault("process_control", {})
if not isinstance(process_control, dict):
process_control = {}
result["process_control"] = process_control
process_names = normalize_process_names(process_control.get("process_names"))
if "greenshot" in identity and not process_names and not process_names_configured:
process_names = ["Greenshot.exe"]
notes.append("greenshot_process_control")
process_control["process_names"] = process_names
try:
grace_seconds = int(process_control.get("grace_seconds", 5))
except (TypeError, ValueError):
grace_seconds = 5
process_control["grace_seconds"] = max(0, min(grace_seconds, 30))
force_close = process_control.get("force_close", True)
if isinstance(force_close, str):
force_close = force_close.strip().lower() in {"1", "true", "yes", "on"}
process_control["force_close"] = bool(force_close)
if installer_type == "inno" and process_names:
arguments = str(install.get("arguments") or arguments).strip()
if not re.search(r"(?i)(^|\s)/(NO)?CLOSEAPPLICATIONS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/CLOSEAPPLICATIONS", r"(^|\s)/(NO)?CLOSEAPPLICATIONS(?=\s|$)")
if process_control["force_close"] and not re.search(r"(?i)(^|\s)/(NO)?FORCECLOSEAPPLICATIONS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/FORCECLOSEAPPLICATIONS", r"(^|\s)/(NO)?FORCECLOSEAPPLICATIONS(?=\s|$)")
install["arguments"] = arguments
return result, notes
def delete_package_storage(package_id: int) -> None:
root = package_directory(package_id)
if root.is_dir():
shutil.rmtree(root)
def _int_codes(value: Any, fallback: list[int]) -> list[int]:
result: list[int] = []
for item in value if isinstance(value, (list, tuple, set)) else fallback:
try:
number = int(item)
except (TypeError, ValueError):
continue
if number not in result:
result.append(number)
return result or list(fallback)
def build_generated_install_script(manifest: dict[str, Any]) -> str:
manifest, notes = normalize_package_manifest(manifest)
install = manifest.get("install") or {}
installer_type = str(manifest.get("installer_type") or "").strip().lower()
installer_file = _safe_member_name(manifest.get("installer_file", ""))
arguments = str(install.get("arguments") or "").strip()
success_codes = _int_codes(install.get("success_codes"), [0])
reboot_codes = _int_codes(install.get("reboot_codes"), [])
timeout_seconds = package_execution_timeout_seconds(manifest)
suppress_browser = bool(install.get("suppress_browser"))
greenshot_preset = "greenshot_machine_scope" in notes or str(manifest.get("deployment_profile") or "") == "greenshot-machine"
success = ", ".join(str(code) for code in success_codes)
reboot = ", ".join(str(code) for code in reboot_codes) or "-999999"
lines = [
"$ErrorActionPreference = 'Stop'",
"Set-StrictMode -Version Latest",
"",
"$packageDir = $PSScriptRoot",
f"$installer = Join-Path $packageDir {_ps_quote(installer_file)}",
f"$installerType = {_ps_quote(installer_type)}",
f"$arguments = {_ps_quote(arguments)}",
f"$successCodes = @({success})",
f"$rebootCodes = @({reboot})",
f"$timeoutSeconds = {timeout_seconds}",
"$suppressBrowser = $" + ("true" if suppress_browser else "false"),
"$greenshotPreset = $" + ("true" if greenshot_preset else "false"),
"$innoLog = $null",
"",
"if (-not (Test-Path -LiteralPath $installer)) {",
"\tWrite-Error \"Installer not found: $installer\"",
"\texit 2",
"}",
"",
"if ($installerType -eq 'inno') {",
"\t$innoLog = Join-Path $env:TEMP ('AssetManager-Inno-' + [guid]::NewGuid().ToString('N') + '.log')",
"\tif ($arguments -notmatch '(?i)(^|\\s)/LOG(=|\\s)') {",
"\t\t$arguments = ($arguments + ' /LOG=\"' + $innoLog + '\"').Trim()",
"\t}",
"}",
"",
"Write-Output (\"Installer file: \" + $installer)",
"Write-Output (\"Installer type: \" + $installerType)",
"Write-Output (\"Installer working directory: \" + $packageDir)",
"if ($greenshotPreset) { Write-Output 'Greenshot deployment preset: machine scope, C:\\Program Files\\Greenshot' }",
"",
"$browserPidsBefore = @()",
"if ($suppressBrowser) {",
"\t$browserPidsBefore = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('msedge','chrome','firefox','brave','opera','iexplore') } | Select-Object -ExpandProperty Id)",
"}",
"",
]
lines += _process_control_ps_lines(manifest, "installation")
if installer_type == "msi":
lines += [
"$processArguments = '/i \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
]
elif installer_type == "msp":
lines += [
"$processArguments = '/p \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
]
elif installer_type == "msu":
lines += [
"$processArguments = '\"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
]
elif installer_type in {"msix", "appx"}:
lines += ["Add-AppxPackage -Path $installer -ErrorAction Stop", "exit 0"]
return "\n".join(lines) + "\n"
else:
lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $packageDir -PassThru -NoNewWindow")
lines += [
"Write-Output (\"Installer PID: \" + $process.Id)",
"try {",
"\tWait-Process -Id $process.Id -Timeout $timeoutSeconds -ErrorAction Stop",
"} catch {",
"\ttry { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {}",
"\tWrite-Error (\"Installer timeout after \" + $timeoutSeconds + \" seconds.\")",
"\texit 1460",
"}",
"$process.Refresh()",
"$exitCode = [int]$process.ExitCode",
"Write-Output (\"Installer exit code: \" + $exitCode)",
"",
"if ($suppressBrowser) {",
"\tStart-Sleep -Milliseconds 1500",
"\t$newBrowsers = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('msedge','chrome','firefox','brave','opera','iexplore') -and $browserPidsBefore -notcontains $_.Id })",
"\tforeach ($browser in $newBrowsers) {",
"\t\ttry { Write-Output (\"Stopping installer-opened browser: \" + $browser.ProcessName + \"; PID=\" + $browser.Id); Stop-Process -Id $browser.Id -Force -ErrorAction SilentlyContinue } catch {}",
"\t}",
"}",
"",
"if ($null -ne $innoLog -and (Test-Path -LiteralPath $innoLog)) {",
"\tWrite-Output 'Inno Setup log summary:'",
"\t$interesting = @(Get-Content -LiteralPath $innoLog -ErrorAction SilentlyContinue | Where-Object { $_ -match '(?i)(Dest filename:|Creating directory:|Defaulting to Abort|Error|Installation process succeeded|Installation process failed|Setup exit code|Installation finished)' } | Select-Object -Last 60)",
"\tforeach ($line in $interesting) { Write-Output ('Inno: ' + [string]$line) }",
"}",
"",
"if ($successCodes -notcontains $exitCode) { exit $exitCode }",
"if ($rebootCodes -contains $exitCode) { exit 3010 }",
"exit 0",
]
return "\n".join(lines) + "\n"
def _registry_discovery_block(
display_name: str,
display_version: str,
publisher: str,
) -> str:
return f"""$displayName = {_ps_quote(display_name)}
$displayVersion = {_ps_quote(display_version)}
$publisher = {_ps_quote(publisher)}
function Get-UninstallEntries {{
$entries = @()
$machineRoots = @(
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
)
$entries += @(Get-ItemProperty -Path $machineRoots -ErrorAction SilentlyContinue)
try {{
$userHives = @(Get-ChildItem -Path 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue | Where-Object {{
$_.PSChildName -match '^S-1-(5-21|12-1)-'
}})
foreach ($hive in $userHives) {{
$roots = @(
("Registry::HKEY_USERS\\" + $hive.PSChildName + "\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*"),
("Registry::HKEY_USERS\\" + $hive.PSChildName + "\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*")
)
$entries += @(Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue)
}}
}} catch {{
}}
return @($entries | Where-Object {{ -not [string]::IsNullOrWhiteSpace([string]$_.DisplayName) }})
}}
function Get-PackageEntry {{
$entries = @(Get-UninstallEntries)
if ($entries.Count -eq 0) {{ return $null }}
$exact = @($entries | Where-Object {{ [string]$_.DisplayName -ieq $displayName }})
$candidates = $exact
if ($candidates.Count -eq 0) {{
$prefixSpace = $displayName + ' '
$prefixDash = $displayName + '-'
$prefixParen = $displayName + ' ('
$candidates = @($entries | Where-Object {{
$name = [string]$_.DisplayName
$name.StartsWith($prefixSpace, [System.StringComparison]::OrdinalIgnoreCase) -or
$name.StartsWith($prefixDash, [System.StringComparison]::OrdinalIgnoreCase) -or
$name.StartsWith($prefixParen, [System.StringComparison]::OrdinalIgnoreCase)
}})
}}
if ($candidates.Count -eq 0) {{ return $null }}
$ranked = foreach ($entry in $candidates) {{
$score = 0
if ([string]$entry.DisplayName -ieq $displayName) {{ $score += 100 }} else {{ $score += 60 }}
if (-not [string]::IsNullOrWhiteSpace($displayVersion) -and [string]$entry.DisplayVersion -ieq $displayVersion) {{ $score += 30 }}
if (-not [string]::IsNullOrWhiteSpace($publisher) -and [string]$entry.Publisher -ieq $publisher) {{ $score += 20 }}
[pscustomobject]@{{ Score = $score; Entry = $entry }}
}}
return ($ranked | Sort-Object Score -Descending | Select-Object -First 1).Entry
}}
"""
def build_generated_detection_script(manifest: dict[str, Any]) -> str:
detection = manifest.get("detection") or {}
method = str(detection.get("method") or "manual")
product_code = str(detection.get("product_code") or "").strip()
display_name = str(detection.get("display_name") or manifest.get("name") or "").strip()
display_version = str(detection.get("display_version") or manifest.get("version") or "").strip()
publisher = str(detection.get("publisher") or manifest.get("vendor") or "").strip()
installer_type = str(manifest.get("installer_type") or "").strip().lower()
if method == "msi_product_code" and product_code:
return f"""$ErrorActionPreference = 'SilentlyContinue'
$productCode = {_ps_quote(product_code)}
$paths = @(
"HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode",
"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode"
)
if ($paths | Where-Object {{ Test-Path -LiteralPath $_ }}) {{
Write-Output "Software found by MSI ProductCode: $productCode"
exit 0
}}
Write-Output "Software not found by MSI ProductCode: $productCode"
exit 1
"""
if method == "appx_package" and display_name:
return f"""$ErrorActionPreference = 'SilentlyContinue'
$name = {_ps_quote(display_name)}
$package = Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like "$name*" }} | Select-Object -First 1
if ($null -ne $package) {{
Write-Output ("Software found: " + $package.PackageFullName)
exit 0
}}
Write-Output "Software not found: $name"
exit 1
"""
if method == "registry_display_name" and display_name:
return "$ErrorActionPreference = 'SilentlyContinue'\n" + _registry_discovery_block(
display_name,
display_version,
publisher,
) + """$entry = Get-PackageEntry
if ($null -ne $entry) {
Write-Output ("Software found: " + [string]$entry.DisplayName + "; version=" + [string]$entry.DisplayVersion + "; publisher=" + [string]$entry.Publisher)
exit 0
}
Write-Output ("Software not found: " + $displayName)
exit 1
"""
if installer_type in {"msix", "appx"} and display_name:
fallback = dict(manifest)
fallback["detection"] = {"method": "appx_package", "display_name": display_name}
return build_generated_detection_script(fallback)
return "Write-Output 'No automatic detection rule is available for this package.'\nexit 2\n"
def build_generated_uninstall_script(manifest: dict[str, Any]) -> str:
detection = manifest.get("detection") or {}
method = str(detection.get("method") or "manual")
product_code = str(detection.get("product_code") or "").strip()
display_name = str(detection.get("display_name") or manifest.get("name") or "").strip()
display_version = str(detection.get("display_version") or manifest.get("version") or "").strip()
publisher = str(detection.get("publisher") or manifest.get("vendor") or "").strip()
installer_type = str(manifest.get("installer_type") or "").strip().lower()
timeout_seconds = package_execution_timeout_seconds(manifest)
process_block = "\n".join(_process_control_ps_lines(manifest, "uninstallation"))
if process_block:
process_block += "\n"
if method == "msi_product_code" and product_code:
return f"""$ErrorActionPreference = 'Stop'
{process_block}$productCode = {_ps_quote(product_code)}
$arguments = '/x "' + $productCode + '" /qn /norestart'
Write-Output ("Starting MSI uninstall: msiexec.exe " + $arguments)
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $arguments -PassThru -NoNewWindow
try {{
Wait-Process -Id $process.Id -Timeout {timeout_seconds} -ErrorAction Stop
}} catch {{
Write-Error "MSI uninstall timed out after {timeout_seconds} seconds."
exit 1460
}}
$process.Refresh()
$exitCode = [int]$process.ExitCode
Write-Output "Uninstaller exit code: $exitCode"
if (@(0, 1641, 3010) -notcontains $exitCode) {{ exit $exitCode }}
if (@(1641, 3010) -contains $exitCode) {{ exit 3010 }}
exit 0
"""
if method == "appx_package" and display_name:
return f"""$ErrorActionPreference = 'Stop'
{process_block}$name = {_ps_quote(display_name)}
$packages = @(Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like "$name*" }})
if ($packages.Count -eq 0) {{
Write-Output "Software not found: $name. Nothing to uninstall."
exit 0
}}
foreach ($package in $packages) {{
Write-Output ("Removing AppX package: " + $package.PackageFullName)
Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop
}}
exit 0
"""
if method == "registry_display_name" and display_name:
base = "$ErrorActionPreference = 'Stop'\n" + process_block + _registry_discovery_block(
display_name,
display_version,
publisher,
)
base += f"$installerType = {_ps_quote(installer_type)}\n$timeoutSeconds = {timeout_seconds}\n"
base += """
function Split-UninstallCommand([string]$Command) {
$expanded = [Environment]::ExpandEnvironmentVariables([string]$Command).Trim()
if ([string]::IsNullOrWhiteSpace($expanded)) { throw 'Uninstall command is empty.' }
if ($expanded.StartsWith('"')) {
$closing = $expanded.IndexOf('"', 1)
if ($closing -lt 1) { throw "Invalid uninstall command: $expanded" }
$filePath = $expanded.Substring(1, $closing - 1)
$arguments = $expanded.Substring($closing + 1).Trim()
} else {
$space = $expanded.IndexOf(' ')
if ($space -lt 0) {
$filePath = $expanded
$arguments = ''
} else {
$filePath = $expanded.Substring(0, $space)
$arguments = $expanded.Substring($space + 1).Trim()
}
}
return [pscustomobject]@{ FilePath = $filePath; Arguments = $arguments }
}
$entry = Get-PackageEntry
if ($null -eq $entry) {
Write-Output ("Software not found: " + $displayName + ". Nothing to uninstall.")
exit 0
}
Write-Output ("Software found: " + [string]$entry.DisplayName + "; version=" + [string]$entry.DisplayVersion + "; publisher=" + [string]$entry.Publisher)
$command = [string]$entry.QuietUninstallString
$usedQuiet = -not [string]::IsNullOrWhiteSpace($command)
if ($usedQuiet) {
Write-Output 'QuietUninstallString found.'
} else {
$command = [string]$entry.UninstallString
Write-Output 'QuietUninstallString not available; using UninstallString.'
}
if ([string]::IsNullOrWhiteSpace($command)) {
Write-Error 'No uninstall command was found in the registry.'
exit 3
}
if (-not $usedQuiet) {
if ($installerType -eq 'inno' -and $command -notmatch '(?i)/(very)?silent') {
$command += ' /VERYSILENT /SUPPRESSMSGBOXES /NORESTART'
} elseif ($installerType -eq 'nsis' -and $command -notmatch '(?i)(^|\\s)/S($|\\s)') {
$command += ' /S'
}
}
$parts = Split-UninstallCommand $command
Write-Output ("Starting uninstaller: " + $parts.FilePath + $(if ([string]::IsNullOrWhiteSpace($parts.Arguments)) { '' } else { ' ' + $parts.Arguments }))
if ([string]::IsNullOrWhiteSpace($parts.Arguments)) {
$process = Start-Process -FilePath $parts.FilePath -PassThru
} else {
$process = Start-Process -FilePath $parts.FilePath -ArgumentList $parts.Arguments -PassThru
}
Write-Output ("Uninstaller PID: " + $process.Id)
try {
Wait-Process -Id $process.Id -Timeout $timeoutSeconds -ErrorAction Stop
} catch {
Write-Error ("Uninstaller timed out after " + $timeoutSeconds + " seconds.")
exit 1460
}
$process.Refresh()
$exitCode = [int]$process.ExitCode
Write-Output ("Uninstaller exit code: " + $exitCode)
if (@(0, 1641, 3010) -notcontains $exitCode) { exit $exitCode }
if (@(1641, 3010) -contains $exitCode) { exit 3010 }
exit 0
"""
return base
return "Write-Error 'No automatic uninstall rule is available for this package.'\nexit 2\n"
def refresh_generated_package_runtime_scripts(
package_id: int,
manifest: dict[str, Any] | None = None,
) -> None:
manifest = manifest or load_package_manifest(package_id)
if not isinstance(manifest.get("analysis"), dict):
return
root = package_directory(package_id)
install = manifest.get("install") or {}
detection = manifest.get("detection") or {}
uninstall = manifest.get("uninstall") or {}
install_name = _safe_member_name(install.get("script", "install.ps1"))
detect_name = _safe_member_name(detection.get("script", "detect.ps1"))
uninstall_name = _safe_member_name(uninstall.get("script", "uninstall.ps1"))
generated = {
root / install_name: build_generated_install_script(manifest),
root / detect_name: build_generated_detection_script(manifest),
root / uninstall_name: build_generated_uninstall_script(manifest),
}
for path, content in generated.items():
if not path.parent.is_dir():
continue
current = path.read_text(encoding="utf-8") if path.is_file() else ""
if current != content:
path.write_text(content, encoding="utf-8", newline="\n")
def update_package_process_control(
package_id: int,
process_names: Any,
grace_seconds: int = 5,
force_close: bool = True,
) -> dict[str, Any]:
manifest = load_package_manifest(package_id)
manifest["process_control"] = {
"process_names": normalize_process_names(process_names),
"grace_seconds": max(0, min(int(grace_seconds), 30)),
"force_close": bool(force_close),
}
manifest, _notes = normalize_package_manifest(manifest)
manifest_path = package_directory(package_id) / "package.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
refresh_generated_package_runtime_scripts(package_id, manifest)
return manifest
def build_deployment_user_script(manifest: dict[str, Any], action: str) -> str:
action = str(action or "").strip().lower()
if action not in {"install", "uninstall", "reinstall"}:
raise ValueError("unsupported deployment action")
package_name = str(manifest.get("name") or "Software package")
install_script = _safe_member_name((manifest.get("install") or {}).get("script", "install.ps1"))
uninstall_script = _safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1"))
detect_script = _safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1"))
detection_method = str((manifest.get("detection") or {}).get("method") or "manual")
def ps(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
lines = [
f"$deploymentAction = {ps(action)}",
f"$deploymentPackage = {ps(package_name)}",
f"$detectionMethod = {ps(detection_method)}",
"$deploymentExitCode = 0",
"$rebootRequired = $false",
"",
"function Invoke-PackagePowerShell([string]$ScriptName) {",
" $scriptPath = Join-Path $PSScriptRoot $ScriptName",
" if (-not (Test-Path -LiteralPath $scriptPath)) { throw \"Package script not found: $scriptPath\" }",
" Write-JobLog (\"Running package script: $ScriptName\") | Out-Null",
" $packageOutput = @(& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath 2>&1)",
" $code = [int]$LASTEXITCODE",
" foreach ($outputLine in $packageOutput) {",
" $outputText = [string]$outputLine",
" if (-not [string]::IsNullOrWhiteSpace($outputText)) { Write-JobLog (\"Package output: $outputText\") | Out-Null }",
" }",
" Write-JobLog (\"Package script exit code: $code\") | Out-Null",
" return [int]$code",
"}",
"",
"function Assert-PackageResult([int]$Code, [string]$Step) {",
" if ($Code -eq 3010) { $script:rebootRequired = $true; return }",
" if ($Code -ne 0) { throw \"$Step failed with exit code $Code\" }",
"}",
"",
"function Test-PackageDetected() {",
" if ($detectionMethod -eq 'manual') { return $null }",
f" $detectScript = {ps(detect_script)}",
" $scriptPath = Join-Path $PSScriptRoot $detectScript",
" if (-not (Test-Path -LiteralPath $scriptPath)) { return $null }",
" $detectOutput = @(& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath 2>&1)",
" $detectCode = [int]$LASTEXITCODE",
" foreach ($outputLine in $detectOutput) {",
" $outputText = [string]$outputLine",
" if (-not [string]::IsNullOrWhiteSpace($outputText)) { Write-JobLog (\"Detection output: $outputText\") | Out-Null }",
" }",
" Write-JobLog (\"Detection script exit code: $detectCode\") | Out-Null",
" return [bool]($detectCode -eq 0)",
"}",
"",
"function Wait-PackageDetection([bool]$Expected, [int]$TimeoutSeconds = 30) {",
" if ($detectionMethod -eq 'manual') { return $null }",
" $deadline = (Get-Date).AddSeconds($TimeoutSeconds)",
" $last = $null",
" do {",
" $last = Test-PackageDetected",
" if ($null -eq $last) { return $null }",
" if ([bool]$last -eq $Expected) { return [bool]$last }",
" Start-Sleep -Seconds 2",
" } while ((Get-Date) -lt $deadline)",
" return [bool]$last",
"}",
"",
"Write-JobLog (\"Software deployment started: $deploymentPackage; action=$deploymentAction\")",
]
if action == "install":
lines += [
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(install_script)})",
"Assert-PackageResult $deploymentExitCode 'Installation'",
"$detected = Wait-PackageDetection $true 30",
"if ($detected -eq $false) { throw 'Installation completed but the detection rule did not find the software.' }",
]
elif action == "uninstall":
lines += [
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(uninstall_script)})",
"Assert-PackageResult $deploymentExitCode 'Uninstallation'",
"$detected = Wait-PackageDetection $false 30",
"if ($detected -eq $true) { throw 'Uninstallation completed but the detection rule still finds the software.' }",
]
else:
lines += [
f"$uninstallCode = [int](Invoke-PackagePowerShell {ps(uninstall_script)})",
"Assert-PackageResult $uninstallCode 'Uninstallation'",
"$detectedAfterUninstall = Wait-PackageDetection $false 30",
"if ($detectedAfterUninstall -eq $true) { throw 'Uninstallation completed but the detection rule still finds the software.' }",
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(install_script)})",
"Assert-PackageResult $deploymentExitCode 'Installation'",
"$detected = Wait-PackageDetection $true 30",
"if ($detected -eq $false) { throw 'Reinstallation completed but the detection rule did not find the software.' }",
]
lines += [
"$JobResult['deployment_action'] = $deploymentAction",
"$JobResult['deployment_package'] = $deploymentPackage",
"$JobResult['process_exit_code'] = $deploymentExitCode",
"$JobResult['reboot_required'] = $rebootRequired",
"if ($null -ne $detected) { $JobResult['detected_after_action'] = [bool]$detected }",
"Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")",
]
return "\n".join(lines)
+83
View File
@@ -3147,3 +3147,86 @@ html[data-theme="dark"] .advanced-filter-popup select,html[data-theme="dark"] .a
/* v0.5.5.72: hidden value fields in advanced filter builder must stay hidden despite label display rules. */
.advanced-filter-popup label[hidden]{display:none!important}
/* v0.5.5.76: Setup Analyzer */
.setup-analyzer-toolbar{gap:16px}
.setup-analyzer-upload-panel h2,.setup-analyzer-card h2{margin-top:0}
.setup-analyzer-form,.setup-analyzer-export-form{background:transparent;border:0;padding:0}
.setup-analyzer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.setup-analyzer-card{min-width:0}
.setup-analyzer-details{display:grid;grid-template-columns:minmax(150px,220px) minmax(0,1fr);margin:0}
.setup-analyzer-details dt,.setup-analyzer-details dd{border-bottom:1px solid var(--am-border);padding:8px 0}
.setup-analyzer-details dd{word-break:break-word}
.setup-analyzer-confidence{height:9px;margin:12px 0;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--am-border) 75%,transparent)}
.setup-analyzer-confidence span{display:block;height:100%;background:var(--am-primary)}
.setup-analyzer-notice{margin:8px 0}
.setup-analyzer-actions{display:flex;gap:10px;flex-wrap:wrap}
.setup-analyzer-technical summary,.setup-analyzer-card summary{cursor:pointer;font-weight:700}
.setup-analyzer-card details{margin-top:12px}
.setup-analyzer-card code,.setup-analyzer-technical code{word-break:break-all}
@media(max-width:850px){.setup-analyzer-grid{grid-template-columns:1fr}.setup-analyzer-details{grid-template-columns:1fr}.setup-analyzer-details dt{padding-bottom:0;border-bottom:0}.setup-analyzer-details dd{padding-top:3px}}
/* v0.5.5.77 Setup Analyzer package deployment */
.setup-analyzer-checkboxes{display:flex;flex-direction:column;align-items:flex-start;gap:.35rem;margin-top:.75rem}
.setup-analyzer-checkboxes .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;text-align:left!important}
.setup-analyzer-checkboxes .checkbox-label input{width:auto!important;flex:0 0 auto!important;margin:0!important}
.software-package-job-form{padding:0;border:0;background:transparent}
.software-package-job-form .job-filter-status-fieldset{margin-bottom:1rem}
/* v0.5.5.78 Software package action layout */
.software-package-action-fieldset{
width:100%;
max-width:560px;
min-width:0;
margin:0 0 1rem;
padding:.85rem 1rem;
border:1px solid var(--border-color,#415166);
border-radius:8px;
}
.software-package-action-fieldset legend{padding:0 .35rem;font-weight:700}
.software-package-action-options{
display:flex;
flex-direction:column;
align-items:flex-start;
gap:.6rem;
width:100%;
margin-top:.65rem;
}
.software-package-action-options .checkbox-label{
display:inline-flex!important;
flex-direction:row!important;
align-items:center!important;
justify-content:flex-start!important;
gap:.55rem!important;
width:auto!important;
max-width:100%!important;
margin:0!important;
text-align:left!important;
white-space:normal;
}
.software-package-action-options input[type=radio]{
display:inline-block!important;
width:auto!important;
min-width:0!important;
flex:0 0 auto!important;
margin:0!important;
padding:0!important;
}
/* v0.5.5.79 Software package deletion */
.software-package-delete-panel{margin-top:1rem;border-color:color-mix(in srgb,var(--am-danger) 42%,var(--am-border));}
.software-package-delete-form{padding:0;border:0;background:transparent;display:flex;flex-direction:column;align-items:flex-start;gap:.8rem;}
.software-package-delete-jobs{display:inline-flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:auto!important;max-width:100%!important;margin:0!important;}
.software-package-delete-jobs input[type=checkbox]{width:auto!important;flex:0 0 auto!important;margin:0!important;}
/* v0.5.5.80 software package process control */
.software-package-process-panel{margin-top:1rem;}
.software-package-process-form{display:flex;flex-direction:column;align-items:flex-start;gap:.75rem;max-width:720px;padding:0;border:0;background:transparent;}
.software-package-process-form>label:not(.checkbox-label){display:flex;flex-direction:column;align-items:flex-start;gap:.35rem;width:100%;max-width:720px;}
.software-package-process-form input[type=text],
.software-package-process-form input[type=number]{width:100%;max-width:720px;box-sizing:border-box;}
.software-package-process-form input[type=number]{max-width:180px;}
.software-package-force-close{display:inline-flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:auto!important;max-width:100%!important;margin:0!important;}
.software-package-force-close input[type=checkbox]{width:auto!important;flex:0 0 auto!important;margin:0!important;}
+147
View File
@@ -0,0 +1,147 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar setup-analyzer-toolbar">
<div>
<h1>{{ t('setup_analyzer.title') }}</h1>
<p class="muted">{{ t('setup_analyzer.subtitle') }}</p>
</div>
<div class="setup-analyzer-actions">
<a class="button button-secondary" href="/software/packages">{{ t('software_packages.title') }}</a>
<a class="button button-secondary" href="/software">{{ t('setup_analyzer.back_to_software') }}</a>
</div>
</div>
<section class="panel setup-analyzer-upload-panel">
<h2>{{ t('setup_analyzer.upload_title') }}</h2>
<p>{{ t('setup_analyzer.static_note') }}</p>
<form class="setup-analyzer-form" method="post" action="/software/setup-analyzer/analyze" enctype="multipart/form-data">
<label for="installer">{{ t('setup_analyzer.file') }}
<input id="installer" name="installer" type="file" accept=".exe,.msi,.msp,.msix,.appx,.msu" required>
</label>
<p class="muted">{{ t('setup_analyzer.max_upload', size=max_upload_mb) }}</p>
<button class="button" type="submit">{{ t('setup_analyzer.analyze') }}</button>
</form>
</section>
{% if analysis %}
<div class="setup-analyzer-grid">
<section class="panel setup-analyzer-card">
<h2>{{ t('setup_analyzer.file_info') }}</h2>
<dl class="setup-analyzer-details">
<dt>{{ t('setup_analyzer.file') }}</dt><dd>{{ analysis.filename }}</dd>
<dt>{{ t('setup_analyzer.size') }}</dt><dd>{{ analysis.size_human }}</dd>
<dt>SHA256</dt><dd><code>{{ analysis.sha256 }}</code></dd>
<dt>{{ t('setup_analyzer.signature') }}</dt>
<dd>{% if analysis.signature_present is sameas true %}{{ t('setup_analyzer.signature_present') }}{% elif analysis.signature_present is sameas false %}{{ t('setup_analyzer.signature_absent') }}{% else %}{{ t('setup_analyzer.not_available') }}{% endif %}</dd>
</dl>
</section>
<section class="panel setup-analyzer-card">
<h2>{{ t('setup_analyzer.detection') }}</h2>
<dl class="setup-analyzer-details">
<dt>{{ t('setup_analyzer.technology') }}</dt><dd><strong>{{ analysis.installer_label }}</strong></dd>
<dt>{{ t('setup_analyzer.confidence') }}</dt><dd>{{ analysis.confidence }} %</dd>
<dt>{{ t('setup_analyzer.command_confidence') }}</dt><dd>{{ t('setup_analyzer.command_confidence.' ~ analysis.command_confidence) }}</dd>
</dl>
<div class="setup-analyzer-confidence" title="{{ analysis.confidence }} %"><span style="width:{{ analysis.confidence }}%"></span></div>
<details>
<summary>{{ t('setup_analyzer.detection_signals') }}</summary>
<ul>{% for key in analysis.signal_keys %}<li>{{ t(key) }}</li>{% endfor %}</ul>
</details>
{% if analysis.candidates|length > 1 %}
<details>
<summary>{{ t('setup_analyzer.other_candidates') }}</summary>
<ul>{% for candidate in analysis.candidates[1:] %}<li>{{ candidate.label }} - {{ candidate.confidence }} %</li>{% endfor %}</ul>
</details>
{% endif %}
{% if analysis.embedded_switches %}
<details>
<summary>{{ t('setup_analyzer.embedded_switches') }}</summary>
<code>{{ analysis.embedded_switches|join(' ') }}</code>
</details>
{% endif %}
</section>
</div>
{% if analysis.warning_keys %}
<section class="panel">
<h2>{{ t('setup_analyzer.notes') }}</h2>
{% for key in analysis.warning_keys %}<div class="notice setup-analyzer-notice">{{ t(key) }}</div>{% endfor %}
</section>
{% endif %}
<form class="setup-analyzer-export-form" method="post">
<input type="hidden" name="token" value="{{ analysis.token }}">
<div class="setup-analyzer-grid">
<section class="panel setup-analyzer-card">
<h2>{{ t('setup_analyzer.product_info') }}</h2>
<label for="product_name">{{ t('setup_analyzer.product_name') }}
<input id="product_name" name="product_name" value="{{ analysis.product_name }}">
</label>
<label for="product_version">{{ t('setup_analyzer.version') }}
<input id="product_version" name="product_version" value="{{ analysis.product_version }}">
</label>
<label for="manufacturer">{{ t('setup_analyzer.manufacturer') }}
<input id="manufacturer" name="manufacturer" value="{{ analysis.manufacturer }}">
</label>
<label for="architecture">{{ t('setup_analyzer.architecture') }}
<input id="architecture" name="architecture" value="{{ analysis.architecture }}">
</label>
{% if analysis.product_code %}<p><strong>ProductCode:</strong> <code>{{ analysis.product_code }}</code></p>{% endif %}
{% if analysis.upgrade_code %}<p><strong>UpgradeCode:</strong> <code>{{ analysis.upgrade_code }}</code></p>{% endif %}
</section>
<section class="panel setup-analyzer-card">
<h2>{{ t('setup_analyzer.installation') }}</h2>
<label for="install_arguments">{{ t('setup_analyzer.arguments') }}
<input id="install_arguments" name="install_arguments" value="{{ analysis.install_arguments }}">
</label>
<p><strong>{{ t('setup_analyzer.recommended_command') }}:</strong><br><code>{{ analysis.install_command }}</code></p>
<label for="timeout_seconds">{{ t('setup_analyzer.timeout') }}
<input id="timeout_seconds" name="timeout_seconds" type="number" min="30" max="86400" value="600">
</label>
<input type="hidden" name="run_as" value="system">
<p><strong>{{ t('setup_analyzer.run_as') }}:</strong> SYSTEM<br><small class="muted">{{ t('setup_analyzer.run_as_help') }}</small></p>
<label for="success_codes">{{ t('setup_analyzer.success_codes') }}
<input id="success_codes" name="success_codes" value="{{ analysis.success_codes|join(', ') }}">
</label>
<label for="reboot_codes">{{ t('setup_analyzer.reboot_codes') }}
<input id="reboot_codes" name="reboot_codes" value="{{ analysis.reboot_codes|join(', ') }}">
</label>
<div class="setup-analyzer-checkboxes">
<label class="checkbox-label" for="suppress_browser">
<input id="suppress_browser" name="suppress_browser" type="checkbox" value="true" {% if analysis.suppress_browser_default %}checked{% endif %}>
<span>{{ t('setup_analyzer.suppress_browser') }}</span>
</label>
<small class="muted">{{ t('setup_analyzer.suppress_browser_help') }}</small>
</div>
<label for="process_names">{{ t('setup_analyzer.process_names') }}
<input id="process_names" name="process_names" value="{{ analysis.process_names_default }}" placeholder="Greenshot.exe">
</label>
<small class="muted">{{ t('setup_analyzer.process_names_help') }}</small>
</section>
</div>
<section class="panel">
<h2>{{ t('setup_analyzer.generated_logic') }}</h2>
<p><strong>{{ t('setup_analyzer.detection_method') }}:</strong> <code>{{ analysis.detect_method }}</code></p>
{% if analysis.uninstall_command %}<p><strong>{{ t('setup_analyzer.uninstall_command') }}:</strong><br><code>{{ analysis.uninstall_command }}</code></p>{% endif %}
<p class="muted">{{ t('setup_analyzer.export_note_v2') }}</p>
<div class="setup-analyzer-actions">
<button class="button button-secondary" type="submit" formaction="/software/setup-analyzer/export/powershell">{{ t('setup_analyzer.export_ps') }}</button>
<button class="button button-secondary" type="submit" formaction="/software/setup-analyzer/export/package">{{ t('setup_analyzer.export_package') }}</button>
<button class="button" type="submit" formaction="/software/setup-analyzer/create-package">{{ t('setup_analyzer.create_package') }}</button>
</div>
</section>
</form>
<details class="panel setup-analyzer-technical">
<summary>{{ t('setup_analyzer.technical_details') }}</summary>
<dl class="setup-analyzer-details">
<dt>{{ t('setup_analyzer.pefile') }}</dt><dd>{{ t('common.yes') if analysis.pefile_available else t('common.no') }}</dd>
<dt>msiinfo</dt><dd>{{ t('common.yes') if analysis.msiinfo_available else t('common.no') }}</dd>
{% if analysis.original_filename %}<dt>OriginalFilename</dt><dd>{{ analysis.original_filename }}</dd>{% endif %}
</dl>
</details>
{% endif %}
{% endblock %}
+10
View File
@@ -38,6 +38,16 @@
<span>{{ t('software.aggregated_list_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
<a href="/software/setup-analyzer" class="software-list-link">
<strong>{{ t('setup_analyzer.card_title') }}</strong>
<span>{{ t('setup_analyzer.card_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
<a href="/software/packages" class="software-list-link">
<strong>{{ t('software_packages.card_title') }}</strong>
<span>{{ t('software_packages.card_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
</div>
</section>
+134
View File
@@ -0,0 +1,134 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ package.name }}</h1>
<p class="muted">{{ t('software_packages.detail_subtitle') }}</p>
</div>
<div class="setup-analyzer-actions">
<a class="button button-secondary" href="/software/packages">{{ t('software_packages.back') }}</a>
<a class="button button-secondary" href="/software">{{ t('setup_analyzer.back_to_software') }}</a>
</div>
</div>
{% if request.query_params.get('created') %}
<div class="notice success">{{ t('software_packages.created') }}</div>
{% endif %}
<div class="setup-analyzer-grid">
<section class="panel setup-analyzer-card">
<h2>{{ t('software_packages.package_info') }}</h2>
<dl class="setup-analyzer-details">
<dt>{{ t('software.package') }}</dt><dd>{{ package.name }}</dd>
<dt>{{ t('setup_analyzer.product_name') }}</dt><dd>{{ manifest.name or '—' }}</dd>
<dt>{{ t('setup_analyzer.version') }}</dt><dd>{{ manifest.version or '—' }}</dd>
<dt>{{ t('setup_analyzer.manufacturer') }}</dt><dd>{{ manifest.vendor or '—' }}</dd>
<dt>{{ t('setup_analyzer.architecture') }}</dt><dd>{{ manifest.architecture or '—' }}</dd>
<dt>{{ t('software_packages.installer_type') }}</dt><dd>{{ manifest.installer_type or '—' }}</dd>
<dt>{{ t('software_packages.installer_file') }}</dt><dd><code>{{ manifest.installer_file or '—' }}</code></dd>
<dt>{{ t('software_packages.storage') }}</dt><dd>{{ human_size(storage_size) }}</dd>
</dl>
</section>
<section class="panel setup-analyzer-card">
<h2>{{ t('software_packages.deployment_info') }}</h2>
<dl class="setup-analyzer-details">
<dt>{{ t('setup_analyzer.arguments') }}</dt><dd><code>{{ manifest.install.arguments or '—' }}</code></dd>
<dt>{{ t('setup_analyzer.timeout') }}</dt><dd>{{ manifest.install.timeout_seconds }} s</dd>
<dt>{{ t('setup_analyzer.success_codes') }}</dt><dd>{{ manifest.install.success_codes|join(', ') }}</dd>
<dt>{{ t('setup_analyzer.reboot_codes') }}</dt><dd>{{ manifest.install.reboot_codes|join(', ') }}</dd>
<dt>{{ t('setup_analyzer.suppress_browser') }}</dt><dd>{{ t('common.yes') if manifest.install.suppress_browser else t('common.no') }}</dd>
<dt>{{ t('software_packages.process_names') }}</dt><dd>{% if manifest.process_control.process_names %}<code>{{ manifest.process_control.process_names|join(', ') }}</code>{% else %}—{% endif %}</dd>
<dt>{{ t('setup_analyzer.detection_method') }}</dt><dd><code>{{ manifest.detection.method }}</code></dd>
</dl>
</section>
</div>
<section class="panel software-package-process-panel">
<h2>{{ t('software_packages.process_control') }}</h2>
<p class="muted">{{ t('software_packages.process_control_help') }}</p>
<form method="post" action="/software/packages/{{ package.id }}/process-control" class="software-package-process-form">
<label for="package_process_names">{{ t('software_packages.process_names') }}
<input id="package_process_names" name="process_names" value="{{ manifest.process_control.process_names|join(', ') }}" placeholder="Greenshot.exe">
</label>
<small class="muted">{{ t('software_packages.process_names_help') }}</small>
<label for="package_process_grace_seconds">{{ t('software_packages.process_grace_seconds') }}
<input id="package_process_grace_seconds" name="grace_seconds" type="number" min="0" max="30" value="{{ manifest.process_control.grace_seconds }}">
</label>
<label class="checkbox-label software-package-force-close" for="package_force_close">
<input id="package_force_close" name="force_close" type="checkbox" value="true" {% if manifest.process_control.force_close %}checked{% endif %}>
<span>{{ t('software_packages.force_close') }}</span>
</label>
<button class="button button-secondary" type="submit">{{ t('common.save') }}</button>
</form>
</section>
<section class="panel">
<h2>{{ t('software_packages.create_jobs') }}</h2>
<p class="muted">{{ t('software_packages.create_jobs_help') }}</p>
<form method="post" action="/software/packages/{{ package.id }}/run" class="software-package-job-form">
<fieldset class="software-package-action-fieldset">
<legend>{{ t('software_packages.action') }}</legend>
<div class="software-package-action-options">
<label class="checkbox-label"><input type="radio" name="action" value="install" checked><span>{{ t('jobs.action.install') }}</span></label>
<label class="checkbox-label"><input type="radio" name="action" value="uninstall"><span>{{ t('jobs.action.uninstall') }}</span></label>
<label class="checkbox-label"><input type="radio" name="action" value="reinstall"><span>{{ t('jobs.action.reinstall') }}</span></label>
</div>
</fieldset>
<div class="table-scroll management-table-scroll">
<table class="data-table" data-storage-key="software-package-assets">
<thead>
<tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1"><input id="software-package-select-all" type="checkbox" aria-label="{{ t('software_packages.select_all') }}"></th>
<th>{{ t('common.assets') }}</th>
<th>{{ t('field.hostname') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('field.department') }}</th>
<th>{{ t('field.location') }}</th>
</tr>
</thead>
<tbody>
{% for asset in assets %}
<tr>
<td class="selection-column"><input class="software-package-asset" type="checkbox" name="asset_ids" value="{{ asset.id }}"></td>
<td><a href="/assets/{{ asset.id }}">{{ asset.name }}</a></td>
<td>{{ asset.hostname or '—' }}</td>
<td>{{ detect_platform(asset) }}</td>
<td>{{ asset.department or '—' }}</td>
<td>{{ asset.location or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="6">{{ t('software_packages.no_compatible_assets') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<button class="button" type="submit">{{ t('software_packages.start_jobs') }}</button>
</div>
</form>
</section>
<section class="panel software-package-delete-panel">
<h2>{{ t('software_packages.delete_title') }}</h2>
<p class="muted">{{ t('software_packages.delete_help') }}</p>
<form method="post" action="/software/packages/{{ package.id }}/delete" class="software-package-delete-form" onsubmit="return confirm({{ t('software_packages.delete_confirm', package=package.name)|tojson }});">
{% if job_count %}
<label class="checkbox-label software-package-delete-jobs">
<input type="checkbox" name="delete_jobs" value="1">
<span>{{ t('software_packages.delete_jobs', count=job_count) }}</span>
</label>
{% endif %}
<button class="button button-danger" type="submit">{{ t('software_packages.delete_button') }}</button>
</form>
</section>
<script>
(function(){
const master=document.getElementById('software-package-select-all');
if(!master){return;}
const items=Array.from(document.querySelectorAll('.software-package-asset'));
master.addEventListener('change',()=>{items.forEach(item=>{item.checked=master.checked;});});
})();
</script>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software_packages.title') }}</h1>
<p class="muted">{{ t('software_packages.subtitle') }}</p>
</div>
<div class="setup-analyzer-actions">
<a class="button" href="/software/setup-analyzer">{{ t('software_packages.new_from_analyzer') }}</a>
<a class="button button-secondary" href="/software">{{ t('setup_analyzer.back_to_software') }}</a>
</div>
</div>
<section class="panel">
<div class="table-scroll management-table-scroll">
<table class="data-table" data-storage-key="software-packages">
<thead>
<tr>
<th>{{ t('software.package') }}</th>
<th>{{ t('setup_analyzer.version') }}</th>
<th>{{ t('setup_analyzer.manufacturer') }}</th>
<th>{{ t('setup_analyzer.architecture') }}</th>
<th>{{ t('software_packages.installer_type') }}</th>
<th>{{ t('software_packages.storage') }}</th>
<th>{{ t('software_packages.status') }}</th>
<th>{{ t('jobs.actions') }}</th>
</tr>
</thead>
<tbody>
{% for row in package_rows %}
{% set package = row.package %}
{% set manifest = row.manifest %}
<tr>
<td><a href="/software/packages/{{ package.id }}"><strong>{{ package.name }}</strong></a></td>
<td>{{ manifest.version or '—' }}</td>
<td>{{ manifest.vendor or '—' }}</td>
<td>{{ manifest.architecture or '—' }}</td>
<td>{{ manifest.installer_type or '—' }}</td>
<td>{{ human_size(row.storage_size) }}</td>
<td>{% if row.storage_error %}<span class="job-status job-status-failed">{{ t('software_packages.storage_error') }}</span>{% elif package.enabled %}<span class="job-status job-status-success">{{ t('software_packages.enabled') }}</span>{% else %}<span class="job-status">{{ t('software_packages.disabled') }}</span>{% endif %}</td>
<td><a class="button button-secondary button-small" href="/software/packages/{{ package.id }}">{{ t('software.open') }}</a></td>
</tr>
{% else %}
<tr><td colspan="8">{{ t('software_packages.none') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.75"
APP_VERSION = "0.5.5.81"
__version__ = APP_VERSION
Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 764 KiB

After

Width:  |  Height:  |  Size: 764 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

Before

Width:  |  Height:  |  Size: 823 KiB

After

Width:  |  Height:  |  Size: 823 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View File

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File
+2
View File
@@ -35,6 +35,7 @@ services:
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
SYNC_LOG_DIR: /app/data/logs/sync
DIAGNOSTIC_DIR: /app/data/logs/diagnostics
SOFTWARE_PACKAGE_DIR: /app/data/software-packages
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
SESSION_SECRET: ${SESSION_SECRET:-}
LOCAL_ADMIN_USERNAME: ${LOCAL_ADMIN_USERNAME:-}
@@ -50,6 +51,7 @@ services:
- ./data/logs:/app/data/logs
- ./data/backups:/data/backups
- ./data/scripts:/scripts
- ./data/software-packages:/app/data/software-packages
# Callback-only listener for Internet/DMZ scenarios.
# Starts together with AssetManager and exposes only the callback POST endpoint
Regular → Executable
View File
+3 -1
View File
@@ -9,7 +9,7 @@ Create a dedicated directory and the persistent data directories:
```bash
mkdir -p /srv/docker/assetmanager
cd /srv/docker/assetmanager
mkdir -p data/config data/uploads data/logs data/backups data/scripts data/postgres
mkdir -p data/config data/uploads data/logs data/backups data/scripts data/software-packages data/postgres
```
Create a `.env` file. Use strong, unique values for all secrets:
@@ -89,6 +89,7 @@ services:
- ./data/logs:/app/data/logs
- ./data/backups:/data/backups
- ./data/scripts:/scripts
- ./data/software-packages:/app/data/software-packages
callback:
image: git.jusaro.de/roland/assetmanager:${ASSETMANAGER_VERSION:-0.5.5.68}
@@ -208,6 +209,7 @@ data/uploads/
data/logs/
data/backups/
data/scripts/
data/software-packages/
.env
```
+64
View File
@@ -0,0 +1,64 @@
# Setup Analyzer
The Setup Analyzer prepares Windows installation files for silent deployment without executing the uploaded installer on the AssetManager server.
## Supported package types
- MSI and MSP
- MSIX and AppX
- MSU
- Inno Setup
- NSIS
- WiX Burn
- InstallShield
- Advanced Installer
- Squirrel
- common 7-Zip and WinRAR SFX wrappers
- unknown EXE fallback
## Analysis output
- SHA256
- installer technology and confidence
- PE product/version/manufacturer/architecture metadata when available
- presence of Authenticode signature data for PE files
- detected installer marker strings
- detected switch-like strings
- recommended silent arguments and command
- success and reboot exit-code suggestions
- detection and uninstall suggestions
## Exports and AssetManager packages
The PowerShell export creates an `install.ps1` wrapper. The deployment-package export contains the original installer plus `install.ps1`, `uninstall.ps1`, `detect.ps1`, `package.json`, and `analysis.json`.
Version 0.5.5.77 can also create a persistent AssetManager software package directly from the analyzer result. Package files are stored in `data/software-packages/` and the package appears under **Software and Jobs → Software packages**. From there administrators can create install, uninstall, and reinstall jobs for one or more compatible Windows assets.
Software-package jobs transfer only the files required for the selected action through MeshCentral, run the generated PowerShell wrapper, and use the normal AssetManager callback/status/retry infrastructure.
## Installer process handling
Generated installation scripts no longer use `Start-Process -Wait` for the installer. They start the installer process, wait only for that specific PID with a configurable timeout, and then evaluate its exit code. This prevents a browser or another long-running child process launched by the installer from keeping the PowerShell script open indefinitely.
The optional **Suppress post-install browser launch** setting terminates only browser processes that were newly created inside the installer process tree. Existing browser sessions are not touched. The option is preselected for Greenshot detections because Greenshot installers can open a completion web page after setup.
## Security
The uploaded installer is stored in a temporary directory and is not executed. Access is restricted to administrators. Old temporary analyses are removed after the configured retention period.
Environment variables:
```text
SETUP_ANALYZER_TMP_DIR=/tmp/assetmanager-setup-analyzer
SETUP_ANALYZER_MAX_UPLOAD_MB=4096
SETUP_ANALYZER_RETENTION_HOURS=24
SOFTWARE_PACKAGE_DIR=/app/data/software-packages
```
## MSI metadata
Standard MSI silent-command generation works without additional system packages. Detailed MSI properties such as ProductCode and UpgradeCode are additionally read when the optional `msiinfo` utility is present in the container.
## Important
Installer technology detection and a suggested command do not guarantee vendor-specific compatibility. Test generated commands on a designated test asset before broad deployment.
+7 -1
View File
@@ -1,3 +1,9 @@
- [0.5.5.81](UPDATE-0.5.5.81.md) - fix generated PowerShell process-control syntax for software jobs.
- [0.5.5.80](UPDATE-0.5.5.80.md) - add configurable process control before install/uninstall and improve Inno application closing.
- [0.5.5.79](UPDATE-0.5.5.79.md) - fix Greenshot SYSTEM deployment and add software-package deletion.
- [0.5.5.78](UPDATE-0.5.5.78.md) - fix software-package job exit-code handling and action layout.
- [0.5.5.77](UPDATE-0.5.5.77.md) - add persistent software packages and install/uninstall/reinstall deployment jobs; fix installer wait behavior.
- [0.5.5.76](UPDATE-0.5.5.76.md) - add the static Setup Analyzer with silent-install command and deployment-script export.
- [0.5.5.75](UPDATE-0.5.5.75.md) - resize only the dragged table column while the total table width grows or shrinks by the same amount.
- [0.5.5.74](UPDATE-0.5.5.74.md) - keep column resize handles, filter cells and stored widths aligned after reordering Asset list columns.
- [0.5.5.73](UPDATE-0.5.5.73.md) - save table filters, sorting and column widths in the signed-in user account instead of only in browser storage.
@@ -11,7 +17,7 @@
Release notes are stored outside the project root to keep the repository overview compact.
The current release is **0.5.5.68**.
The current release is **0.5.5.81**.
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
+23
View File
@@ -0,0 +1,23 @@
# AssetManager 0.5.5.76
## Added
- Added an administrator-only Setup Analyzer under **Software and Jobs → Software lists**.
- Added static installer detection for MSI, MSP, MSIX/AppX, MSU, Inno Setup, NSIS, WiX Burn, InstallShield, Advanced Installer, Squirrel, and common SFX wrappers.
- Added SHA256 calculation, PE metadata, architecture, Authenticode-presence indication, detection confidence, and detected switch strings.
- Added editable recommended silent arguments, success/reboot exit codes, execution context, and timeout.
- Added PowerShell installation-script export.
- Added deployment ZIP export containing the installer, `install.ps1`, `uninstall.ps1`, `detect.ps1`, `package.json`, and `analysis.json`.
- Added German and English interface translations for the analyzer.
## Security
- Uploaded installers are analyzed statically and are never executed on the AssetManager server.
- Analyzer access requires administrator permissions.
- Uploaded analysis files are stored below a temporary directory and removed automatically after the configured retention period.
## Compatibility
- Built directly on 0.5.5.75.
- Existing table filtering, column order, independent column resizing, and per-user stored table state remain unchanged.
- No database migration is required.
+23
View File
@@ -0,0 +1,23 @@
# AssetManager 0.5.5.77
## Added
- Added persistent AssetManager software packages created directly from Setup Analyzer results.
- Added a **Software packages** administration page under **Software and Jobs**.
- Added install, uninstall, and reinstall software jobs for stored packages.
- Added bulk asset selection for software-package jobs.
- Added MeshCentral transfer of installer/package files before package-job execution.
- Added package storage under `data/software-packages/` and included that storage in AssetManager backup and restore.
- Added an optional post-install browser suppression setting; Greenshot detections enable it by default.
## Fixed
- Generated installation PowerShell scripts no longer use `Start-Process -Wait`, which can wait for the complete child-process tree.
- Installer completion now waits for the setup PID only, with an explicit timeout, so browser child processes cannot keep the generated installation script open indefinitely.
- Added deployment timeout propagation to the MeshCentral dispatcher so longer package installations are not cut off by the default dispatcher timeout.
## Compatibility
- Built directly on 0.5.5.76, which itself was built on the verified 0.5.5.75 source tree.
- No database schema migration is required; the existing `software_packages` and `software_jobs` tables are used.
- Existing asset tables, filters, column order, column resizing, per-user table state, inventory jobs, and custom job definitions are unchanged.
+19
View File
@@ -0,0 +1,19 @@
# AssetManager 0.5.5.78
## Fixed
- Fixed software-package install, uninstall, and reinstall jobs when package scripts write text to standard output.
- Package script output is now captured into the AssetManager client log while the deployment helper returns exactly one integer exit code.
- Detection-script output is also captured without contaminating the returned Boolean detection result.
- Added defensive integer normalization for package-script exit codes before result validation.
- Fixed the software-package action selector layout so Install, Uninstall, and Reinstall stay compact and left-aligned instead of stretching beyond the viewport.
- Fixed generated registry detection so versioned display names such as `Greenshot 1.3.312` match the package name `Greenshot`.
- Generated uninstall scripts now prefer `QuietUninstallString`, log the selected registry entry and command, start the actual uninstaller process, and wait for its PID with a timeout.
- Reinstall jobs verify that the software is no longer detected before starting the installation step.
- Existing Setup-Analyzer software packages are refreshed automatically when their payload is prepared, so they do not need to be recreated for this fix.
## Compatibility
- Built directly on 0.5.5.77.
- Existing software packages remain compatible; they do not need to be recreated for this fix.
- No database schema migration is required.
+20
View File
@@ -0,0 +1,20 @@
# Update 0.5.5.79
## Software package deployment fixes
- Existing Setup Analyzer packages now refresh install, detect and uninstall runtime scripts before transfer.
- Greenshot Inno packages are normalized for SYSTEM deployment with `/ALLUSERS` and `/DIR="C:\Program Files\Greenshot"` unless an explicit scope or destination was configured.
- Inno installer execution logs the working directory, PID, exit code and selected setup-log diagnostics.
- Registry detection and uninstall support version-suffixed display names such as `Greenshot 1.3.312`.
## Software package administration
- Stored deployment packages can be deleted from the package detail page.
- If jobs reference the package, deleting those jobs requires a separate explicit checkbox.
- Package files under `data/software-packages/<id>` are removed with the package definition.
## Compatibility
- Built directly on 0.5.5.78.
- Existing Greenshot packages are normalized automatically and do not need to be recreated.
- No database schema migration is required.
+18
View File
@@ -0,0 +1,18 @@
# Update 0.5.5.80
## Software package process control
- Software packages can store executable names that must be closed before installation and uninstallation.
- AssetManager first requests a normal application close and waits for a configurable grace period.
- If enabled, remaining processes are terminated forcibly before deployment continues.
- Deployment logs report found processes, close requests, forced termination and remaining process errors.
- The process list, grace period and force-close behavior can be edited on the software-package detail page.
- Setup Analyzer suggests `Greenshot.exe` automatically for Greenshot packages.
- Existing Greenshot packages are normalized automatically and receive the process-control preset when they are loaded or used.
- Inno Setup packages with process control also receive `/CLOSEAPPLICATIONS` and `/FORCECLOSEAPPLICATIONS` when appropriate.
## Compatibility
- Based on 0.5.5.79.
- No database migration is required; process-control settings are stored in the package manifest.
- Existing software packages remain compatible.
+14
View File
@@ -0,0 +1,14 @@
# Update 0.5.5.81
## Fixed
- Correct generated PowerShell syntax in software-package process control.
- Avoid invalid `$Phase:` variable references in generated install and uninstall scripts.
- Process-control logging now builds the phase label using string concatenation.
- Existing package runtime scripts are regenerated automatically before the next software job.
## Effect
Version 0.5.5.80 could generate an `uninstall.ps1` that failed during PowerShell parsing before any process-control or uninstall command was executed. Version 0.5.5.81 fixes this generator error.
Existing software packages do not need to be recreated.
+1
View File
@@ -9,3 +9,4 @@ itsdangerous
ldap3
passlib[bcrypt]
openpyxl
pefile==2024.8.26