software packages and deploying
This commit is contained in:
@@ -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
@@ -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}"),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Executable
+948
@@ -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
@@ -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)
|
||||
|
||||
Executable
+902
@@ -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)
|
||||
@@ -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;}
|
||||
|
||||
Executable
+147
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Executable
+134
@@ -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 %}
|
||||
Executable
+50
@@ -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
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = "0.5.5.75"
|
||||
APP_VERSION = "0.5.5.81"
|
||||
__version__ = APP_VERSION
|
||||
|
||||
Reference in New Issue
Block a user