software package profiles implemeted, job deletion optimized
This commit is contained in:
@@ -26,6 +26,7 @@ data/backups
|
||||
data/backup
|
||||
data/scripts
|
||||
data/software-packages
|
||||
data/analyzer-profiles
|
||||
app/static/uploads/*
|
||||
!app/static/uploads/.gitkeep
|
||||
app/main_old.py
|
||||
|
||||
@@ -15,3 +15,15 @@ BACKUP_RETENTION_DAYS=3
|
||||
# address or 0.0.0.0 only when the listener must be reachable directly.
|
||||
CALLBACK_BIND_IP=127.0.0.1
|
||||
CALLBACK_PORT=8090
|
||||
|
||||
# Setup Analyzer SFX safety limits
|
||||
SETUP_ANALYZER_MAX_EXTRACTED_MB=8192
|
||||
SETUP_ANALYZER_MAX_EXTRACTED_FILES=20000
|
||||
SETUP_ANALYZER_MAX_SFX_DEPTH=2
|
||||
# Analyzer profile system / community repository
|
||||
ANALYZER_PROFILE_REPOSITORY_URL=
|
||||
ANALYZER_PROFILE_MAX_BYTES=2097152
|
||||
ANALYZER_PROFILE_REPOSITORY_MAX_BYTES=4194304
|
||||
SOFTWARE_PACKAGE_IMPORT_MAX_MB=8192
|
||||
SOFTWARE_PACKAGE_IMPORT_MAX_FILES=20000
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ data/scripts/*
|
||||
!data/scripts/.gitkeep
|
||||
data/software-packages/*
|
||||
!data/software-packages/.gitkeep
|
||||
data/analyzer-profiles/*
|
||||
!data/analyzer-profiles/.gitkeep
|
||||
app/static/uploads/*
|
||||
!app/static/uploads/.gitkeep
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.title="AssetManager" \
|
||||
|
||||
WORKDIR /opt/meshcentral
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client \
|
||||
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client 7zip unar \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install --omit=dev meshcentral \
|
||||
&& chown -R root:root /opt/meshcentral
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AssetManager 0.5.5.88
|
||||
# AssetManager 0.5.5.90
|
||||
|
||||
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
|
||||
|
||||
@@ -9,7 +9,7 @@ The project is licensed under the **Apache License 2.0** and may be used, modifi
|
||||
- configurable asset categories, fields, status values, images, and assignments
|
||||
- asset lists, detail views, history, bulk editing, Excel import/export, duplicate merging, and tree views
|
||||
- hardware and software inventory with comparison, aggregation views, and Excel export
|
||||
- static Setup Analyzer for Windows installer technology, silent parameters, deployment-script export, and persistent software-package creation
|
||||
- static Setup Analyzer for Windows installer technology, silent parameters, recursive SFX/embedded-installer analysis, modular analyzer profiles with import/export/community repository support, deployment-script export, and persistent software-package creation/import/export
|
||||
- install, uninstall, and reinstall software-package jobs with MeshCentral file transfer, callback tracking, process control, and optional post-install application launch
|
||||
- job definitions and remote job execution with status tracking, callbacks, retries, and logs
|
||||
- optional MeshCentral integration through the MeshCtrl command-line interface
|
||||
@@ -141,4 +141,4 @@ Copyright © 2026 Roland Reich
|
||||
Licensed under the Apache License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for the complete license text.
|
||||
|
||||
|
||||
Reusable asset/category images are persisted in `./data/uploads/library` and mounted into the application container.
|
||||
Reusable asset/category images are persisted in `./data/uploads/library` below the common AssetManager data root mounted into the application container.
|
||||
|
||||
@@ -19,6 +19,9 @@ AssetManager is released under the Apache License 2.0. The following components
|
||||
| bcrypt | Hashing library used by `passlib[bcrypt]` | Apache-2.0 |
|
||||
| openpyxl | Excel import and export | MIT |
|
||||
| pefile | Static Portable Executable metadata analysis for Setup Analyzer | MIT |
|
||||
| cryptography | Static Authenticode certificate metadata parsing for Setup Analyzer | Apache-2.0 OR BSD-3-Clause |
|
||||
| 7-Zip command-line tools | Static extraction of supported SFX/archive payloads in Setup Analyzer | System package; see distribution package license metadata |
|
||||
| The Unarchiver (`unar` / `lsar`) | Static extraction/listing fallback for supported SFX/archive payloads | System package; see distribution package license metadata |
|
||||
| MeshCentral / MeshCtrl | Optional integration and remote jobs | Apache-2.0 |
|
||||
| PostgreSQL container image | Database service | Contains PostgreSQL and operating-system packages under their own licenses |
|
||||
| Python container image | Runtime base | Contains Python and operating-system packages under their own licenses |
|
||||
|
||||
Executable
+630
@@ -0,0 +1,630 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROFILE_SCHEMA = "assetmanager-analyzer-profile-v1"
|
||||
PROFILE_BUNDLE_SCHEMA = "assetmanager-analyzer-profile-bundle-v1"
|
||||
PROFILE_REPOSITORY_SCHEMA = "assetmanager-analyzer-profile-repository-v1"
|
||||
PROFILE_API = 1
|
||||
SYSTEM_PROFILE_DIR = Path(__file__).resolve().parent / "analyzer_profiles" / "system"
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
PROFILE_DATA_ROOT = Path(os.getenv("ANALYZER_PROFILE_DIR", str(DATA_ROOT / "analyzer-profiles")))
|
||||
COMMUNITY_PROFILE_DIR = PROFILE_DATA_ROOT / "community"
|
||||
LOCAL_PROFILE_DIR = PROFILE_DATA_ROOT / "local"
|
||||
PROFILE_STATE_FILE = PROFILE_DATA_ROOT / "state.json"
|
||||
REPOSITORY_URL = os.getenv("ANALYZER_PROFILE_REPOSITORY_URL", "").strip()
|
||||
MAX_PROFILE_BYTES = max(64 * 1024, int(os.getenv("ANALYZER_PROFILE_MAX_BYTES", str(2 * 1024 * 1024))))
|
||||
MAX_REPOSITORY_INDEX_BYTES = max(64 * 1024, int(os.getenv("ANALYZER_PROFILE_REPOSITORY_MAX_BYTES", str(4 * 1024 * 1024))))
|
||||
|
||||
for _directory in (COMMUNITY_PROFILE_DIR, LOCAL_PROFILE_DIR):
|
||||
_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _safe_profile_id(value: str) -> str:
|
||||
value = str(value or "").strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{1,95}", value):
|
||||
raise ValueError("invalid profile id")
|
||||
return value
|
||||
|
||||
|
||||
def _clean_text(value: Any, maximum: int = 500) -> str:
|
||||
return re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()[:maximum]
|
||||
|
||||
|
||||
def _state() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(PROFILE_STATE_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(data: dict[str, Any]) -> None:
|
||||
PROFILE_DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
temporary = PROFILE_STATE_FILE.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.replace(PROFILE_STATE_FILE)
|
||||
|
||||
|
||||
def profile_enabled(profile_id: str) -> bool:
|
||||
return not bool((_state().get("disabled") or {}).get(profile_id))
|
||||
|
||||
|
||||
def set_profile_enabled(profile_id: str, enabled: bool) -> None:
|
||||
profile_id = _safe_profile_id(profile_id)
|
||||
data = _state()
|
||||
disabled = data.setdefault("disabled", {})
|
||||
if enabled:
|
||||
disabled.pop(profile_id, None)
|
||||
else:
|
||||
disabled[profile_id] = True
|
||||
_write_state(data)
|
||||
|
||||
|
||||
def validate_profile(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("profile is not an object")
|
||||
profile = json.loads(json.dumps(raw))
|
||||
if str(profile.get("schema") or "") != PROFILE_SCHEMA:
|
||||
raise ValueError("unsupported analyzer profile schema")
|
||||
if int(profile.get("profile_api") or 0) != PROFILE_API:
|
||||
raise ValueError("unsupported analyzer profile API")
|
||||
profile["id"] = _safe_profile_id(profile.get("id"))
|
||||
profile["name"] = _clean_text(profile.get("name"), 180)
|
||||
profile["version"] = _clean_text(profile.get("version") or "1.0.0", 40)
|
||||
if not profile["name"]:
|
||||
raise ValueError("profile name is required")
|
||||
stage = str(profile.get("stage") or "analysis").strip().lower()
|
||||
if stage not in {"marker", "analysis", "sfx_extracted"}:
|
||||
raise ValueError("invalid analyzer profile stage")
|
||||
profile["stage"] = stage
|
||||
kind = str(profile.get("kind") or "vendor").strip().lower()
|
||||
if kind not in {"technology", "vendor", "generic"}:
|
||||
raise ValueError("invalid analyzer profile kind")
|
||||
profile["kind"] = kind
|
||||
try:
|
||||
profile["priority"] = max(-10000, min(int(profile.get("priority") or 0), 10000))
|
||||
except (TypeError, ValueError):
|
||||
profile["priority"] = 0
|
||||
for key in ("match", "result", "command", "metadata"):
|
||||
if key in profile and not isinstance(profile[key], dict):
|
||||
raise ValueError(f"profile {key} must be an object")
|
||||
if stage == "marker":
|
||||
markers = (profile.get("match") or {}).get("markers") or []
|
||||
if not isinstance(markers, list) or not markers:
|
||||
raise ValueError("marker profile requires match.markers")
|
||||
for marker in markers:
|
||||
if not isinstance(marker, dict) or not _clean_text(marker.get("text"), 1024):
|
||||
raise ValueError("invalid marker definition")
|
||||
return profile
|
||||
|
||||
|
||||
def _load_profile_file(path: Path, source: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
if path.stat().st_size > MAX_PROFILE_BYTES:
|
||||
return None
|
||||
profile = validate_profile(json.loads(path.read_text(encoding="utf-8")))
|
||||
profile["source"] = source
|
||||
profile["path"] = str(path)
|
||||
profile["enabled"] = profile_enabled(profile["id"])
|
||||
return profile
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_profiles(include_disabled: bool = False) -> list[dict[str, Any]]:
|
||||
profiles: dict[str, dict[str, Any]] = {}
|
||||
# System is the fallback. Community can override a system profile and local
|
||||
# profiles have highest precedence without modifying application files.
|
||||
for directory, source in (
|
||||
(SYSTEM_PROFILE_DIR, "system"),
|
||||
(COMMUNITY_PROFILE_DIR, "community"),
|
||||
(LOCAL_PROFILE_DIR, "local"),
|
||||
):
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for path in sorted(directory.glob("*.json")):
|
||||
profile = _load_profile_file(path, source)
|
||||
if profile:
|
||||
profiles[profile["id"]] = profile
|
||||
result = list(profiles.values())
|
||||
if not include_disabled:
|
||||
result = [profile for profile in result if profile.get("enabled", True)]
|
||||
result.sort(key=lambda item: (int(item.get("priority") or 0), item.get("name", "").casefold()), reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def marker_needles() -> dict[bytes, str]:
|
||||
result: dict[bytes, str] = {}
|
||||
for profile in load_profiles():
|
||||
if profile.get("stage") != "marker":
|
||||
continue
|
||||
for marker in (profile.get("match") or {}).get("markers") or []:
|
||||
text = _clean_text(marker.get("text"), 1024).casefold()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
raw = text.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
continue
|
||||
result[raw] = text
|
||||
try:
|
||||
result[text.encode("utf-16le")] = text
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def detect_marker_profiles(found_markers: set[str]) -> list[dict[str, Any]]:
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for profile in load_profiles():
|
||||
if profile.get("stage") != "marker":
|
||||
continue
|
||||
score = 0
|
||||
signals: list[str] = []
|
||||
matched: list[str] = []
|
||||
for marker in (profile.get("match") or {}).get("markers") or []:
|
||||
text = _clean_text(marker.get("text"), 1024).casefold()
|
||||
if text and text in found_markers:
|
||||
try:
|
||||
score += int(marker.get("points") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
signal = _clean_text(marker.get("signal_key"), 180)
|
||||
if signal and signal not in signals:
|
||||
signals.append(signal)
|
||||
matched.append(text)
|
||||
if not score:
|
||||
continue
|
||||
result = profile.get("result") or {}
|
||||
floor = int(result.get("confidence_floor") or 55)
|
||||
candidates.append({
|
||||
"key": str(result.get("installer_type") or profile["id"]),
|
||||
"label": str(result.get("installer_label") or profile["name"]),
|
||||
"confidence": max(floor, min(score, 99)),
|
||||
"signals": signals,
|
||||
"profile_id": profile["id"],
|
||||
"profile_name": profile["name"],
|
||||
"profile_version": profile["version"],
|
||||
"profile_source": profile["source"],
|
||||
"matched_rules": matched,
|
||||
})
|
||||
candidates.sort(key=lambda item: int(item.get("confidence") or 0), reverse=True)
|
||||
return candidates
|
||||
|
||||
|
||||
def _profile_for_installer_type(installer_type: str) -> dict[str, Any] | None:
|
||||
installer_type = str(installer_type or "").strip().casefold()
|
||||
matches = []
|
||||
for profile in load_profiles():
|
||||
result = profile.get("result") or {}
|
||||
if str(result.get("installer_type") or "").strip().casefold() == installer_type:
|
||||
matches.append(profile)
|
||||
matches.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
|
||||
def installer_type_flag(installer_type: str, key: str, default: bool = False) -> bool:
|
||||
profile = _profile_for_installer_type(installer_type)
|
||||
if not profile:
|
||||
return default
|
||||
result = profile.get("result") or {}
|
||||
if key in result:
|
||||
return bool(result.get(key))
|
||||
return default
|
||||
|
||||
def command_profile(installer_type: str) -> dict[str, Any]:
|
||||
profile = _profile_for_installer_type(installer_type)
|
||||
if not profile:
|
||||
return {}
|
||||
command = json.loads(json.dumps(profile.get("command") or {}))
|
||||
if command:
|
||||
command["profile_id"] = profile["id"]
|
||||
command["profile_name"] = profile["name"]
|
||||
command["profile_version"] = profile["version"]
|
||||
command["profile_source"] = profile["source"]
|
||||
return command
|
||||
|
||||
|
||||
def _identity(analysis: dict[str, Any], filename: str) -> str:
|
||||
return " ".join([
|
||||
filename,
|
||||
str(analysis.get("product_name") or ""),
|
||||
str(analysis.get("manufacturer") or ""),
|
||||
str(analysis.get("installer_label") or ""),
|
||||
]).casefold()
|
||||
|
||||
|
||||
def _matches_analysis(profile: dict[str, Any], analysis: dict[str, Any], filename: str, found_markers: set[str]) -> tuple[bool, list[str]]:
|
||||
match = profile.get("match") or {}
|
||||
reasons: list[str] = []
|
||||
installer_types = [str(item).casefold() for item in match.get("installer_types") or []]
|
||||
if installer_types and str(analysis.get("installer_type") or "").casefold() not in installer_types:
|
||||
return False, []
|
||||
if installer_types:
|
||||
reasons.append("installer_type")
|
||||
patterns = match.get("filename_regex") or []
|
||||
if patterns:
|
||||
if not any(re.search(str(pattern), filename, flags=re.IGNORECASE) for pattern in patterns):
|
||||
return False, []
|
||||
reasons.append("filename")
|
||||
identity = _identity(analysis, filename)
|
||||
all_values = [str(item).casefold() for item in match.get("identity_contains_all") or []]
|
||||
if any(value not in identity for value in all_values):
|
||||
return False, []
|
||||
if all_values:
|
||||
reasons.append("identity_all")
|
||||
any_values = [str(item).casefold() for item in match.get("identity_contains_any") or []]
|
||||
if any_values and not any(value in identity for value in any_values):
|
||||
return False, []
|
||||
if any_values:
|
||||
reasons.append("identity_any")
|
||||
marker_all = [str(item).casefold() for item in match.get("markers_all") or []]
|
||||
if any(value not in found_markers for value in marker_all):
|
||||
return False, []
|
||||
marker_any = [str(item).casefold() for item in match.get("markers_any") or []]
|
||||
if marker_any and not any(value in found_markers for value in marker_any):
|
||||
return False, []
|
||||
if marker_all or marker_any:
|
||||
reasons.append("markers")
|
||||
return True, reasons
|
||||
|
||||
|
||||
def _apply_result_overlay(analysis: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]:
|
||||
result = profile.get("result") or {}
|
||||
updated = dict(analysis)
|
||||
for key, value in result.get("set", {}).items():
|
||||
updated[key] = value
|
||||
for key, value in result.get("set_if_empty", {}).items():
|
||||
if not updated.get(key):
|
||||
updated[key] = value
|
||||
for key, values in result.get("append", {}).items():
|
||||
current = updated.get(key)
|
||||
if not isinstance(current, list):
|
||||
current = []
|
||||
for value in values if isinstance(values, list) else [values]:
|
||||
if value not in current:
|
||||
current.append(value)
|
||||
updated[key] = current
|
||||
updated["profile_id"] = profile["id"]
|
||||
updated["profile_name"] = profile["name"]
|
||||
updated["profile_version"] = profile["version"]
|
||||
updated["profile_source"] = profile["source"]
|
||||
return updated
|
||||
|
||||
|
||||
def apply_analysis_profiles(analysis: dict[str, Any], filename: str, found_markers: set[str]) -> dict[str, Any]:
|
||||
matches: list[tuple[int, dict[str, Any], list[str]]] = []
|
||||
for profile in load_profiles():
|
||||
if profile.get("stage") != "analysis":
|
||||
continue
|
||||
matched, reasons = _matches_analysis(profile, analysis, filename, found_markers)
|
||||
if matched:
|
||||
matches.append((int(profile.get("priority") or 0), profile, reasons))
|
||||
matches.sort(key=lambda item: item[0], reverse=True)
|
||||
updated = dict(analysis)
|
||||
applied: list[dict[str, Any]] = []
|
||||
for _priority, profile, reasons in matches:
|
||||
updated = _apply_result_overlay(updated, profile)
|
||||
command = profile.get("command") or {}
|
||||
if command:
|
||||
updated = apply_command_overlay(updated, command, filename)
|
||||
metadata = profile.get("metadata") or {}
|
||||
if metadata.get("filename_version_regex") and not updated.get("product_version"):
|
||||
match = re.search(str(metadata["filename_version_regex"]), filename, flags=re.IGNORECASE)
|
||||
if match:
|
||||
updated["product_version"] = match.groupdict().get("version") or (match.group(1) if match.groups() else "")
|
||||
applied.append({
|
||||
"id": profile["id"], "name": profile["name"], "version": profile["version"],
|
||||
"source": profile["source"], "reasons": reasons,
|
||||
})
|
||||
if applied:
|
||||
updated["applied_profiles"] = applied
|
||||
return updated
|
||||
|
||||
|
||||
def apply_command_overlay(analysis: dict[str, Any], command: dict[str, Any], filename: str) -> dict[str, Any]:
|
||||
updated = dict(analysis)
|
||||
arguments = str(command.get("install_arguments") or "").strip()
|
||||
if arguments:
|
||||
updated["install_arguments"] = arguments
|
||||
updated["install_command"] = f'"{filename}" {arguments}'.strip()
|
||||
alternative = str(command.get("alternative_install_arguments") or "").strip()
|
||||
if alternative:
|
||||
updated["alternative_install_arguments"] = alternative
|
||||
updated["alternative_install_command"] = f'"{filename}" {alternative}'.strip()
|
||||
for key in ("success_codes", "reboot_codes", "detect_method", "command_confidence", "uninstall_command"):
|
||||
if key in command:
|
||||
updated[key] = command[key]
|
||||
for warning in command.get("warning_keys") or []:
|
||||
warnings = updated.setdefault("warning_keys", [])
|
||||
if warning not in warnings:
|
||||
warnings.append(warning)
|
||||
return updated
|
||||
|
||||
|
||||
def _read_relaxed_ini(path: Path) -> dict[str, dict[str, str]]:
|
||||
data = path.read_bytes()
|
||||
text = ""
|
||||
for encoding in ("utf-8-sig", "cp1252", "latin-1"):
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
sections: dict[str, dict[str, str]] = {}
|
||||
current = ""
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith((";", "#", "//")):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
current = line[1:-1].strip().casefold()
|
||||
sections.setdefault(current, {})
|
||||
continue
|
||||
if current and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
sections[current][key.strip().casefold()] = value.strip()
|
||||
return sections
|
||||
|
||||
|
||||
def _ini_value(sections: dict[str, dict[str, str]], spec: dict[str, Any]) -> str:
|
||||
section = str(spec.get("section") or "").casefold()
|
||||
key = str(spec.get("key") or "").casefold()
|
||||
return str((sections.get(section) or {}).get(key) or "").strip()
|
||||
|
||||
|
||||
def _find_glob(root: Path, pattern: str) -> list[Path]:
|
||||
pattern = str(pattern or "").strip()
|
||||
if not pattern:
|
||||
return []
|
||||
return sorted([item for item in root.rglob(pattern) if item.is_file()], key=lambda p: (len(p.relative_to(root).parts), p.as_posix().casefold()))
|
||||
|
||||
|
||||
def _matches_sfx_profile(profile: dict[str, Any], root: Path) -> tuple[bool, dict[str, Any], list[str]]:
|
||||
match = profile.get("match") or {}
|
||||
reasons: list[str] = []
|
||||
for pattern in match.get("extracted_files_all") or []:
|
||||
if not _find_glob(root, str(pattern)):
|
||||
return False, {}, []
|
||||
reasons.append(f"file:{pattern}")
|
||||
any_patterns = match.get("extracted_files_any") or []
|
||||
if any_patterns and not any(_find_glob(root, str(pattern)) for pattern in any_patterns):
|
||||
return False, {}, []
|
||||
if any_patterns:
|
||||
reasons.append("extracted_file_any")
|
||||
ini_spec = match.get("ini") or {}
|
||||
context: dict[str, Any] = {}
|
||||
if ini_spec:
|
||||
files = _find_glob(root, str(ini_spec.get("glob") or ""))
|
||||
if not files:
|
||||
return False, {}, []
|
||||
ini_path = files[0]
|
||||
sections = _read_relaxed_ini(ini_path)
|
||||
for condition in ini_spec.get("conditions_all") or []:
|
||||
value = _ini_value(sections, condition).casefold()
|
||||
contains = [str(item).casefold() for item in condition.get("contains_any") or []]
|
||||
regex = str(condition.get("regex") or "")
|
||||
if contains and not any(item in value for item in contains):
|
||||
return False, {}, []
|
||||
if regex and not re.search(regex, value, flags=re.IGNORECASE):
|
||||
return False, {}, []
|
||||
context = {"ini_path": ini_path, "ini": sections}
|
||||
reasons.append(f"ini:{ini_path.relative_to(root).as_posix()}")
|
||||
return True, context, reasons
|
||||
|
||||
|
||||
def _metadata_from_sfx(profile: dict[str, Any], context: dict[str, Any], outer_path: Path) -> dict[str, Any]:
|
||||
metadata = profile.get("metadata") or {}
|
||||
sections = context.get("ini") or {}
|
||||
result: dict[str, Any] = {}
|
||||
for target, source in (metadata.get("ini_fields") or {}).items():
|
||||
specs = source if isinstance(source, list) else [source]
|
||||
for spec in specs:
|
||||
value = _ini_value(sections, spec)
|
||||
if value:
|
||||
result[target] = value
|
||||
break
|
||||
for target, value in (metadata.get("fixed") or {}).items():
|
||||
if not result.get(target):
|
||||
result[target] = value
|
||||
arch = metadata.get("architecture_from") or {}
|
||||
if arch:
|
||||
value = _ini_value(sections, arch)
|
||||
for item in arch.get("patterns") or []:
|
||||
if re.search(str(item.get("regex") or ""), value, flags=re.IGNORECASE):
|
||||
result["architecture"] = str(item.get("value") or "")
|
||||
result["architecture_source"] = str(metadata.get("architecture_source") or "profile_metadata")
|
||||
break
|
||||
process = metadata.get("process_name_from") or {}
|
||||
if process:
|
||||
value = _ini_value(sections, process)
|
||||
if value:
|
||||
result["process_names_default"] = value
|
||||
version_regex = str(metadata.get("filename_version_regex") or "")
|
||||
if version_regex and not result.get("product_version"):
|
||||
match = re.search(version_regex, outer_path.name, flags=re.IGNORECASE)
|
||||
if match:
|
||||
result["product_version"] = match.groupdict().get("version") or (match.group(1) if match.groups() else "")
|
||||
return result
|
||||
|
||||
|
||||
def match_sfx_profiles(root: Path, outer_path: Path, base: dict[str, Any]) -> dict[str, Any] | None:
|
||||
matches: list[tuple[int, dict[str, Any], dict[str, Any], list[str]]] = []
|
||||
for profile in load_profiles():
|
||||
if profile.get("stage") != "sfx_extracted":
|
||||
continue
|
||||
matched, context, reasons = _matches_sfx_profile(profile, root)
|
||||
if matched:
|
||||
matches.append((int(profile.get("priority") or 0), profile, context, reasons))
|
||||
if not matches:
|
||||
return None
|
||||
matches.sort(key=lambda item: item[0], reverse=True)
|
||||
_priority, profile, context, reasons = matches[0]
|
||||
result = dict(base)
|
||||
profile_result = profile.get("result") or {}
|
||||
result.update(profile_result.get("set") or {})
|
||||
result.update(_metadata_from_sfx(profile, context, outer_path))
|
||||
command = profile.get("command") or {}
|
||||
result = apply_command_overlay(result, command, outer_path.name)
|
||||
result["profile_id"] = profile["id"]
|
||||
result["profile_name"] = profile["name"]
|
||||
result["profile_version"] = profile["version"]
|
||||
result["profile_source"] = profile["source"]
|
||||
result["applied_profiles"] = [{
|
||||
"id": profile["id"], "name": profile["name"], "version": profile["version"],
|
||||
"source": profile["source"], "reasons": reasons,
|
||||
}]
|
||||
ini_path = context.get("ini_path")
|
||||
if ini_path:
|
||||
result["metadata_file"] = ini_path.relative_to(root).as_posix()
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def apply_profile(profile_id: str, analysis: dict[str, Any], filename: str) -> dict[str, Any]:
|
||||
profile = get_profile(profile_id)
|
||||
if not profile or not profile.get("enabled", True):
|
||||
return dict(analysis)
|
||||
updated = _apply_result_overlay(analysis, profile)
|
||||
command = profile.get("command") or {}
|
||||
if command:
|
||||
updated = apply_command_overlay(updated, command, filename)
|
||||
updated["applied_profiles"] = [{
|
||||
"id": profile["id"], "name": profile["name"], "version": profile["version"],
|
||||
"source": profile["source"], "reasons": ["marker_profile"],
|
||||
}]
|
||||
return updated
|
||||
|
||||
def _profile_path(profile_id: str, source: str) -> Path:
|
||||
profile_id = _safe_profile_id(profile_id)
|
||||
if source == "system":
|
||||
return SYSTEM_PROFILE_DIR / f"{profile_id}.json"
|
||||
if source == "community":
|
||||
return COMMUNITY_PROFILE_DIR / f"{profile_id}.json"
|
||||
if source == "local":
|
||||
return LOCAL_PROFILE_DIR / f"{profile_id}.json"
|
||||
raise ValueError("invalid profile source")
|
||||
|
||||
|
||||
def get_profile(profile_id: str) -> dict[str, Any] | None:
|
||||
for profile in load_profiles(include_disabled=True):
|
||||
if profile.get("id") == profile_id:
|
||||
return profile
|
||||
return None
|
||||
|
||||
|
||||
def export_profile_bundle(profile_id: str) -> bytes:
|
||||
profile = get_profile(_safe_profile_id(profile_id))
|
||||
if not profile:
|
||||
raise FileNotFoundError("profile not found")
|
||||
public = {key: value for key, value in profile.items() if key not in {"source", "path", "enabled"}}
|
||||
profile_bytes = (json.dumps(public, ensure_ascii=True, indent=2) + "\n").encode("utf-8")
|
||||
manifest = {
|
||||
"schema": PROFILE_BUNDLE_SCHEMA,
|
||||
"bundle_version": 1,
|
||||
"profile_id": profile["id"],
|
||||
"profile_version": profile["version"],
|
||||
"profile_api": PROFILE_API,
|
||||
"sha256": hashlib.sha256(profile_bytes).hexdigest(),
|
||||
}
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=True, indent=2) + "\n")
|
||||
archive.writestr("profile.json", profile_bytes)
|
||||
stream.seek(0)
|
||||
return stream.read()
|
||||
|
||||
|
||||
def import_profile_bundle(data: bytes, source: str = "community") -> dict[str, Any]:
|
||||
if source not in {"community", "local"}:
|
||||
raise ValueError("invalid import source")
|
||||
if not data or len(data) > MAX_PROFILE_BYTES:
|
||||
raise ValueError("profile bundle is empty or too large")
|
||||
with zipfile.ZipFile(io.BytesIO(data), "r") as archive:
|
||||
names = archive.namelist()
|
||||
if any(name.startswith(("/", "\\")) or ".." in Path(name).parts for name in names):
|
||||
raise ValueError("unsafe profile bundle path")
|
||||
if "manifest.json" not in names or "profile.json" not in names:
|
||||
raise ValueError("profile bundle is incomplete")
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if str(manifest.get("schema") or "") != PROFILE_BUNDLE_SCHEMA:
|
||||
raise ValueError("unsupported profile bundle")
|
||||
profile_bytes = archive.read("profile.json")
|
||||
if hashlib.sha256(profile_bytes).hexdigest() != str(manifest.get("sha256") or ""):
|
||||
raise ValueError("profile checksum mismatch")
|
||||
profile = validate_profile(json.loads(profile_bytes))
|
||||
if profile["id"] != str(manifest.get("profile_id") or ""):
|
||||
raise ValueError("profile id mismatch")
|
||||
path = _profile_path(profile["id"], source)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(profile, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
set_profile_enabled(profile["id"], True)
|
||||
return get_profile(profile["id"]) or profile
|
||||
|
||||
|
||||
def delete_imported_profile(profile_id: str) -> bool:
|
||||
profile = get_profile(_safe_profile_id(profile_id))
|
||||
if not profile or profile.get("source") == "system":
|
||||
return False
|
||||
path = Path(str(profile.get("path") or ""))
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def repository_index(url: str | None = None) -> dict[str, Any]:
|
||||
url = str(url or REPOSITORY_URL).strip()
|
||||
if not url:
|
||||
return {"configured": False, "url": "", "profiles": []}
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise ValueError("profile repository URL must use HTTPS")
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "AssetManager-AnalyzerProfiles/1"})
|
||||
with urllib.request.urlopen(request, timeout=12) as response:
|
||||
data = response.read(MAX_REPOSITORY_INDEX_BYTES + 1)
|
||||
if len(data) > MAX_REPOSITORY_INDEX_BYTES:
|
||||
raise ValueError("profile repository index is too large")
|
||||
index = json.loads(data.decode("utf-8"))
|
||||
if not isinstance(index, dict) or str(index.get("schema") or "") != PROFILE_REPOSITORY_SCHEMA:
|
||||
raise ValueError("unsupported profile repository index")
|
||||
profiles = index.get("profiles") or []
|
||||
if not isinstance(profiles, list):
|
||||
raise ValueError("invalid profile repository index")
|
||||
return {"configured": True, "url": url, "profiles": profiles, "name": _clean_text(index.get("name"), 180)}
|
||||
|
||||
|
||||
def install_repository_profile(profile_id: str, url: str | None = None) -> dict[str, Any]:
|
||||
profile_id = _safe_profile_id(profile_id)
|
||||
index = repository_index(url)
|
||||
entry = next((item for item in index.get("profiles") or [] if str(item.get("id") or "") == profile_id), None)
|
||||
if not entry:
|
||||
raise FileNotFoundError("profile is not present in repository")
|
||||
bundle_url = str(entry.get("url") or "").strip()
|
||||
parsed = urllib.parse.urlparse(bundle_url)
|
||||
if parsed.scheme != "https":
|
||||
raise ValueError("repository profile URL must use HTTPS")
|
||||
request = urllib.request.Request(bundle_url, headers={"User-Agent": "AssetManager-AnalyzerProfiles/1"})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
data = response.read(MAX_PROFILE_BYTES + 1)
|
||||
if len(data) > MAX_PROFILE_BYTES:
|
||||
raise ValueError("repository profile is too large")
|
||||
expected = str(entry.get("sha256") or "").strip().lower()
|
||||
if expected and hashlib.sha256(data).hexdigest() != expected:
|
||||
raise ValueError("repository profile checksum mismatch")
|
||||
return import_profile_bundle(data, source="community")
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.7zip-sfx", "name": "7-Zip SFX", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "__7z_archive_signature__", "points": 90, "signal_key": "setup_analyzer.signal.7zip_signature"},
|
||||
{"text": "7-zip sfx", "points": 75, "signal_key": "setup_analyzer.signal.7zip_sfx"},
|
||||
{"text": "7zs.sfx", "points": 50, "signal_key": "setup_analyzer.signal.7zip_module"}
|
||||
]},
|
||||
"result": {"installer_type": "7zip_sfx", "installer_label": "7-Zip SFX", "confidence_floor": 55},
|
||||
"command": {"warning_keys": ["setup_analyzer.warning.sfx"]}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.advanced-installer", "name": "Advanced Installer", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "advanced installer", "points": 80, "signal_key": "setup_analyzer.signal.advanced_installer"},
|
||||
{"text": "caphyon", "points": 50, "signal_key": "setup_analyzer.signal.caphyon"}
|
||||
]},
|
||||
"result": {"installer_type": "advanced_installer", "installer_label": "Advanced Installer", "confidence_floor": 55},
|
||||
"command": {"install_arguments": "/exenoui /qn /norestart", "success_codes": [0,1641,3010], "reboot_codes": [1641,3010], "command_confidence": "medium", "warning_keys": ["setup_analyzer.warning.advanced_installer"]}
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1",
|
||||
"profile_api": 1,
|
||||
"id": "vendor.greenshot",
|
||||
"name": "Greenshot",
|
||||
"version": "1.0.0",
|
||||
"kind": "vendor",
|
||||
"stage": "analysis",
|
||||
"priority": 400,
|
||||
"match": {
|
||||
"installer_types": [
|
||||
"inno"
|
||||
],
|
||||
"identity_contains_any": [
|
||||
"greenshot"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"set_if_empty": {
|
||||
"product_name": "Greenshot",
|
||||
"manufacturer": "Greenshot"
|
||||
},
|
||||
"set": {
|
||||
"suppress_browser_default": true,
|
||||
"process_names_default": "Greenshot.exe",
|
||||
"start_application_default": true,
|
||||
"start_executable_default": "C:\\Program Files\\Greenshot\\Greenshot.exe",
|
||||
"start_arguments_default": ""
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"filename_version_regex": "(?i)greenshot[-_ ]installer[-_ ](?P<version>\\d+(?:\\.\\d+){1,3})"
|
||||
},
|
||||
"command": {
|
||||
"install_arguments": "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS /DIR=\"C:\\Program Files\\Greenshot\" /CLOSEAPPLICATIONS /FORCECLOSEAPPLICATIONS",
|
||||
"success_codes": [
|
||||
0,
|
||||
3010
|
||||
],
|
||||
"reboot_codes": [
|
||||
3010
|
||||
],
|
||||
"command_confidence": "high"
|
||||
}
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1",
|
||||
"profile_api": 1,
|
||||
"id": "technology.inno",
|
||||
"name": "Inno Setup",
|
||||
"version": "1.0.0",
|
||||
"kind": "technology",
|
||||
"stage": "marker",
|
||||
"priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "inno setup setup data", "points": 75, "signal_key": "setup_analyzer.signal.inno_data"},
|
||||
{"text": "inno setup", "points": 35, "signal_key": "setup_analyzer.signal.inno"},
|
||||
{"text": "innosetup", "points": 20, "signal_key": "setup_analyzer.signal.inno_internal"}
|
||||
]},
|
||||
"result": {"installer_type": "inno", "installer_label": "Inno Setup", "confidence_floor": 55},
|
||||
"command": {"install_arguments": "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-", "success_codes": [0,3010], "reboot_codes": [3010], "command_confidence": "high"}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.installshield", "name": "InstallShield", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "installshield", "points": 80, "signal_key": "setup_analyzer.signal.installshield"},
|
||||
{"text": "installscript", "points": 30, "signal_key": "setup_analyzer.signal.installscript"}
|
||||
]},
|
||||
"result": {"installer_type": "installshield", "installer_label": "InstallShield", "confidence_floor": 55},
|
||||
"command": {"install_arguments": "/s /v\"/qn /norestart\"", "success_codes": [0,1641,3010], "reboot_codes": [1641,3010], "command_confidence": "medium", "warning_keys": ["setup_analyzer.warning.installshield"]}
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.nsis", "name": "NSIS", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "nullsoft install system", "points": 80, "signal_key": "setup_analyzer.signal.nsis_system"},
|
||||
{"text": "nullsoftinst", "points": 55, "signal_key": "setup_analyzer.signal.nsis_installer"},
|
||||
{"text": "nullsoft", "points": 25, "signal_key": "setup_analyzer.signal.nullsoft"},
|
||||
{"text": "nsis", "points": 20, "signal_key": "setup_analyzer.signal.nsis"}
|
||||
]},
|
||||
"result": {"installer_type": "nsis", "installer_label": "NSIS", "confidence_floor": 55},
|
||||
"command": {"install_arguments": "/S", "success_codes": [0,3010], "reboot_codes": [3010], "command_confidence": "high"}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1",
|
||||
"profile_api": 1,
|
||||
"id": "vendor.pdf24-online",
|
||||
"name": "PDF24 Creator Online Installer",
|
||||
"version": "1.0.0",
|
||||
"kind": "vendor",
|
||||
"stage": "marker",
|
||||
"priority": 500,
|
||||
"match": {
|
||||
"markers": [
|
||||
{
|
||||
"text": "global\\pdf24installermutex",
|
||||
"points": 45,
|
||||
"signal_key": "setup_analyzer.signal.profile_marker"
|
||||
},
|
||||
{
|
||||
"text": "pdf24 creator installer",
|
||||
"points": 35,
|
||||
"signal_key": "setup_analyzer.signal.profile_marker"
|
||||
},
|
||||
{
|
||||
"text": "/fromsmallinstaller",
|
||||
"points": 35,
|
||||
"signal_key": "setup_analyzer.signal.profile_marker"
|
||||
},
|
||||
{
|
||||
"text": "download.pdf24.org/pdf24-creator-latest-x64.exe",
|
||||
"points": 20,
|
||||
"signal_key": "setup_analyzer.signal.profile_marker"
|
||||
},
|
||||
{
|
||||
"text": "download.pdf24.org/pdf24-creator-latest-arm64.exe",
|
||||
"points": 10,
|
||||
"signal_key": "setup_analyzer.signal.profile_marker"
|
||||
}
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"installer_type": "pdf24_online",
|
||||
"installer_label": "PDF24 Creator online installer",
|
||||
"confidence_floor": 80,
|
||||
"set": {
|
||||
"product_name": "PDF24 Creator",
|
||||
"manufacturer": "geek software GmbH",
|
||||
"architecture": "x86+x64+arm64",
|
||||
"architecture_source": "profile",
|
||||
"product_version": ""
|
||||
},
|
||||
"wrapper": true
|
||||
},
|
||||
"command": {
|
||||
"install_arguments": "/SILENT",
|
||||
"success_codes": [
|
||||
0,
|
||||
3010
|
||||
],
|
||||
"reboot_codes": [
|
||||
3010
|
||||
],
|
||||
"detect_method": "registry_display_name",
|
||||
"command_confidence": "medium",
|
||||
"warning_keys": [
|
||||
"setup_analyzer.warning.online_bootstrapper"
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.squirrel", "name": "Squirrel", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "squirrel", "points": 55, "signal_key": "setup_analyzer.signal.squirrel"},
|
||||
{"text": "releasify", "points": 25, "signal_key": "setup_analyzer.signal.squirrel_releasify"}
|
||||
]},
|
||||
"result": {"installer_type": "squirrel", "installer_label": "Squirrel", "confidence_floor": 55},
|
||||
"command": {"install_arguments": "--silent", "success_codes": [0], "reboot_codes": [], "command_confidence": "low", "warning_keys": ["setup_analyzer.warning.squirrel"]}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1",
|
||||
"profile_api": 1,
|
||||
"id": "vendor.total-commander",
|
||||
"name": "Total Commander",
|
||||
"version": "1.0.0",
|
||||
"kind": "vendor",
|
||||
"stage": "sfx_extracted",
|
||||
"priority": 500,
|
||||
"match": {
|
||||
"extracted_files_all": [
|
||||
"INSTALL.INF"
|
||||
],
|
||||
"ini": {
|
||||
"glob": "INSTALL.INF",
|
||||
"conditions_all": [
|
||||
{
|
||||
"section": "installation",
|
||||
"key": "program",
|
||||
"contains_any": [
|
||||
"Total Commander"
|
||||
]
|
||||
},
|
||||
{
|
||||
"section": "installation",
|
||||
"key": "publisher",
|
||||
"contains_any": [
|
||||
"Ghisler"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"set": {
|
||||
"installer_type": "totalcmd_sfx",
|
||||
"installer_label": "Total Commander self-extracting installer",
|
||||
"confidence": 99,
|
||||
"architecture_source": "embedded_install_inf",
|
||||
"embedded_switches": [
|
||||
"/A1",
|
||||
"/AH1"
|
||||
],
|
||||
"warning_keys": []
|
||||
},
|
||||
"wrapper": true
|
||||
},
|
||||
"metadata": {
|
||||
"ini_fields": {
|
||||
"product_name": [
|
||||
{
|
||||
"section": "installation",
|
||||
"key": "program"
|
||||
},
|
||||
{
|
||||
"section": "installation",
|
||||
"key": "progname"
|
||||
}
|
||||
],
|
||||
"product_version": {
|
||||
"section": "installation",
|
||||
"key": "progver"
|
||||
},
|
||||
"manufacturer": {
|
||||
"section": "installation",
|
||||
"key": "publisher"
|
||||
}
|
||||
},
|
||||
"fixed": {
|
||||
"manufacturer": "Ghisler Software GmbH"
|
||||
},
|
||||
"architecture_from": {
|
||||
"section": "installation",
|
||||
"key": "program",
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "(?:64\\+32|32\\+64|64 \\+ 32|32 \\+ 64)",
|
||||
"value": "x86+x64"
|
||||
},
|
||||
{
|
||||
"regex": "64[- ]?bit",
|
||||
"value": "x64"
|
||||
},
|
||||
{
|
||||
"regex": "32[- ]?bit",
|
||||
"value": "x86"
|
||||
}
|
||||
]
|
||||
},
|
||||
"architecture_source": "embedded_install_inf",
|
||||
"process_name_from": {
|
||||
"section": "installation",
|
||||
"key": "updatecheck"
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"install_arguments": "/AH1",
|
||||
"alternative_install_arguments": "/A1",
|
||||
"success_codes": [
|
||||
0
|
||||
],
|
||||
"reboot_codes": [],
|
||||
"detect_method": "registry_display_name",
|
||||
"command_confidence": "high"
|
||||
}
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1",
|
||||
"profile_api": 1,
|
||||
"id": "vendor.vlc-media-player",
|
||||
"name": "VLC media player",
|
||||
"version": "1.0.0",
|
||||
"kind": "vendor",
|
||||
"stage": "analysis",
|
||||
"priority": 300,
|
||||
"match": {
|
||||
"installer_types": [
|
||||
"nsis"
|
||||
],
|
||||
"filename_regex": [
|
||||
"^vlc(?:[-_.].*)?\\.exe$"
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"set_if_empty": {
|
||||
"product_name": "VLC media player",
|
||||
"manufacturer": "VideoLAN"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.winrar-sfx", "name": "WinRAR SFX", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "__rar_archive_signature__", "points": 90, "signal_key": "setup_analyzer.signal.rar_signature"},
|
||||
{"text": "winrar sfx", "points": 75, "signal_key": "setup_analyzer.signal.winrar_sfx"},
|
||||
{"text": "rar sfx", "points": 45, "signal_key": "setup_analyzer.signal.rar_sfx"}
|
||||
]},
|
||||
"result": {"installer_type": "winrar_sfx", "installer_label": "WinRAR SFX", "confidence_floor": 55},
|
||||
"command": {"warning_keys": ["setup_analyzer.warning.sfx"]}
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-v1", "profile_api": 1,
|
||||
"id": "technology.wix-burn", "name": "WiX Burn", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100,
|
||||
"match": {"markers": [
|
||||
{"text": "wixburn", "points": 80, "signal_key": "setup_analyzer.signal.wix_burn"},
|
||||
{"text": "wixbundle", "points": 55, "signal_key": "setup_analyzer.signal.wix_bundle"},
|
||||
{"text": "wixstdba", "points": 45, "signal_key": "setup_analyzer.signal.wix_stdba"},
|
||||
{"text": "burn engine", "points": 30, "signal_key": "setup_analyzer.signal.burn_engine"}
|
||||
]},
|
||||
"result": {"installer_type": "wix_burn", "installer_label": "WiX Burn", "confidence_floor": 55},
|
||||
"command": {"install_arguments": "/quiet /norestart", "success_codes": [0,1641,3010], "reboot_codes": [1641,3010], "command_confidence": "high"}
|
||||
}
|
||||
+21
-6
@@ -13,11 +13,13 @@ from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from .version import APP_VERSION
|
||||
|
||||
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"))
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", str(DATA_ROOT / "backups")))
|
||||
CONFIG_PATH = Path(os.getenv("APP_CONFIG", str(DATA_ROOT / "config" / "config.json")))
|
||||
APPINFO_PATH = Path(os.getenv("APPINFO_PATH", str(DATA_ROOT / "config" / "APPINFO.json")))
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(DATA_ROOT / "uploads")))
|
||||
SOFTWARE_PACKAGE_DIR = Path(os.getenv("SOFTWARE_PACKAGE_DIR", str(DATA_ROOT / "software-packages")))
|
||||
ANALYZER_PROFILE_DIR = Path(os.getenv("ANALYZER_PROFILE_DIR", str(DATA_ROOT / "analyzer-profiles")))
|
||||
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-"
|
||||
@@ -103,7 +105,8 @@ def system_storage_information(log_dir: Path | None = None) -> dict:
|
||||
"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"))),
|
||||
"analyzer_profiles": _directory_status(ANALYZER_PROFILE_DIR, create=True),
|
||||
"logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", str(DATA_ROOT / "logs")))),
|
||||
"backups": _directory_status(BACKUP_DIR, create=True),
|
||||
"backup_count": len(backups),
|
||||
"backup_total_size": total_size,
|
||||
@@ -164,6 +167,10 @@ def create_backup(created_by: str = "system", reason: str = "manual") -> dict:
|
||||
target = files_dir / "software-packages"
|
||||
shutil.copytree(SOFTWARE_PACKAGE_DIR, target)
|
||||
included.append("files/software-packages/")
|
||||
if ANALYZER_PROFILE_DIR.is_dir():
|
||||
target = files_dir / "analyzer-profiles"
|
||||
shutil.copytree(ANALYZER_PROFILE_DIR, target)
|
||||
included.append("files/analyzer-profiles/")
|
||||
|
||||
metadata = {
|
||||
"format": 1,
|
||||
@@ -338,6 +345,14 @@ def restore_backup(name: str) -> dict:
|
||||
else:
|
||||
child.unlink()
|
||||
shutil.copytree(files / "software-packages", SOFTWARE_PACKAGE_DIR, dirs_exist_ok=True)
|
||||
if (files / "analyzer-profiles").is_dir():
|
||||
ANALYZER_PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for child in ANALYZER_PROFILE_DIR.iterdir():
|
||||
if child.is_dir():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
shutil.copytree(files / "analyzer-profiles", ANALYZER_PROFILE_DIR, dirs_exist_ok=True)
|
||||
return _metadata(path)
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -3,7 +3,8 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config.json"))
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
CONFIG_PATH = Path(os.getenv("APP_CONFIG", str(DATA_ROOT / "config" / "config.json")))
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"general": {
|
||||
@@ -42,6 +43,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"automatic_retry_max_age_hours": 12,
|
||||
"automatic_retry_interval_seconds": 60,
|
||||
"automatic_retry_max_retries": 3,
|
||||
"remote_job_cleanup_enabled": True,
|
||||
"remote_job_retention_hours": 24,
|
||||
"inventory_exclusion_rules": []
|
||||
},
|
||||
"privacy": {},
|
||||
|
||||
+112
-6
@@ -667,7 +667,7 @@ BASE_TRANSLATIONS.update({
|
||||
"duplicates.confirm_apply": ("Save the merge permanently? The following assets will be deleted.", "Zusammenführung endgültig speichern? Die nachfolgenden Assets werden gelöscht."),
|
||||
"duplicates.merge_unique_error": ("The duplicates could not be merged because a unique value is already assigned to another asset.", "Die Duplikate konnten nicht zusammengeführt werden, weil ein eindeutiger Wert bereits einem anderen Asset zugeordnet ist."),
|
||||
"settings.system_information": ("System information", "Systeminformationen"),
|
||||
"settings.system_information_file_help": ("These values are read from /app/config/APPINFO.json and VERSION and cannot be edited here.", "Diese Angaben werden aus /app/config/APPINFO.json und VERSION gelesen und können hier nicht bearbeitet werden."),
|
||||
"settings.system_information_file_help": ("These values are read from the persistent APPINFO.json and VERSION and cannot be edited here.", "Diese Angaben werden aus der persistenten APPINFO.json und VERSION gelesen und können hier nicht bearbeitet werden."),
|
||||
"settings.storage_information": ("Storage and runtime", "Speicher und Laufzeit"),
|
||||
"settings.storage_information_help": ("The following paths and states are detected at runtime inside the container.", "Die folgenden Pfade und Zustände werden zur Laufzeit im Container ermittelt."),
|
||||
"settings.database": ("Database", "Datenbank"),
|
||||
@@ -1476,10 +1476,25 @@ BASE_TRANSLATIONS.update({
|
||||
"setup_analyzer.product_name": ("Product name", "Produktname"),
|
||||
"setup_analyzer.version": ("Version", "Version"),
|
||||
"setup_analyzer.manufacturer": ("Manufacturer", "Hersteller"),
|
||||
"setup_analyzer.architecture": ("Architecture", "Architektur"),
|
||||
"setup_analyzer.architecture": ("Target architecture", "Zielarchitektur"),
|
||||
"setup_analyzer.launcher_architecture": ("Installer launcher architecture", "Architektur des Setup-Launchers"),
|
||||
"setup_analyzer.architecture_source": ("Target architecture source", "Quelle der Zielarchitektur"),
|
||||
"setup_analyzer.architecture_source.filename": ("Explicit package filename", "Eindeutiger Paketdateiname"),
|
||||
"setup_analyzer.architecture_source.package_metadata": ("Package metadata", "Paketmetadaten"),
|
||||
"setup_analyzer.architecture_source.launcher_requirement": ("64-bit launcher requirement", "64-Bit-Anforderung des Setup-Launchers"),
|
||||
"setup_analyzer.architecture_source.pe_machine": ("Executable PE architecture", "PE-Architektur der Programmdatei"),
|
||||
"setup_analyzer.architecture_source.embedded_install_inf": ("Embedded installer metadata", "Eingebettete Installer-Metadaten"),
|
||||
"setup_analyzer.architecture_source.bootstrapper_targets": ("Architecture-specific bootstrapper download targets", "Architekturspezifische Download-Ziele des Bootstrappers"),
|
||||
"setup_analyzer.version_source": ("Version source", "Quelle der Version"),
|
||||
"setup_analyzer.manufacturer_source": ("Manufacturer source", "Quelle des Herstellers"),
|
||||
"setup_analyzer.metadata_source.package_metadata": ("Package metadata", "Paketmetadaten"),
|
||||
"setup_analyzer.metadata_source.pe_version": ("Executable VERSIONINFO", "VERSIONINFO der Programmdatei"),
|
||||
"setup_analyzer.metadata_source.filename": ("Installer filename", "Dateiname des Installers"),
|
||||
"setup_analyzer.metadata_source.authenticode_signer": ("Authenticode certificate publisher", "Herausgeber des Authenticode-Zertifikats"),
|
||||
"setup_analyzer.installation": ("Installation", "Installation"),
|
||||
"setup_analyzer.arguments": ("Silent arguments", "Silent-Parameter"),
|
||||
"setup_analyzer.recommended_command": ("Recommended command", "Empfohlener Aufruf"),
|
||||
"setup_analyzer.alternative_command": ("Alternative automatic command", "Alternative automatische Installation"),
|
||||
"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"),
|
||||
@@ -1510,8 +1525,11 @@ BASE_TRANSLATIONS.update({
|
||||
"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.zip_sfx": ("ZIP-compatible self-extracting archive", "ZIP-kompatibles selbstentpackendes Archiv"),
|
||||
"setup_analyzer.signal.7zip_signature": ("Embedded 7z archive signature", "Eingebettete 7z-Archivsignatur"),
|
||||
"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.rar_signature": ("Embedded RAR archive signature", "Eingebettete RAR-Archivsignatur"),
|
||||
"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"),
|
||||
@@ -1525,15 +1543,33 @@ BASE_TRANSLATIONS.update({
|
||||
"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.sfx_embedded_selected": ("The SFX payload was extracted statically and the most likely embedded installer was selected heuristically. Test the generated package before broad deployment.", "Der SFX-Payload wurde statisch entpackt und der wahrscheinlichste eingebettete Installer heuristisch ausgewählt. Das erzeugte Paket vor einer breiten Verteilung testen."),
|
||||
"setup_analyzer.warning.sfx_tool_missing": ("The SFX container was detected, but no suitable extraction tool is available in the AssetManager container.", "Der SFX-Container wurde erkannt, aber im AssetManager-Container ist kein geeignetes Entpackwerkzeug verfügbar."),
|
||||
"setup_analyzer.warning.sfx_extract_failed": ("The SFX container was detected but could not be extracted safely.", "Der SFX-Container wurde erkannt, konnte aber nicht sicher entpackt werden."),
|
||||
"setup_analyzer.warning.sfx_no_installer": ("The SFX payload was extracted, but no supported embedded installer candidate was found.", "Der SFX-Payload wurde entpackt, aber es wurde kein unterstützter eingebetteter Installer-Kandidat gefunden."),
|
||||
"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.wrapper_architecture": ("The setup launcher architecture differs from the detected target architecture. This is normal for installer stubs such as NSIS or Inno Setup; the target architecture is evaluated separately.", "Die Architektur des Setup-Launchers unterscheidet sich von der erkannten Zielarchitektur. Das ist bei Installer-Stubs wie NSIS oder Inno Setup normal; die Zielarchitektur wird getrennt bewertet."),
|
||||
"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.sfx_title": ("SFX / embedded installer analysis", "SFX-/Embedded-Installer-Analyse"),
|
||||
"setup_analyzer.sfx_container": ("Outer container", "Äußerer Container"),
|
||||
"setup_analyzer.sfx_status": ("Extraction status", "Entpackstatus"),
|
||||
"setup_analyzer.sfx_status.success": ("Extracted safely", "Sicher entpackt"),
|
||||
"setup_analyzer.sfx_status.tool_missing": ("Extraction tool missing", "Entpackwerkzeug fehlt"),
|
||||
"setup_analyzer.sfx_status.failed": ("Extraction failed", "Entpacken fehlgeschlagen"),
|
||||
"setup_analyzer.sfx_extractor": ("Extractor", "Entpackwerkzeug"),
|
||||
"setup_analyzer.sfx_files": ("Files", "Dateien"),
|
||||
"setup_analyzer.sfx_size": ("Extracted size", "Entpackte Größe"),
|
||||
"setup_analyzer.sfx_selected": ("Selected embedded installer", "Ausgewählter eingebetteter Installer"),
|
||||
"setup_analyzer.sfx_candidates": ("Embedded installer candidates", "Eingebettete Installer-Kandidaten"),
|
||||
"setup_analyzer.sfx_limits": ("SFX safety limits", "SFX-Sicherheitsgrenzen"),
|
||||
"setup_analyzer.sfx_depth": ("recursion depth {depth}", "Rekursionstiefe {depth}"),
|
||||
"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"),
|
||||
@@ -1575,7 +1611,9 @@ BASE_TRANSLATIONS.update({
|
||||
"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_confirm_with_jobs": ('Really delete software package "{package}"? {count} associated software jobs will also be deleted.', 'Softwarepaket "{package}" wirklich löschen? Dabei werden auch {count} zugehörige Softwarejobs gelöscht.'),
|
||||
"software_packages.delete_button": ("Delete package", "Paket löschen"),
|
||||
"software_packages.delete_button_short": ("Delete", "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"),
|
||||
@@ -1588,11 +1626,11 @@ BASE_TRANSLATIONS.update({
|
||||
# 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."),
|
||||
"setup_analyzer.process_names_help": ("Comma-, semicolon- or line-separated executable names. Known applications can be suggested automatically, for example ExampleApp.exe.", "Komma-, Semikolon- oder zeilengetrennte EXE-Namen. Bei bekannten Anwendungen kann der Setup-Analyzer automatisch einen Vorschlag eintragen, z. B. ExampleApp.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_names_help": ("Enter executable names such as ExampleApp.exe. Multiple names can be separated by comma, semicolon or line break.", "EXE-Namen wie ExampleApp.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."),
|
||||
@@ -1602,7 +1640,7 @@ BASE_TRANSLATIONS.update({
|
||||
# v0.5.5.82 post-install application start
|
||||
BASE_TRANSLATIONS.update({
|
||||
"setup_analyzer.start_application": ("Start application after successful installation", "Anwendung nach erfolgreicher Installation starten"),
|
||||
"setup_analyzer.start_application_help": ("Starts the configured application in the active interactive user session after installation detection succeeds. This option is preselected for known applications such as Greenshot.", "Startet die konfigurierte Anwendung nach erfolgreicher Installationserkennung in der aktiven interaktiven Benutzersitzung. Bei bekannten Anwendungen wie Greenshot wird die Option automatisch vorgeschlagen."),
|
||||
"setup_analyzer.start_application_help": ("Starts the configured application in the active interactive user session after installation detection succeeds. This option is preselected for known applications with a matching analyzer profile.", "Startet die konfigurierte Anwendung nach erfolgreicher Installationserkennung in der aktiven interaktiven Benutzersitzung. Bei bekannten Anwendungen mit passendem Analyzer-Profil wird die Option automatisch vorgeschlagen."),
|
||||
"setup_analyzer.start_executable": ("Application executable", "Programmdatei"),
|
||||
"setup_analyzer.start_arguments": ("Application arguments", "Programmparameter"),
|
||||
"setup_analyzer.start_only_if_user_logged_on": ("Start only when an interactive user is logged on", "Nur starten, wenn ein interaktiver Benutzer angemeldet ist"),
|
||||
@@ -1612,7 +1650,7 @@ BASE_TRANSLATIONS.update({
|
||||
"software_packages.post_install_help": ("Optionally start an application after install or reinstall. Because software jobs run as SYSTEM, AssetManager launches the application explicitly in the active interactive user session.", "Optional eine Anwendung nach Installation oder Neuinstallation starten. Da Softwarejobs unter SYSTEM laufen, startet AssetManager die Anwendung gezielt in der aktiven interaktiven Benutzersitzung."),
|
||||
"software_packages.start_application": ("Start application automatically", "Anwendung automatisch starten"),
|
||||
"software_packages.start_executable": ("Application executable", "Programmdatei"),
|
||||
"software_packages.start_executable_help": ("Use the full executable path, for example C:\\Program Files\\Greenshot\\Greenshot.exe. System environment variables such as %ProgramFiles% are supported.", "Vollständigen Pfad zur EXE angeben, z. B. C:\\Program Files\\Greenshot\\Greenshot.exe. System-Umgebungsvariablen wie %ProgramFiles% werden unterstützt."),
|
||||
"software_packages.start_executable_help": ("Use the full executable path, for example C:\\Program Files\\ExampleApp\\ExampleApp.exe. System environment variables such as %ProgramFiles% are supported.", "Vollständigen Pfad zur EXE angeben, z. B. C:\\Program Files\\ExampleApp\\ExampleApp.exe. System-Umgebungsvariablen wie %ProgramFiles% werden unterstützt."),
|
||||
"software_packages.start_arguments": ("Application arguments", "Programmparameter"),
|
||||
"software_packages.start_only_if_user_logged_on": ("Start only when an interactive user is logged on", "Nur starten, wenn ein interaktiver Benutzer angemeldet ist"),
|
||||
"software_packages.start_only_if_user_logged_on_help": ("Without an active user session the start is skipped. This does not fail the installation job.", "Ohne aktive Benutzersitzung wird der Start übersprungen. Der Installationsjob wird dadurch nicht als fehlgeschlagen gewertet."),
|
||||
@@ -1620,3 +1658,71 @@ BASE_TRANSLATIONS.update({
|
||||
"software_packages.post_install_saved": ("Post-install settings were saved.", "Einstellungen nach der Installation wurden gespeichert."),
|
||||
"software_packages.post_install_error": ("Post-install settings could not be saved: {error}", "Einstellungen nach der Installation konnten nicht gespeichert werden: {error}"),
|
||||
})
|
||||
|
||||
# v0.5.5.90 analyzer profiles and portable package import/export
|
||||
BASE_TRANSLATIONS.update({
|
||||
"setup_profiles.manage": ("Analyzer profiles", "Analyzer-Profile"),
|
||||
"setup_profiles.title": ("Setup Analyzer profiles", "Setup-Analyzer-Profile"),
|
||||
"setup_profiles.subtitle": ("Manage declarative detection profiles without changing AssetManager code.", "Deklarative Erkennungsprofile verwalten, ohne den AssetManager-Code zu ändern."),
|
||||
"setup_profiles.back": ("Back to Setup Analyzer", "Zurück zum Setup-Analyzer"),
|
||||
"setup_profiles.import_title": ("Import analyzer profile", "Analyzer-Profil importieren"),
|
||||
"setup_profiles.import_help": (".amprofile bundles contain only declarative JSON rules and no executable plugin code.", ".amprofile-Pakete enthalten ausschließlich deklarative JSON-Regeln und keinen ausführbaren Plugin-Code."),
|
||||
"setup_profiles.file": ("Profile file", "Profildatei"),
|
||||
"setup_profiles.source": ("Source", "Quelle"),
|
||||
"setup_profiles.source.system": ("System", "System"),
|
||||
"setup_profiles.source.community": ("Community", "Community"),
|
||||
"setup_profiles.source.local": ("Local", "Lokal"),
|
||||
"setup_profiles.import_button": ("Import profile", "Profil importieren"),
|
||||
"setup_profiles.installed_title": ("Installed analyzer profiles", "Installierte Analyzer-Profile"),
|
||||
"setup_profiles.name": ("Profile", "Profil"),
|
||||
"setup_profiles.id": ("Profile ID", "Profil-ID"),
|
||||
"setup_profiles.kind": ("Type", "Typ"),
|
||||
"setup_profiles.stage": ("Analysis stage", "Analysestufe"),
|
||||
"setup_profiles.export_button": ("Export", "Exportieren"),
|
||||
"setup_profiles.enable": ("Enable", "Aktivieren"),
|
||||
"setup_profiles.disable": ("Disable", "Deaktivieren"),
|
||||
"setup_profiles.delete_confirm": ('Really delete analyzer profile "{profile}"?', 'Analyzer-Profil "{profile}" wirklich löschen?'),
|
||||
"setup_profiles.none": ("No analyzer profiles are installed.", "Es sind keine Analyzer-Profile installiert."),
|
||||
"setup_profiles.repository_title": ("Community profile repository", "Community-Profil-Repository"),
|
||||
"setup_profiles.repository_url": ("Repository:", "Repository:"),
|
||||
"setup_profiles.repository_load": ("Load repository catalog", "Repository-Katalog laden"),
|
||||
"setup_profiles.repository_install": ("Install profile", "Profil installieren"),
|
||||
"setup_profiles.repository_empty": ("The repository contains no profiles.", "Das Repository enthält keine Profile."),
|
||||
"setup_profiles.repository_not_configured": ("No analyzer-profile repository is configured yet. Configure an HTTPS index URL with ANALYZER_PROFILE_REPOSITORY_URL.", "Es ist noch kein Analyzer-Profil-Repository konfiguriert. Eine HTTPS-Index-URL kann mit ANALYZER_PROFILE_REPOSITORY_URL hinterlegt werden."),
|
||||
"setup_profiles.matched_profile": ("Matched analyzer profile", "Verwendetes Analyzer-Profil"),
|
||||
"setup_profiles.profile_source": ("Profile source", "Profilquelle"),
|
||||
"software_packages.import_title": ("Import software package", "Softwarepaket importieren"),
|
||||
"software_packages.import_help": ("Import an AssetManager .ampkg package or a compatible package ZIP exported by the Setup Analyzer.", "Ein AssetManager-.ampkg-Paket oder ein kompatibles, vom Setup-Analyzer exportiertes Paket-ZIP importieren."),
|
||||
"software_packages.import_file": ("Package file", "Paketdatei"),
|
||||
"software_packages.import_script_warning": ("I understand that imported software packages can contain executable PowerShell scripts which will run on managed devices.", "Mir ist bewusst, dass importierte Softwarepakete ausführbare PowerShell-Skripte enthalten können, die auf verwalteten Geräten ausgeführt werden."),
|
||||
"software_packages.import_button": ("Import package", "Paket importieren"),
|
||||
"software_packages.import_confirm_required": ("Confirm that imported packages can contain executable scripts.", "Bestätige, dass importierte Pakete ausführbare Skripte enthalten können."),
|
||||
"software_packages.import_invalid_type": ("Select an AssetManager .ampkg or compatible .zip package.", "Wähle ein AssetManager-.ampkg- oder kompatibles .zip-Paket aus."),
|
||||
"software_packages.import_failed": ("Software package import failed: {error}", "Softwarepaket konnte nicht importiert werden: {error}"),
|
||||
"software_packages.imported": ('Software package "{package}" was imported.', 'Softwarepaket "{package}" wurde importiert.'),
|
||||
"software_packages.export_button": ("Export", "Exportieren"),
|
||||
"software_packages.export_failed": ("Software package export failed: {error}", "Softwarepaket konnte nicht exportiert werden: {error}"),
|
||||
})
|
||||
|
||||
BASE_TRANSLATIONS.update({
|
||||
"setup_analyzer.signal.profile_marker": ("Analyzer profile marker matched", "Analyzer-Profil-Merkmal erkannt"),
|
||||
"setup_analyzer.warning.online_bootstrapper": ("This profile identifies a network-dependent online bootstrapper. The installed version can be dynamic; prefer a version-pinned offline package for reproducible managed deployment when available.", "Dieses Profil erkennt einen netzwerkabhängigen Online-Bootstrapper. Die installierte Version kann dynamisch sein; für reproduzierbare Softwareverteilung sollte nach Möglichkeit ein versionsgebundenes Offline-Paket verwendet werden."),
|
||||
})
|
||||
BASE_TRANSLATIONS.update({
|
||||
"software_packages.import_profile": ("Also import an analyzer profile embedded in the package as a Community profile", "Ein im Paket enthaltenes Analyzer-Profil ebenfalls als Community-Profil importieren"),
|
||||
})
|
||||
|
||||
|
||||
# v0.5.5.90 file-picker localization and remote client job cleanup
|
||||
BASE_TRANSLATIONS.update({
|
||||
"common.choose_file": ("Choose file", "Datei auswählen"),
|
||||
"common.no_file_selected": ("No file selected", "Keine Datei ausgewählt"),
|
||||
"software.settings.remote_cleanup_title": ("Client job files", "Jobdateien auf Clients"),
|
||||
"software.settings.remote_cleanup_help": ("Automatically remove stale AssetManager job directories from managed clients. Cleanup runs about once per hour for online clients and additionally before a new file-based job is dispatched.", "Entfernt veraltete AssetManager-Jobverzeichnisse auf verwalteten Clients automatisch. Die Bereinigung läuft bei erreichbaren Clients etwa einmal pro Stunde und zusätzlich vor dem Versand eines neuen dateibasierten Jobs."),
|
||||
"software.settings.remote_cleanup_badge": ("Client cleanup", "Client-Bereinigung"),
|
||||
"software.settings.remote_cleanup_enabled": ("Automatically remove stale client job files", "Veraltete Jobdateien auf Clients automatisch löschen"),
|
||||
"software.settings.remote_cleanup_enabled_help": ("Only AssetManager job directories named JobID-Attempt below the dedicated job root are considered. Current job files are never removed.", "Berücksichtigt werden ausschließlich AssetManager-Jobverzeichnisse im Schema JobID-Versuch unterhalb des vorgesehenen Jobordners. Dateien des aktuellen Jobs werden niemals entfernt."),
|
||||
"software.settings.remote_cleanup_hours": ("Retention on client in hours", "Aufbewahrung auf dem Client in Stunden"),
|
||||
"software.settings.remote_cleanup_hours_help": ("Job directories whose creation time is older than this value are treated as stale. Active jobs are excluded. Default: 24 hours (1 day).", "Jobverzeichnisse, deren Erstellzeit älter als dieser Wert ist, gelten als veraltet. Aktive Jobs werden ausgeschlossen. Standard: 24 Stunden (1 Tag)."),
|
||||
"software.settings.remote_cleanup_paths": ("Managed job roots", "Verwaltete Jobpfade"),
|
||||
})
|
||||
|
||||
+202
-11
@@ -48,7 +48,7 @@ from .config import load_config, public_config, save_config
|
||||
from .meshcentral import synchronize, fetch_device_summaries_for_linking
|
||||
from .migrations import apply_lightweight_migrations
|
||||
from .i18n import seed_i18n, translate, dictionary as translation_dictionary, languages as i18n_languages, clear_translation_cache
|
||||
from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script
|
||||
from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script, cleanup_stale_remote_job_directories
|
||||
from .job_state import backfill_asset_job_states, filter_state_key, sync_asset_job_state
|
||||
from .presence import mesh_presence_loop
|
||||
from .version import APP_VERSION
|
||||
@@ -56,19 +56,20 @@ from .privacy import merge_privacy_settings, localized_privacy_settings, normali
|
||||
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_post_install, update_package_process_control
|
||||
from .software_packages import delete_package_storage, human_size, load_package_manifest, package_execution_timeout_seconds, package_summary, update_package_post_install, update_package_process_control, export_package_bundle, import_package_bundle, PACKAGE_IMPORT_MAX_MB
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(DATA_ROOT / "uploads")))
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(BASE_DIR / "static" / "uploads" / "library")))
|
||||
STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(UPLOAD_DIR / "library")))
|
||||
STANDARD_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STANDARD_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync"))
|
||||
LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", str(DATA_ROOT / "logs" / "sync")))
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs"))
|
||||
APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", str(DATA_ROOT / "logs")))
|
||||
APP_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SOFTWARE_CALLBACK_DEBUG_LOG = APP_LOG_DIR / "software-callback-debug.log"
|
||||
_SOFTWARE_LOG_LOCK = threading.Lock()
|
||||
@@ -226,6 +227,9 @@ callback_app = FastAPI(
|
||||
_session_cfg = load_config().get("authentication", {})
|
||||
_session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET")
|
||||
_session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret"
|
||||
# Persistent uploads live outside the application image in Docker deployments.
|
||||
# Mount this route before /static so existing /static/uploads/... URLs remain valid.
|
||||
app.mount("/static/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
|
||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
||||
templates.env.globals["application_config"] = load_config
|
||||
@@ -247,11 +251,12 @@ def application_version() -> str:
|
||||
|
||||
|
||||
templates.env.globals["application_version"] = application_version
|
||||
templates.env.globals["application_info_path"] = lambda: str(Path(os.getenv("APPINFO_PATH", str(DATA_ROOT / "config" / "APPINFO.json"))))
|
||||
|
||||
def application_info() -> dict[str, str]:
|
||||
"""Read persistent application metadata.
|
||||
|
||||
Primary file: /app/config/APPINFO.json (or APPINFO_PATH).
|
||||
Primary file: APPINFO_PATH below the persistent AssetManager data root.
|
||||
A legacy APPINFO.json is migrated once when possible.
|
||||
"""
|
||||
defaults = {
|
||||
@@ -259,7 +264,7 @@ def application_info() -> dict[str, str]:
|
||||
"contact": "", "website": "", "repository": "",
|
||||
"license": "", "copyright": "", "description": "",
|
||||
}
|
||||
persistent = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json"))
|
||||
persistent = Path(os.getenv("APPINFO_PATH", str(DATA_ROOT / "config" / "APPINFO.json")))
|
||||
legacy_candidates = [
|
||||
Path("/app/data/config/APPINFO.json"), # path used by v0.3.14.2
|
||||
BASE_DIR.parent / "APPINFO.json", # /app/APPINFO.json
|
||||
@@ -401,6 +406,102 @@ SOFTWARE_TIMEOUT_STOP_EVENT = threading.Event()
|
||||
SOFTWARE_TIMEOUT_THREAD: threading.Thread | None = None
|
||||
SOFTWARE_JOB_TERMINAL_STATES = {"success", "failed", "partial", "timeout", "cancelled"}
|
||||
SOFTWARE_JOB_AUTOMATIC_RETRY_STATES = {"failed", "partial", "timeout", "sent"}
|
||||
REMOTE_JOB_CLEANUP_INTERVAL_SECONDS = 3600
|
||||
_REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC = 0.0
|
||||
|
||||
|
||||
def _remote_job_cleanup_settings() -> tuple[bool, int]:
|
||||
settings = _software_settings()
|
||||
enabled = bool(settings.get("remote_job_cleanup_enabled", True))
|
||||
try:
|
||||
retention_hours = int(settings.get("remote_job_retention_hours", 24) or 24)
|
||||
except (TypeError, ValueError):
|
||||
retention_hours = 24
|
||||
return enabled, max(1, min(retention_hours, 8760))
|
||||
|
||||
|
||||
def _active_remote_job_directory_names(db: Session, asset_id: int) -> list[str]:
|
||||
rows = (
|
||||
db.query(SoftwareJob.id, SoftwareJob.attempt_count)
|
||||
.filter(
|
||||
SoftwareJob.asset_id == asset_id,
|
||||
SoftwareJob.status.notin_(SOFTWARE_JOB_TERMINAL_STATES),
|
||||
SoftwareJob.attempt_count > 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [f"{job_id}-{int(attempt_count or 0)}" for job_id, attempt_count in rows if int(attempt_count or 0) > 0]
|
||||
|
||||
|
||||
def _run_remote_job_cleanup_maintenance() -> None:
|
||||
enabled, retention_hours = _remote_job_cleanup_settings()
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
runtime_config = load_config()
|
||||
cfg = runtime_config.get("meshcentral", {})
|
||||
password_env = str(cfg.get("password_env") or "MESHCENTRAL_PASSWORD")
|
||||
password = os.getenv(password_env, "")
|
||||
if not password:
|
||||
logger.warning("Remote client job cleanup skipped because %s is not set", password_env)
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assets = (
|
||||
db.query(Asset)
|
||||
.join(SoftwareJob, SoftwareJob.asset_id == Asset.id)
|
||||
.filter(
|
||||
Asset.mesh_node_id.isnot(None),
|
||||
Asset.mesh_online.is_(True),
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Asset.id)
|
||||
.all()
|
||||
)
|
||||
if not assets:
|
||||
return
|
||||
|
||||
cleaned_clients = 0
|
||||
failed_clients = 0
|
||||
for asset in assets:
|
||||
platform = detect_platform(asset)
|
||||
if platform not in {"windows", "linux"}:
|
||||
continue
|
||||
excluded = _active_remote_job_directory_names(db, asset.id)
|
||||
try:
|
||||
result = cleanup_stale_remote_job_directories(
|
||||
cfg,
|
||||
asset,
|
||||
password,
|
||||
platform,
|
||||
retention_hours,
|
||||
min(max(30, int(cfg.get("timeout_seconds") or 120)), 120),
|
||||
excluded,
|
||||
)
|
||||
output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
|
||||
if result.returncode == 0:
|
||||
cleaned_clients += 1
|
||||
if output:
|
||||
logger.info("Remote job cleanup asset=%s (%s): %s", asset.id, asset.name, output.replace("\n", " | "))
|
||||
else:
|
||||
failed_clients += 1
|
||||
logger.warning(
|
||||
"Remote job cleanup failed asset=%s (%s) rc=%s: %s",
|
||||
asset.id, asset.name, result.returncode, output,
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_clients += 1
|
||||
logger.warning("Remote job cleanup exception asset=%s (%s): %s", asset.id, asset.name, exc)
|
||||
|
||||
if cleaned_clients or failed_clients:
|
||||
logger.info(
|
||||
"Remote client job cleanup cycle completed: clients=%s failed=%s retention_hours=%s",
|
||||
cleaned_clients, failed_clients, retention_hours,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
def _configured_callback_worker_count() -> int:
|
||||
@@ -555,6 +656,18 @@ def _software_job_timeout_loop(stop_event: threading.Event) -> None:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
global _REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC
|
||||
now_monotonic = time.monotonic()
|
||||
if (
|
||||
_REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC <= 0
|
||||
or now_monotonic - _REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC >= REMOTE_JOB_CLEANUP_INTERVAL_SECONDS
|
||||
):
|
||||
_REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC = now_monotonic
|
||||
try:
|
||||
_run_remote_job_cleanup_maintenance()
|
||||
except Exception:
|
||||
logger.exception("Remote client job cleanup maintenance failed")
|
||||
|
||||
for job_id, token, callback_base in queued:
|
||||
threading.Thread(
|
||||
target=execute_job,
|
||||
@@ -5929,16 +6042,88 @@ def software_packages_page(request: Request, db: Session = Depends(get_db)):
|
||||
.order_by(func.lower(SoftwarePackage.name), SoftwarePackage.id)
|
||||
.all()
|
||||
)
|
||||
package_ids = [package.id for package in packages]
|
||||
job_counts: dict[int, int] = {}
|
||||
if package_ids:
|
||||
job_counts = {
|
||||
int(package_id): int(count or 0)
|
||||
for package_id, count in (
|
||||
db.query(SoftwareJob.package_id, func.count(SoftwareJob.id))
|
||||
.filter(SoftwareJob.package_id.in_(package_ids))
|
||||
.group_by(SoftwareJob.package_id)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
package_rows = []
|
||||
for package in packages:
|
||||
row = package_summary(package)
|
||||
row["job_count"] = job_counts.get(package.id, 0)
|
||||
package_rows.append(row)
|
||||
return templates.TemplateResponse(
|
||||
"software_packages.html",
|
||||
{
|
||||
"request": request,
|
||||
"package_rows": [package_summary(package) for package in packages],
|
||||
"package_rows": package_rows,
|
||||
"human_size": human_size,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/software/packages/import")
|
||||
async def software_package_import(request: Request, package_file: UploadFile = File(...), confirm_scripts: str | None = Form(None), import_profile: str | None = Form(None), db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
if str(confirm_scripts or "").strip().lower() not in {"1", "true", "yes", "on"}:
|
||||
message = _translate_request(request, "software_packages.import_confirm_required", "Confirm that imported packages can contain executable scripts.")
|
||||
return RedirectResponse("/software/packages?toast_error=" + quote(message), status_code=303)
|
||||
suffix = Path(package_file.filename or "package.ampkg").suffix.lower()
|
||||
if suffix not in {".ampkg", ".zip"}:
|
||||
await package_file.close()
|
||||
message = _translate_request(request, "software_packages.import_invalid_type", "Select an AssetManager .ampkg or compatible .zip package.")
|
||||
return RedirectResponse("/software/packages?toast_error=" + quote(message), status_code=303)
|
||||
temporary_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix="assetmanager-package-import-", suffix=suffix, delete=False) as handle:
|
||||
temporary_path = Path(handle.name)
|
||||
total_bytes = 0
|
||||
limit_bytes = PACKAGE_IMPORT_MAX_MB * 1024 * 1024
|
||||
while True:
|
||||
chunk = await package_file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_bytes += len(chunk)
|
||||
if total_bytes > limit_bytes:
|
||||
raise ValueError(f"Package bundle exceeds import size limit ({PACKAGE_IMPORT_MAX_MB} MB).")
|
||||
handle.write(chunk)
|
||||
package = import_package_bundle(db, temporary_path, source_name=package_file.filename or "", import_profile=str(import_profile or "").strip().lower() in {"1", "true", "yes", "on"})
|
||||
except Exception as exc:
|
||||
logger.exception("Software package import failed")
|
||||
message = _translate_request(request, "software_packages.import_failed", "Software package import failed: {error}", error=str(exc))
|
||||
return RedirectResponse("/software/packages?toast_error=" + quote(message), status_code=303)
|
||||
finally:
|
||||
await package_file.close()
|
||||
if temporary_path:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
message = _translate_request(request, "software_packages.imported", 'Software package "{package}" was imported.', package=package.name)
|
||||
return RedirectResponse(f"/software/packages/{package.id}?toast_success=" + quote(message), status_code=303)
|
||||
|
||||
|
||||
@app.get("/software/packages/{package_id}/export")
|
||||
def software_package_export(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:
|
||||
data = export_package_bundle(package.id, APP_VERSION)
|
||||
manifest = load_package_manifest(package.id)
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, _translate_request(request, "software_packages.export_failed", "Software package export failed: {error}", error=str(exc))) from exc
|
||||
base = re.sub(r"[^A-Za-z0-9._+-]+", "-", str(manifest.get("name") or package.name)).strip("-.") or f"package-{package.id}"
|
||||
version = re.sub(r"[^A-Za-z0-9._+-]+", "-", str(manifest.get("version") or "")).strip("-.")
|
||||
filename = f"{base}-{version}.ampkg" if version else f"{base}.ampkg"
|
||||
return StreamingResponse(io.BytesIO(data), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'})
|
||||
|
||||
|
||||
@app.get("/software/packages/{package_id}")
|
||||
def software_package_page(package_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
_require_admin(request)
|
||||
@@ -6078,6 +6263,7 @@ async def software_package_delete(package_id: int, request: Request, db: Session
|
||||
|
||||
form = await request.form()
|
||||
delete_jobs = str(form.get("delete_jobs") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
return_to = str(form.get("return_to") or "detail").strip().lower()
|
||||
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(
|
||||
@@ -6086,8 +6272,9 @@ async def software_package_delete(package_id: int, request: Request, db: Session
|
||||
"This package is referenced by {count} software jobs. Confirm deletion of the associated jobs first.",
|
||||
count=len(job_ids),
|
||||
)
|
||||
redirect_path = "/software/packages" if return_to == "overview" else f"/software/packages/{package_id}"
|
||||
return RedirectResponse(
|
||||
f"/software/packages/{package_id}?toast_error=" + quote(message),
|
||||
redirect_path + "?toast_error=" + quote(message),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
@@ -8781,6 +8968,8 @@ def settings_software_save(
|
||||
automatic_retry_max_age_hours: int = Form(12),
|
||||
automatic_retry_interval_seconds: int = Form(60),
|
||||
automatic_retry_max_retries: int = Form(3),
|
||||
remote_job_cleanup_enabled: str | None = Form(None),
|
||||
remote_job_retention_hours: int = Form(24),
|
||||
inventory_exclusion_platform: list[str] = Form(default=[]),
|
||||
inventory_exclusion_name: list[str] = Form(default=[]),
|
||||
):
|
||||
@@ -8814,11 +9003,13 @@ def settings_software_save(
|
||||
"automatic_retry_max_age_hours": max(1, min(int(automatic_retry_max_age_hours), 168)),
|
||||
"automatic_retry_interval_seconds": max(10, min(int(automatic_retry_interval_seconds), 86400)),
|
||||
"automatic_retry_max_retries": max(1, min(int(automatic_retry_max_retries), 10)),
|
||||
"remote_job_cleanup_enabled": remote_job_cleanup_enabled == "on",
|
||||
"remote_job_retention_hours": max(1, min(int(remote_job_retention_hours), 8760)),
|
||||
"inventory_exclusion_rules": submitted_exclusion_rules,
|
||||
})
|
||||
save_config({"software": software})
|
||||
_software_debug_log(
|
||||
f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or '<automatic>'} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}",
|
||||
f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or '<automatic>'} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | remote_job_cleanup_enabled={software['remote_job_cleanup_enabled']} | remote_job_retention_hours={software['remote_job_retention_hours']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}",
|
||||
force=True,
|
||||
)
|
||||
return RedirectResponse(
|
||||
|
||||
+2
-1
@@ -177,7 +177,8 @@ def _run_meshctrl(
|
||||
|
||||
# stdout/stderr bewusst direkt in Dateien schreiben. Dadurch umgehen wir
|
||||
# possible pipe or buffer limits with very large MeshCtrl JSON output.
|
||||
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics"))
|
||||
data_root = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", str(data_root / "logs" / "diagnostics")))
|
||||
diagnostic_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
||||
mode = "details" if include_details else "basic"
|
||||
|
||||
+2
-1
@@ -86,7 +86,8 @@ def refresh_mesh_presence() -> dict[str, int]:
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout or "MeshCentral presence query failed").strip())
|
||||
|
||||
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics"))
|
||||
data_root = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", str(data_root / "logs" / "diagnostics")))
|
||||
presence_error_path = diagnostic_dir / "meshctrl-presence-last-error.stdout.json"
|
||||
try:
|
||||
devices = _extract_devices(_parse_json_output(result.stdout))
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ from typing import Any
|
||||
import json
|
||||
import os
|
||||
|
||||
PRIVACY_AUDIT_LOG = Path(os.getenv("PRIVACY_AUDIT_LOG", "/app/data/logs/privacy-policy-audit.log"))
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
PRIVACY_AUDIT_LOG = Path(os.getenv("PRIVACY_AUDIT_LOG", str(DATA_ROOT / "logs" / "privacy-policy-audit.log")))
|
||||
|
||||
DEFAULT_PURPOSES = [
|
||||
"Inventory of company computers and servers",
|
||||
|
||||
@@ -12,8 +12,9 @@ from sqlalchemy import cast, func, or_, Text
|
||||
from .database import SessionLocal
|
||||
from .models import SoftwareJob
|
||||
|
||||
PRIVACY_DELETION_LOG = Path(os.getenv("PRIVACY_DELETION_LOG", "/app/data/logs/privacy-deletion-audit.log"))
|
||||
APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs"))
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
PRIVACY_DELETION_LOG = Path(os.getenv("PRIVACY_DELETION_LOG", str(DATA_ROOT / "logs" / "privacy-deletion-audit.log")))
|
||||
APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", str(DATA_ROOT / "logs")))
|
||||
|
||||
IMPLEMENTED_RETENTION_KEYS = {"am_job_payloads", "am_diagnostic_logs"}
|
||||
PROTECTED_LOG_FILES = {
|
||||
|
||||
+971
-181
File diff suppressed because it is too large
Load Diff
+168
-10
@@ -440,13 +440,14 @@ def _prepare_remote_directory(
|
||||
remote_dir: str,
|
||||
timeout: int,
|
||||
remote_file: str | None = None,
|
||||
reset_acl: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Create the job directory and remove a stale target file.
|
||||
"""Create the job directory and optionally remove one stale target file.
|
||||
|
||||
MeshCtrl's Upload action does not overwrite an existing file reliably on
|
||||
all Windows agents. Failed jobs intentionally retain their directories, so
|
||||
a repeated dispatch must explicitly remove a previous run.ps1 before the
|
||||
upload starts.
|
||||
ACLs are applied to the directory only. Never use a recursive icacls /T
|
||||
here: directory inheritance flags such as (OI)(CI) must not be rewritten
|
||||
onto already uploaded files. Doing so can leave files with no effective
|
||||
ACEs and make them unreadable even for the SYSTEM account.
|
||||
"""
|
||||
if platform == 'windows':
|
||||
command=(
|
||||
@@ -455,12 +456,51 @@ def _prepare_remote_directory(
|
||||
)
|
||||
if remote_file:
|
||||
command += "Remove-Item -LiteralPath '"+remote_file.replace("'","''")+"' -Force -ErrorAction SilentlyContinue;"
|
||||
command += "& icacls.exe $p /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C|Out-Null"
|
||||
if reset_acl:
|
||||
command += (
|
||||
"& icacls.exe $p /inheritance:r "
|
||||
"/grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' "
|
||||
"/C|Out-Null;"
|
||||
)
|
||||
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout)
|
||||
command=f"mkdir -p '{remote_dir}'"
|
||||
if remote_file:
|
||||
command += f" && rm -f -- '{remote_file}'"
|
||||
command += f" && chmod 700 '{remote_dir}'"
|
||||
if reset_acl:
|
||||
command += f" && chmod 700 '{remote_dir}'"
|
||||
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout)
|
||||
|
||||
|
||||
def _remote_script_preflight(
|
||||
cfg: dict,
|
||||
asset: Asset,
|
||||
password: str,
|
||||
platform: str,
|
||||
remote_file: str,
|
||||
timeout: int,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Verify that the uploaded job script exists and is readable.
|
||||
|
||||
The Windows diagnostic also prints the effective ACL so an upload/ACL
|
||||
problem is visible in the dispatcher log before PowerShell is launched.
|
||||
"""
|
||||
if platform == 'windows':
|
||||
escaped=remote_file.replace("'","''")
|
||||
command=(
|
||||
"$f='"+escaped+"';"
|
||||
"$exists=Test-Path -LiteralPath $f -PathType Leaf;"
|
||||
"Write-Output ('File exists: '+$exists);"
|
||||
"if($exists){"
|
||||
"try{$i=Get-Item -LiteralPath $f -ErrorAction Stop;Write-Output ('Size: '+$i.Length)}"
|
||||
"catch{Write-Output ('Size: ERROR - '+$_.Exception.Message)};"
|
||||
"try{$s=[System.IO.File]::Open($f,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite);$s.Close();Write-Output 'Readable: True'}"
|
||||
"catch{Write-Output ('Readable: False - '+$_.Exception.Message)};"
|
||||
"Write-Output 'ACL:'; & icacls.exe $f"
|
||||
"}"
|
||||
)
|
||||
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout)
|
||||
escaped=remote_file.replace("'","'\''")
|
||||
command=f"test -f '{escaped}' && test -r '{escaped}' && ls -l '{escaped}'"
|
||||
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout)
|
||||
|
||||
|
||||
@@ -510,6 +550,94 @@ def _cleanup_remote_directory(cfg: dict, asset: Asset, password: str, platform:
|
||||
action=['RunCommand','--id',asset.mesh_node_id,'--run',f"rm -rf -- '{remote_dir}'",'--reply']
|
||||
return _run_meshctrl(cfg,asset,password,action,timeout)
|
||||
|
||||
def cleanup_stale_remote_job_directories(
|
||||
cfg: dict,
|
||||
asset: Asset,
|
||||
password: str,
|
||||
platform: str,
|
||||
retention_hours: int,
|
||||
timeout: int,
|
||||
exclude_directory_names: list[str] | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Remove stale AssetManager job-attempt directories on the client.
|
||||
|
||||
Windows age is based on directory CreationTimeUtc, not LastWriteTimeUtc.
|
||||
That represents the age of the job workspace and is not extended when a
|
||||
script or installer later touches files in that directory. Only numeric
|
||||
AssetManager job directories (legacy ``<job-id>`` and current
|
||||
``<job-id>-<attempt>``) are considered. Active/current directory names
|
||||
supplied by the caller are excluded. Deletion is best-effort.
|
||||
"""
|
||||
try:
|
||||
retention = max(1, min(int(retention_hours), 8760))
|
||||
except (TypeError, ValueError):
|
||||
retention = 24
|
||||
|
||||
excluded = sorted({str(name or '').strip() for name in (exclude_directory_names or []) if str(name or '').strip()})
|
||||
|
||||
if platform == 'windows':
|
||||
root = r'C:\ProgramData\AssetManager\Jobs'
|
||||
escaped_root = root.replace("'", "''")
|
||||
excluded_ps = ','.join("'" + name.replace("'", "''") + "'" for name in excluded)
|
||||
command = (
|
||||
f"$root='{escaped_root}';"
|
||||
f"$cutoff=[DateTime]::UtcNow.AddHours(-{retention});"
|
||||
f"$excluded=@({excluded_ps});"
|
||||
"if(Test-Path -LiteralPath $root){"
|
||||
"$found=0;$eligible=0;$removed=0;$failed=0;$young=0;$active=0;$ignored=0;"
|
||||
"Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue|ForEach-Object{"
|
||||
"$item=$_;$found++;"
|
||||
"if($item.Name -notmatch '^[0-9]+(?:-[0-9]+)?$'){$ignored++;return};"
|
||||
"if($excluded -contains $item.Name){$active++;Write-Output ('Kept active job directory: '+$item.FullName);return};"
|
||||
"$created=$item.CreationTimeUtc;"
|
||||
"if($created -ge $cutoff){$young++;return};"
|
||||
"$eligible++;$dir=$item.FullName;"
|
||||
"try{Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop;$removed++;Write-Output ('Removed stale job directory: '+$dir+' created_utc='+$created.ToString('o'))}"
|
||||
"catch{"
|
||||
"$firstError=$_.Exception.Message;"
|
||||
"try{"
|
||||
"& icacls.exe $dir /inheritance:e /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C /Q | Out-Null;"
|
||||
"Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop;"
|
||||
"$removed++;Write-Output ('Removed stale job directory after ACL repair: '+$dir+' first_error='+$firstError)"
|
||||
"}catch{$failed++;Write-Output ('Failed stale job directory: '+$dir+' - '+$_.Exception.Message+' first_error='+$firstError)}"
|
||||
"}"
|
||||
"};"
|
||||
f"Write-Output ('Stale cleanup summary: found='+$found+' eligible='+$eligible+' removed='+$removed+' failed='+$failed+' young='+$young+' active='+$active+' ignored='+$ignored+' retention_hours={retention}')"
|
||||
"}else{Write-Output 'Stale cleanup summary: job root not present'}"
|
||||
)
|
||||
action = ['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply']
|
||||
return _run_meshctrl(cfg,asset,password,action,timeout)
|
||||
|
||||
root = '/var/lib/assetmanager/jobs'
|
||||
minutes = retention * 60
|
||||
exclude_tests = ' '.join(f"! -name '{name}'" for name in excluded)
|
||||
command = (
|
||||
f"root='{root}'; "
|
||||
"if [ -d \"$root\" ]; then "
|
||||
f"find \"$root\" -mindepth 1 -maxdepth 1 -type d -mmin +{minutes} \\( -name '[0-9]*' -o -name '[0-9]*-[0-9]*' \\) {exclude_tests} -print -exec rm -rf -- {{}} \\;; "
|
||||
f"echo 'Stale cleanup completed; retention_hours={retention}'; "
|
||||
"else echo 'Stale cleanup summary: job root not present'; fi"
|
||||
)
|
||||
action = ['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply']
|
||||
return _run_meshctrl(cfg,asset,password,action,timeout)
|
||||
|
||||
|
||||
def _cleanup_stale_remote_job_directories(
|
||||
cfg: dict,
|
||||
asset: Asset,
|
||||
password: str,
|
||||
platform: str,
|
||||
current_remote_dir: str,
|
||||
retention_hours: int,
|
||||
timeout: int,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Compatibility wrapper for dispatcher-side cleanup."""
|
||||
current_name = Path(current_remote_dir.replace('\\', '/')).name
|
||||
return cleanup_stale_remote_job_directories(
|
||||
cfg, asset, password, platform, retention_hours, timeout, [current_name] if current_name else []
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _add_job_event(db, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None) -> None:
|
||||
db.add(JobEvent(
|
||||
@@ -562,7 +690,14 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
||||
sync_asset_job_state(db, job)
|
||||
db.commit()
|
||||
|
||||
cfg=load_config().get('meshcentral',{})
|
||||
runtime_config=load_config()
|
||||
cfg=runtime_config.get('meshcentral',{})
|
||||
software_settings=runtime_config.get('software',{})
|
||||
remote_cleanup_enabled=bool(software_settings.get('remote_job_cleanup_enabled', True))
|
||||
try:
|
||||
remote_job_retention_hours=max(1,min(int(software_settings.get('remote_job_retention_hours',24) or 24),8760))
|
||||
except (TypeError,ValueError):
|
||||
remote_job_retention_hours=24
|
||||
password_env=str(cfg.get('password_env') or 'MESHCENTRAL_PASSWORD')
|
||||
password=os.getenv(password_env,'')
|
||||
if not password: raise RuntimeError(f'MeshCentral-Passwortvariable {password_env} ist nicht gesetzt.')
|
||||
@@ -586,6 +721,8 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
||||
f'Mesh node id: {asset.mesh_node_id}',
|
||||
f'Interpreter: {interpreter}',
|
||||
f'Upload required: {upload_required}',
|
||||
f'Remote stale cleanup enabled: {remote_cleanup_enabled}',
|
||||
f'Remote job retention: {remote_job_retention_hours} hours',
|
||||
f'Resolved script characters: {len(payload)}',
|
||||
f'Resolved script SHA-256: {hashlib.sha256(payload.encode("utf-8")).hexdigest()}',
|
||||
]
|
||||
@@ -614,6 +751,16 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
||||
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
|
||||
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
|
||||
|
||||
if remote_cleanup_enabled:
|
||||
stale_cleanup=_cleanup_stale_remote_job_directories(
|
||||
cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120)
|
||||
)
|
||||
diagnostics += [
|
||||
'--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '',
|
||||
'--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '',
|
||||
f'Stale remote job cleanup return code: {stale_cleanup.returncode}',
|
||||
]
|
||||
|
||||
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
|
||||
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
|
||||
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
|
||||
@@ -626,8 +773,8 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
||||
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
||||
if not upload_ok:
|
||||
# MeshCtrl can return code 0 together with "Upload error".
|
||||
# Recreate the directory, remove any stale target and retry once.
|
||||
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file)
|
||||
# Remove only the stale target and retry once. Do not touch ACLs of already uploaded files.
|
||||
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False)
|
||||
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
|
||||
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
||||
upload_results.append(uploaded)
|
||||
@@ -662,6 +809,7 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
||||
remote_dir,
|
||||
min(timeout, 120),
|
||||
remote_package_file,
|
||||
reset_acl=False,
|
||||
)
|
||||
diagnostics += [
|
||||
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
|
||||
@@ -687,6 +835,16 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
||||
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}'
|
||||
)
|
||||
|
||||
preflight=_remote_script_preflight(cfg,asset,password,job.platform,remote_file,min(timeout,120))
|
||||
diagnostics += [
|
||||
'--- Remote script preflight stdout ---', preflight.stdout or '',
|
||||
'--- Remote script preflight stderr ---', preflight.stderr or '',
|
||||
f'Remote script preflight return code: {preflight.returncode}',
|
||||
]
|
||||
preflight_text=(preflight.stdout or '')+'\n'+(preflight.stderr or '')
|
||||
if preflight.returncode!=0 or (job.platform=='windows' and ('File exists: True' not in preflight_text or 'Readable: True' not in preflight_text)):
|
||||
raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.')
|
||||
|
||||
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)
|
||||
|
||||
+250
-41
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -14,7 +17,8 @@ from sqlalchemy.orm import Session
|
||||
from .models import SoftwarePackage
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages"))
|
||||
DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data"))
|
||||
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", str(DATA_ROOT / "software-packages")))
|
||||
PACKAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -32,6 +36,18 @@ def _safe_member_name(value: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def _safe_relative_member_path(value: str) -> str:
|
||||
name = str(value or "").replace("\\", "/").strip()
|
||||
while name.startswith("./"):
|
||||
name = name[2:]
|
||||
if not name or name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
||||
raise ValueError("invalid relative package member path")
|
||||
parts = [part for part in name.split("/") if part not in {"", "."}]
|
||||
if not parts or any(part == ".." for part in parts):
|
||||
raise ValueError("invalid relative package member path")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _safe_package_label(value: str) -> str:
|
||||
text = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
|
||||
return re.sub(r"\s+", " ", text)[:180]
|
||||
@@ -76,6 +92,15 @@ def write_package_storage(
|
||||
json.dumps(analysis, ensure_ascii=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
profile_id = str((analysis or {}).get("profile_id") or "").strip()
|
||||
if profile_id:
|
||||
try:
|
||||
from .analyzer_profiles import export_profile_bundle
|
||||
profile_dir = temporary / "metadata"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
(profile_dir / "analyzer-profile.amprofile").write_bytes(export_profile_bundle(profile_id))
|
||||
except Exception:
|
||||
pass
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
temporary.replace(target)
|
||||
@@ -151,16 +176,21 @@ def human_size(size: int) -> str:
|
||||
|
||||
def package_summary(package: SoftwarePackage) -> dict[str, Any]:
|
||||
manifest: dict[str, Any] = {}
|
||||
error = ""
|
||||
storage_size = 0
|
||||
errors: list[str] = []
|
||||
try:
|
||||
manifest = load_package_manifest(package.id)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
errors.append(str(exc))
|
||||
try:
|
||||
storage_size = package_storage_size(package.id)
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
return {
|
||||
"package": package,
|
||||
"manifest": manifest,
|
||||
"storage_size": package_storage_size(package.id),
|
||||
"storage_error": error,
|
||||
"storage_size": storage_size,
|
||||
"storage_error": "; ".join(error for error in errors if error),
|
||||
}
|
||||
|
||||
|
||||
@@ -756,20 +786,6 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any]
|
||||
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", {})
|
||||
@@ -777,9 +793,6 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any]
|
||||
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))
|
||||
@@ -806,15 +819,8 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any]
|
||||
if not isinstance(post_install, dict):
|
||||
post_install = {}
|
||||
result["post_install"] = post_install
|
||||
if "greenshot" in identity and not start_configured:
|
||||
post_install["start_application"] = True
|
||||
notes.append("greenshot_post_install_start")
|
||||
else:
|
||||
post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False)
|
||||
if "greenshot" in identity and not executable_configured:
|
||||
post_install["executable"] = r"C:\Program Files\Greenshot\Greenshot.exe"
|
||||
else:
|
||||
post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024)
|
||||
post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False)
|
||||
post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024)
|
||||
post_install["arguments"] = _clean_manifest_text(post_install.get("arguments"), 2048)
|
||||
post_install["only_if_user_logged_on"] = _manifest_bool(post_install.get("only_if_user_logged_on"), True)
|
||||
post_install["fail_job_on_error"] = _manifest_bool(post_install.get("fail_job_on_error"), False)
|
||||
@@ -844,12 +850,15 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
|
||||
install = manifest.get("install") or {}
|
||||
installer_type = str(manifest.get("installer_type") or "").strip().lower()
|
||||
installer_file = _safe_member_name(manifest.get("installer_file", ""))
|
||||
source_mode = str(install.get("source_mode") or "direct").strip().lower()
|
||||
embedded_installer = ""
|
||||
if source_mode == "embedded_archive":
|
||||
embedded_installer = _safe_relative_member_path(install.get("embedded_installer", ""))
|
||||
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"
|
||||
|
||||
@@ -858,17 +867,38 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
|
||||
"Set-StrictMode -Version Latest",
|
||||
"",
|
||||
"$packageDir = $PSScriptRoot",
|
||||
f"$installer = Join-Path $packageDir {_ps_quote(installer_file)}",
|
||||
f"$packageSource = Join-Path $packageDir {_ps_quote(installer_file)}",
|
||||
f"$sourceMode = {_ps_quote(source_mode)}",
|
||||
f"$embeddedInstaller = {_ps_quote(embedded_installer)}",
|
||||
"$installer = $packageSource",
|
||||
"$installerWorkingDirectory = $packageDir",
|
||||
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)) {",
|
||||
"if (-not (Test-Path -LiteralPath $packageSource)) {",
|
||||
"\tWrite-Error \"Package source not found: $packageSource\"",
|
||||
"\texit 2",
|
||||
"}",
|
||||
"",
|
||||
"if ($sourceMode -eq 'embedded_archive') {",
|
||||
"\t$payloadDir = Join-Path $packageDir '_embedded_payload'",
|
||||
"\tif (Test-Path -LiteralPath $payloadDir) { Remove-Item -LiteralPath $payloadDir -Recurse -Force -ErrorAction Stop }",
|
||||
"\tNew-Item -ItemType Directory -Path $payloadDir -Force | Out-Null",
|
||||
"\tExpand-Archive -LiteralPath $packageSource -DestinationPath $payloadDir -Force",
|
||||
"\t$embeddedWindowsPath = $embeddedInstaller.Replace('/', '\\')",
|
||||
"\t$installer = Join-Path $payloadDir $embeddedWindowsPath",
|
||||
"\t$installerWorkingDirectory = [System.IO.Path]::GetDirectoryName($installer)",
|
||||
"\tif ([string]::IsNullOrWhiteSpace($installerWorkingDirectory)) { $installerWorkingDirectory = $payloadDir }",
|
||||
"\tWrite-Output (\"Embedded payload extracted: \" + $payloadDir)",
|
||||
"\tWrite-Output (\"Embedded installer selected: \" + $embeddedInstaller)",
|
||||
"}",
|
||||
"",
|
||||
"if (-not (Test-Path -LiteralPath $installer -PathType Leaf)) {",
|
||||
"\tWrite-Error \"Installer not found: $installer\"",
|
||||
"\texit 2",
|
||||
"}",
|
||||
@@ -882,8 +912,7 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
|
||||
"",
|
||||
"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' }",
|
||||
"Write-Output (\"Installer working directory: \" + $installerWorkingDirectory)",
|
||||
"",
|
||||
"$browserPidsBefore = @()",
|
||||
"if ($suppressBrowser) {",
|
||||
@@ -897,23 +926,23 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str:
|
||||
if installer_type == "msi":
|
||||
lines += [
|
||||
"$processArguments = '/i \"' + $installer + '\" ' + $arguments",
|
||||
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
|
||||
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow",
|
||||
]
|
||||
elif installer_type == "msp":
|
||||
lines += [
|
||||
"$processArguments = '/p \"' + $installer + '\" ' + $arguments",
|
||||
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
|
||||
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow",
|
||||
]
|
||||
elif installer_type == "msu":
|
||||
lines += [
|
||||
"$processArguments = '\"' + $installer + '\" ' + $arguments",
|
||||
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
|
||||
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -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.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow")
|
||||
|
||||
lines += [
|
||||
"Write-Output (\"Installer PID: \" + $process.Id)",
|
||||
@@ -1417,3 +1446,183 @@ def build_deployment_user_script(manifest: dict[str, Any], action: str) -> str:
|
||||
"Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
# v0.5.5.90 portable software-package bundles
|
||||
PACKAGE_BUNDLE_SCHEMA = "assetmanager-software-package-bundle-v1"
|
||||
PACKAGE_IMPORT_MAX_MB = max(64, int(os.getenv("SOFTWARE_PACKAGE_IMPORT_MAX_MB", "8192")))
|
||||
PACKAGE_IMPORT_MAX_FILES = max(100, int(os.getenv("SOFTWARE_PACKAGE_IMPORT_MAX_FILES", "20000")))
|
||||
|
||||
|
||||
def _sha256_path(path: Path) -> str:
|
||||
import hashlib
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def export_package_bundle(package_id: int, app_version: str = "") -> bytes:
|
||||
root = package_directory(package_id)
|
||||
manifest = load_package_manifest(package_id)
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError("package storage not found")
|
||||
files: dict[str, dict[str, Any]] = {}
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(root).as_posix()
|
||||
_safe_relative_member_path(rel)
|
||||
files[rel] = {"sha256": _sha256_path(path), "size": path.stat().st_size}
|
||||
|
||||
embedded_profile: bytes | None = None
|
||||
profile_member = "metadata/analyzer-profile.amprofile"
|
||||
stored_profile_path = root / profile_member
|
||||
if stored_profile_path.is_file():
|
||||
embedded_profile = stored_profile_path.read_bytes()
|
||||
profile_id = str((manifest.get("analysis") or {}).get("profile_id") or "").strip()
|
||||
if embedded_profile is None and profile_id:
|
||||
try:
|
||||
from .analyzer_profiles import export_profile_bundle
|
||||
embedded_profile = export_profile_bundle(profile_id)
|
||||
except Exception:
|
||||
embedded_profile = None
|
||||
if embedded_profile is not None:
|
||||
files[profile_member] = {
|
||||
"sha256": hashlib.sha256(embedded_profile).hexdigest(),
|
||||
"size": len(embedded_profile),
|
||||
}
|
||||
|
||||
export_manifest = {
|
||||
"schema": PACKAGE_BUNDLE_SCHEMA,
|
||||
"bundle_version": 1,
|
||||
"application": "AssetManager",
|
||||
"application_version": str(app_version or ""),
|
||||
"package_schema": str(manifest.get("schema") or ""),
|
||||
"name": str(manifest.get("name") or ""),
|
||||
"version": str(manifest.get("version") or ""),
|
||||
"profile_id": profile_id,
|
||||
"files": files,
|
||||
}
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
archive.writestr("assetmanager-export.json", json.dumps(export_manifest, ensure_ascii=True, indent=2) + "\n")
|
||||
for rel in files:
|
||||
if rel == "metadata/analyzer-profile.amprofile":
|
||||
if embedded_profile is not None:
|
||||
archive.writestr(rel, embedded_profile)
|
||||
else:
|
||||
archive.write(root / rel, arcname=rel)
|
||||
stream.seek(0)
|
||||
return stream.read()
|
||||
|
||||
|
||||
def _validate_package_zip_member(info: zipfile.ZipInfo) -> str:
|
||||
name = str(info.filename or "").replace("\\", "/")
|
||||
if not name or name.endswith("/"):
|
||||
return ""
|
||||
name = _safe_relative_member_path(name)
|
||||
mode = (info.external_attr >> 16) & 0o170000
|
||||
if mode == 0o120000:
|
||||
raise ValueError("symbolic links are not allowed in package bundles")
|
||||
return name
|
||||
|
||||
|
||||
def import_package_bundle(db: Session, data: bytes | Path, source_name: str = "", import_profile: bool = False) -> SoftwarePackage:
|
||||
if isinstance(data, Path):
|
||||
if not data.is_file() or data.stat().st_size <= 0:
|
||||
raise ValueError("package bundle is empty")
|
||||
if data.stat().st_size > PACKAGE_IMPORT_MAX_MB * 1024 * 1024:
|
||||
raise ValueError("package bundle exceeds import size limit")
|
||||
zip_source: Any = data
|
||||
else:
|
||||
if not data:
|
||||
raise ValueError("package bundle is empty")
|
||||
if len(data) > PACKAGE_IMPORT_MAX_MB * 1024 * 1024:
|
||||
raise ValueError("package bundle exceeds import size limit")
|
||||
zip_source = io.BytesIO(data)
|
||||
temporary_root = PACKAGE_ROOT / f".import-{uuid.uuid4().hex}.tmp"
|
||||
temporary_root.mkdir(parents=True, exist_ok=False)
|
||||
package: SoftwarePackage | None = None
|
||||
embedded_profile_data: bytes | None = None
|
||||
try:
|
||||
with zipfile.ZipFile(zip_source, "r") as archive:
|
||||
infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||
if len(infos) > PACKAGE_IMPORT_MAX_FILES:
|
||||
raise ValueError("package bundle contains too many files")
|
||||
total = sum(max(0, int(info.file_size)) for info in infos)
|
||||
if total > PACKAGE_IMPORT_MAX_MB * 1024 * 1024:
|
||||
raise ValueError("expanded package bundle exceeds import size limit")
|
||||
members: dict[str, zipfile.ZipInfo] = {}
|
||||
for info in infos:
|
||||
name = _validate_package_zip_member(info)
|
||||
if not name:
|
||||
continue
|
||||
if name in members:
|
||||
raise ValueError("duplicate package bundle member")
|
||||
members[name] = info
|
||||
if "package.json" not in members:
|
||||
raise ValueError("package.json is missing")
|
||||
export_meta: dict[str, Any] = {}
|
||||
if "assetmanager-export.json" in members:
|
||||
export_meta = json.loads(archive.read(members["assetmanager-export.json"]))
|
||||
if str(export_meta.get("schema") or "") != PACKAGE_BUNDLE_SCHEMA:
|
||||
raise ValueError("unsupported AssetManager package bundle")
|
||||
manifest = json.loads(archive.read(members["package.json"]))
|
||||
manifest, _notes = normalize_package_manifest(manifest)
|
||||
installer = _safe_member_name(manifest.get("installer_file", ""))
|
||||
required = {"package.json", installer, _safe_member_name((manifest.get("install") or {}).get("script", "install.ps1")), _safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1"))}
|
||||
if str((manifest.get("detection") or {}).get("method") or "manual") != "manual":
|
||||
required.add(_safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1")))
|
||||
missing = sorted(name for name in required if name not in members)
|
||||
if missing:
|
||||
raise ValueError("package bundle is incomplete: " + ", ".join(missing))
|
||||
checksums = export_meta.get("files") or {}
|
||||
for name, info in members.items():
|
||||
if name == "assetmanager-export.json":
|
||||
continue
|
||||
content = archive.read(info)
|
||||
if name == "metadata/analyzer-profile.amprofile":
|
||||
embedded_profile_data = content
|
||||
if name in checksums:
|
||||
expected = str((checksums.get(name) or {}).get("sha256") or "").lower()
|
||||
if expected and hashlib.sha256(content).hexdigest() != expected:
|
||||
raise ValueError(f"checksum mismatch for {name}")
|
||||
if name in checksums:
|
||||
import hashlib
|
||||
expected = str((checksums.get(name) or {}).get("sha256") or "").lower()
|
||||
if expected and hashlib.sha256(content).hexdigest() != expected:
|
||||
raise ValueError(f"checksum mismatch for {name}")
|
||||
target = temporary_root / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
package = SoftwarePackage(
|
||||
name=unique_package_name(db, str(manifest.get("name") or Path(source_name or "Imported package").stem), str(manifest.get("version") or "")),
|
||||
description=f"Imported AssetManager package | {str(manifest.get('vendor') or '').strip()}".strip(" |"),
|
||||
package_type="deployment",
|
||||
enabled=True,
|
||||
is_system=False,
|
||||
command_windows="install.ps1",
|
||||
callback_timeout_minutes=max(5, min(((package_execution_timeout_seconds(manifest) + 59) // 60) + 5, 240)),
|
||||
)
|
||||
db.add(package)
|
||||
db.flush()
|
||||
manifest["assetmanager_package_id"] = package.id
|
||||
(temporary_root / "package.json").write_text(json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||||
(temporary_root / "assetmanager-export.json").unlink(missing_ok=True)
|
||||
target_root = package_directory(package.id)
|
||||
if target_root.exists():
|
||||
shutil.rmtree(target_root)
|
||||
temporary_root.replace(target_root)
|
||||
db.commit()
|
||||
if import_profile and embedded_profile_data:
|
||||
from .analyzer_profiles import import_profile_bundle
|
||||
import_profile_bundle(embedded_profile_data, source="community")
|
||||
return package
|
||||
except Exception:
|
||||
db.rollback()
|
||||
shutil.rmtree(temporary_root, ignore_errors=True)
|
||||
if package is not None and getattr(package, "id", None):
|
||||
shutil.rmtree(package_directory(package.id), ignore_errors=True)
|
||||
raise
|
||||
|
||||
@@ -3327,4 +3327,102 @@ html[data-theme="dark"] .standard-data-table-scroll > .data-table > thead > .col
|
||||
width:10px !important;
|
||||
z-index:40 !important;
|
||||
}
|
||||
/* v0.5.5.89: software package overview actions */
|
||||
.software-package-row-actions{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.software-package-row-actions .inline-form{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
|
||||
/* v0.5.5.90: analyzer-profile and package exchange controls */
|
||||
.software-package-import-form{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:flex-start;
|
||||
gap:10px;
|
||||
}
|
||||
.software-package-import-form > label:not(.checkbox-label){
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:flex-start;
|
||||
gap:5px;
|
||||
}
|
||||
.software-package-import-options{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:flex-start;
|
||||
gap:.65rem;
|
||||
width:100%;
|
||||
margin:.2rem 0 .45rem;
|
||||
}
|
||||
.software-package-import-options .checkbox-label{
|
||||
display:inline-flex!important;
|
||||
flex-direction:row!important;
|
||||
align-items:flex-start!important;
|
||||
justify-content:flex-start!important;
|
||||
gap:.55rem!important;
|
||||
width:auto!important;
|
||||
max-width:min(100%,900px)!important;
|
||||
margin:0!important;
|
||||
text-align:left!important;
|
||||
line-height:1.35;
|
||||
}
|
||||
.software-package-import-options .checkbox-label input[type=checkbox]{
|
||||
display:inline-block!important;
|
||||
width:16px!important;
|
||||
min-width:16px!important;
|
||||
height:16px!important;
|
||||
flex:0 0 16px!important;
|
||||
margin:.12rem 0 0!important;
|
||||
padding:0!important;
|
||||
}
|
||||
.software-package-import-options .checkbox-label span{
|
||||
display:block;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
|
||||
/* v0.5.5.90: translated file selector used by analyzer/package exchange pages. */
|
||||
.localized-file-picker{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
flex-wrap:wrap;
|
||||
gap:.65rem;
|
||||
min-width:0;
|
||||
}
|
||||
.localized-file-picker-input{
|
||||
position:absolute!important;
|
||||
width:1px!important;
|
||||
height:1px!important;
|
||||
padding:0!important;
|
||||
margin:-1px!important;
|
||||
overflow:hidden!important;
|
||||
clip:rect(0,0,0,0)!important;
|
||||
white-space:nowrap!important;
|
||||
border:0!important;
|
||||
}
|
||||
.localized-file-picker-button{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
margin:0!important;
|
||||
font-weight:normal;
|
||||
cursor:pointer;
|
||||
}
|
||||
.localized-file-picker-name{
|
||||
min-width:0;
|
||||
max-width:min(70vw,720px);
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:var(--am-text,#1f2933);
|
||||
}
|
||||
html[data-theme="dark"] .localized-file-picker-name{
|
||||
color:#edf3f8;
|
||||
}
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
(() => {
|
||||
function bindPicker(picker) {
|
||||
const input = picker.querySelector('[data-file-picker-input]');
|
||||
const output = picker.querySelector('[data-file-picker-name]');
|
||||
const button = picker.querySelector('[data-file-picker-button]');
|
||||
if (!input || !output || input.dataset.filePickerBound === '1') return;
|
||||
input.dataset.filePickerBound = '1';
|
||||
const emptyText = output.dataset.emptyText || '';
|
||||
const refresh = () => {
|
||||
const files = Array.from(input.files || []);
|
||||
output.textContent = files.length ? files.map(file => file.name).join(', ') : emptyText;
|
||||
output.title = output.textContent;
|
||||
};
|
||||
button?.addEventListener('click', () => input.click());
|
||||
input.addEventListener('change', refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function bindAll(root = document) {
|
||||
root.querySelectorAll('[data-file-picker]').forEach(bindPicker);
|
||||
}
|
||||
|
||||
bindAll();
|
||||
})();
|
||||
@@ -136,5 +136,5 @@
|
||||
document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed);
|
||||
});
|
||||
})();
|
||||
</script><script src="/static/js/asset-page-boot.js"></script><script src="/static/js/presence.js"></script><script src="/static/js/software-inventory-selection.js?v={{ app_version }}"></script>
|
||||
</script><script src="/static/js/asset-page-boot.js"></script><script src="/static/js/presence.js"></script><script src="/static/js/file-picker.js?v={{ app_version }}"></script><script src="/static/js/software-inventory-selection.js?v={{ app_version }}"></script>
|
||||
</body></html>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<h2>{{ t('settings.system_information') }}</h2>
|
||||
<p class="muted">{{ t('settings.system_information_file_help') }}</p>
|
||||
<dl class="system-info-grid">
|
||||
<dt>{{ t('settings.appinfo_path') }}</dt><dd><code>/app/config/APPINFO.json</code></dd>
|
||||
<dt>{{ t('settings.appinfo_path') }}</dt><dd><code>{{ application_info_path() }}</code></dd>
|
||||
<dt>{{ t('about.version') }}</dt><dd>{{ application_version() }}</dd>
|
||||
<dt>{{ t('about.author') }}</dt><dd>{{ appinfo.author or '—' }}</dd>
|
||||
<dt>{{ t('about.organization') }}</dt><dd>{{ appinfo.organization or '—' }}</dd>
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
<section class="panel">
|
||||
<h2>{{ t('settings.backup_planned', 'Datenbanksicherung ist für v0.3.15.0 vorgesehen') }}</h2>
|
||||
<p>{{ t('settings.backup_planned_help', 'Die automatische Sicherung alle 8 Stunden, eine Aufbewahrung von 3 Tagen sowie Download, Upload, manuelles Backup und Wiederherstellung werden im nächsten Entwicklungsschritt umgesetzt.') }}</p>
|
||||
<p><strong>{{ t('settings.backup_directory', 'Backup-Verzeichnis') }}:</strong> <code>/app/data/backups</code></p>
|
||||
<p><strong>{{ t('settings.backup_directory', 'Backup-Verzeichnis') }}:</strong> <code>/assetmanager-data/backups</code></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -76,6 +76,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel software-settings-section">
|
||||
<div class="software-settings-section-heading">
|
||||
<div>
|
||||
<h2>{{ t('software.settings.remote_cleanup_title') }}</h2>
|
||||
<p class="muted">{{ t('software.settings.remote_cleanup_help') }}</p>
|
||||
</div>
|
||||
<span class="software-settings-badge">{{ t('software.settings.remote_cleanup_badge') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="software-settings-grid">
|
||||
<label class="software-toggle-option software-settings-wide">
|
||||
<input type="checkbox" name="remote_job_cleanup_enabled" {% if software_settings.get('remote_job_cleanup_enabled', true) %}checked{% endif %}>
|
||||
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
|
||||
<span>
|
||||
<strong>{{ t('software.settings.remote_cleanup_enabled') }}</strong>
|
||||
<small>{{ t('software.settings.remote_cleanup_enabled_help') }}</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{{ t('software.settings.remote_cleanup_hours') }}
|
||||
<input type="number" name="remote_job_retention_hours" min="1" max="8760" step="1" value="{{ software_settings.get('remote_job_retention_hours', 24) }}">
|
||||
<small>{{ t('software.settings.remote_cleanup_hours_help') }}</small>
|
||||
</label>
|
||||
|
||||
<div class="software-callback-preview">
|
||||
<span>{{ t('software.settings.remote_cleanup_paths') }}</span>
|
||||
<code>C:\ProgramData\AssetManager\Jobs</code>
|
||||
<code>/var/lib/assetmanager/jobs</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel software-settings-section">
|
||||
<div class="software-settings-section-heading">
|
||||
<div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<p class="muted">{{ t('setup_analyzer.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="setup-analyzer-actions">
|
||||
<a class="button button-secondary" href="/software/setup-analyzer/profiles">{{ t('setup_profiles.manage') }}</a>
|
||||
<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>
|
||||
@@ -15,9 +16,12 @@
|
||||
<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>
|
||||
<label for="installer">{{ t('setup_analyzer.file') }}</label>
|
||||
<div class="localized-file-picker" data-file-picker>
|
||||
<input class="localized-file-picker-input" id="installer" name="installer" type="file" accept=".exe,.msi,.msp,.msix,.appx,.msu" required data-file-picker-input>
|
||||
<button class="button button-secondary localized-file-picker-button" type="button" data-file-picker-button aria-controls="installer">{{ t('common.choose_file') }}</button>
|
||||
<span class="localized-file-picker-name" data-file-picker-name data-empty-text="{{ t('common.no_file_selected') }}" aria-live="polite">{{ t('common.no_file_selected') }}</span>
|
||||
</div>
|
||||
<p class="muted">{{ t('setup_analyzer.max_upload', size=max_upload_mb) }}</p>
|
||||
<button class="button" type="submit">{{ t('setup_analyzer.analyze') }}</button>
|
||||
</form>
|
||||
@@ -63,6 +67,45 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if analysis.sfx_analysis %}
|
||||
<section class="panel setup-analyzer-card">
|
||||
<h2>{{ t('setup_analyzer.sfx_title') }}</h2>
|
||||
<dl class="setup-analyzer-details">
|
||||
<dt>{{ t('setup_analyzer.sfx_container') }}</dt><dd>{{ analysis.sfx_analysis.container_label }}</dd>
|
||||
<dt>{{ t('setup_analyzer.sfx_status') }}</dt><dd>{{ t('setup_analyzer.sfx_status.' ~ analysis.sfx_analysis.status) }}</dd>
|
||||
<dt>{{ t('setup_analyzer.sfx_extractor') }}</dt><dd>{{ analysis.sfx_analysis.extractor or t('setup_analyzer.not_available') }}</dd>
|
||||
<dt>{{ t('setup_analyzer.sfx_files') }}</dt><dd>{{ analysis.sfx_analysis.file_count }}</dd>
|
||||
<dt>{{ t('setup_analyzer.sfx_size') }}</dt><dd>{{ analysis.sfx_analysis.extracted_size_human }}</dd>
|
||||
{% if analysis.sfx_analysis.selected %}
|
||||
<dt>{{ t('setup_analyzer.sfx_selected') }}</dt><dd><code>{{ analysis.sfx_analysis.selected.relative_path }}</code></dd>
|
||||
<dt>{{ t('setup_analyzer.technology') }}</dt><dd>{{ analysis.sfx_analysis.selected.installer_label }} ({{ analysis.sfx_analysis.selected.confidence }} %)</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if analysis.sfx_analysis.candidates %}
|
||||
<details>
|
||||
<summary>{{ t('setup_analyzer.sfx_candidates') }} ({{ analysis.sfx_analysis.candidates|length }})</summary>
|
||||
<div class="table-scroll standard-data-table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>{{ t('setup_analyzer.file') }}</th><th>{{ t('setup_analyzer.technology') }}</th><th>{{ t('setup_analyzer.confidence') }}</th><th>{{ t('setup_analyzer.product_name') }}</th><th>{{ t('setup_analyzer.version') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for candidate in analysis.sfx_analysis.candidates %}
|
||||
<tr{% if analysis.sfx_analysis.selected and candidate.relative_path == analysis.sfx_analysis.selected.relative_path %} class="is-selected"{% endif %}>
|
||||
<td><code>{{ candidate.relative_path }}</code></td>
|
||||
<td>{{ candidate.installer_label }}</td>
|
||||
<td>{{ candidate.confidence }} %</td>
|
||||
<td>{{ candidate.product_name }}</td>
|
||||
<td>{{ candidate.product_version }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if analysis.sfx_analysis.error %}<p class="muted">{{ analysis.sfx_analysis.error }}</p>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if analysis.warning_keys %}
|
||||
<section class="panel">
|
||||
<h2>{{ t('setup_analyzer.notes') }}</h2>
|
||||
@@ -97,6 +140,7 @@
|
||||
<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>
|
||||
{% if analysis.alternative_install_command %}<p><strong>{{ t('setup_analyzer.alternative_command') }}:</strong><br><code>{{ analysis.alternative_install_command }}</code></p>{% endif %}
|
||||
<label for="timeout_seconds">{{ t('setup_analyzer.timeout') }}
|
||||
<input id="timeout_seconds" name="timeout_seconds" type="number" min="30" max="86400" value="600">
|
||||
</label>
|
||||
@@ -116,7 +160,7 @@
|
||||
<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">
|
||||
<input id="process_names" name="process_names" value="{{ analysis.process_names_default }}" placeholder="ExampleApp.exe">
|
||||
</label>
|
||||
<small class="muted">{{ t('setup_analyzer.process_names_help') }}</small>
|
||||
<div class="setup-analyzer-checkboxes">
|
||||
@@ -164,6 +208,15 @@
|
||||
<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>
|
||||
<dt>7-Zip</dt><dd>{{ t('common.yes') if analysis.archive_tools.sevenzip_available else t('common.no') }}</dd>
|
||||
<dt>unar / lsar</dt><dd>{{ t('common.yes') if analysis.archive_tools.unar_available else t('common.no') }}</dd>
|
||||
<dt>{{ t('setup_analyzer.sfx_limits') }}</dt><dd>{{ max_extracted_mb }} MB / {{ max_extracted_files }} {{ t('setup_analyzer.sfx_files') }} / {{ t('setup_analyzer.sfx_depth', depth=max_sfx_depth) }}</dd>
|
||||
{% if analysis.launcher_architecture %}<dt>{{ t('setup_analyzer.launcher_architecture') }}</dt><dd>{{ analysis.launcher_architecture }}</dd>{% endif %}
|
||||
{% if analysis.architecture_source %}<dt>{{ t('setup_analyzer.architecture_source') }}</dt><dd>{{ t('setup_analyzer.architecture_source.' ~ analysis.architecture_source) }}</dd>{% endif %}
|
||||
{% if analysis.product_version_source %}<dt>{{ t('setup_analyzer.version_source') }}</dt><dd>{{ t('setup_analyzer.metadata_source.' ~ analysis.product_version_source) }}</dd>{% endif %}
|
||||
{% if analysis.manufacturer_source %}<dt>{{ t('setup_analyzer.manufacturer_source') }}</dt><dd>{{ t('setup_analyzer.metadata_source.' ~ analysis.manufacturer_source) }}</dd>{% endif %}
|
||||
{% if analysis.profile_name %}<dt>{{ t('setup_profiles.matched_profile') }}</dt><dd>{{ analysis.profile_name }} {{ analysis.profile_version or '' }} <code>{{ analysis.profile_id }}</code></dd>{% endif %}
|
||||
{% if analysis.profile_source %}<dt>{{ t('setup_profiles.profile_source') }}</dt><dd>{{ t('setup_profiles.source.' ~ analysis.profile_source) }}</dd>{% endif %}
|
||||
{% if analysis.original_filename %}<dt>OriginalFilename</dt><dd>{{ analysis.original_filename }}</dd>{% endif %}
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h1>{{ t('setup_profiles.title') }}</h1>
|
||||
<p class="muted">{{ t('setup_profiles.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="setup-analyzer-actions">
|
||||
<a class="button button-secondary" href="/software/setup-analyzer">{{ t('setup_profiles.back') }}</a>
|
||||
<a class="button button-secondary" href="/software/packages">{{ t('software_packages.title') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{{ t('setup_profiles.import_title') }}</h2>
|
||||
<p class="muted">{{ t('setup_profiles.import_help') }}</p>
|
||||
<form method="post" action="/software/setup-analyzer/profiles/import" enctype="multipart/form-data" class="setup-analyzer-form">
|
||||
<label for="analyzer-profile-import-file">{{ t('setup_profiles.file') }}</label>
|
||||
<div class="localized-file-picker" data-file-picker>
|
||||
<input class="localized-file-picker-input" id="analyzer-profile-import-file" type="file" name="profile_file" accept=".amprofile,.zip" required data-file-picker-input>
|
||||
<button class="button button-secondary localized-file-picker-button" type="button" data-file-picker-button aria-controls="analyzer-profile-import-file">{{ t('common.choose_file') }}</button>
|
||||
<span class="localized-file-picker-name" data-file-picker-name data-empty-text="{{ t('common.no_file_selected') }}" aria-live="polite">{{ t('common.no_file_selected') }}</span>
|
||||
</div>
|
||||
<label>{{ t('setup_profiles.source') }}
|
||||
<select name="source">
|
||||
<option value="community">{{ t('setup_profiles.source.community') }}</option>
|
||||
<option value="local">{{ t('setup_profiles.source.local') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button" type="submit">{{ t('setup_profiles.import_button') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{{ t('setup_profiles.installed_title') }}</h2>
|
||||
<div class="table-scroll management-table-scroll">
|
||||
<table class="data-table" data-storage-key="setup-analyzer-profiles">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('setup_profiles.name') }}</th>
|
||||
<th>{{ t('setup_profiles.id') }}</th>
|
||||
<th>{{ t('setup_analyzer.version') }}</th>
|
||||
<th>{{ t('setup_profiles.kind') }}</th>
|
||||
<th>{{ t('setup_profiles.stage') }}</th>
|
||||
<th>{{ t('setup_profiles.source') }}</th>
|
||||
<th>{{ t('software_packages.status') }}</th>
|
||||
<th>{{ t('jobs.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for profile in profiles %}
|
||||
<tr>
|
||||
<td><strong>{{ profile.name }}</strong></td>
|
||||
<td><code>{{ profile.id }}</code></td>
|
||||
<td>{{ profile.version }}</td>
|
||||
<td>{{ profile.kind }}</td>
|
||||
<td>{{ profile.stage }}</td>
|
||||
<td>{{ t('setup_profiles.source.' ~ profile.source) }}</td>
|
||||
<td>{% if profile.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>
|
||||
<div class="software-package-row-actions">
|
||||
<a class="button button-secondary button-small" href="/software/setup-analyzer/profiles/{{ profile.id }}/export">{{ t('setup_profiles.export_button') }}</a>
|
||||
<form class="inline-form" method="post" action="/software/setup-analyzer/profiles/{{ profile.id }}/toggle">
|
||||
<input type="hidden" name="enabled" value="{{ '0' if profile.enabled else '1' }}">
|
||||
<button class="button button-secondary button-small" type="submit">{{ t('setup_profiles.disable') if profile.enabled else t('setup_profiles.enable') }}</button>
|
||||
</form>
|
||||
{% if profile.source != 'system' %}
|
||||
<form class="inline-form" method="post" action="/software/setup-analyzer/profiles/{{ profile.id }}/delete" onsubmit="return confirm({{ t('setup_profiles.delete_confirm', profile=profile.name)|tojson }});">
|
||||
<button class="button button-danger button-small" type="submit">{{ t('software_packages.delete_button_short') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">{{ t('setup_profiles.none') }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>{{ t('setup_profiles.repository_title') }}</h2>
|
||||
{% if repository.url %}
|
||||
<p class="muted">{{ t('setup_profiles.repository_url') }} <code>{{ repository.url }}</code></p>
|
||||
{% if not request.query_params.get('repository') %}
|
||||
<a class="button button-secondary" href="/software/setup-analyzer/profiles?repository=1">{{ t('setup_profiles.repository_load') }}</a>
|
||||
{% elif repository_error %}
|
||||
<div class="notice error">{{ repository_error }}</div>
|
||||
{% else %}
|
||||
<div class="table-scroll management-table-scroll">
|
||||
<table class="data-table" data-storage-key="setup-analyzer-repository">
|
||||
<thead><tr><th>{{ t('setup_profiles.name') }}</th><th>{{ t('setup_analyzer.version') }}</th><th>{{ t('setup_profiles.id') }}</th><th>{{ t('jobs.actions') }}</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in repository.profiles %}
|
||||
<tr>
|
||||
<td>{{ item.name or item.id }}</td><td>{{ item.version or '—' }}</td><td><code>{{ item.id }}</code></td>
|
||||
<td><form method="post" action="/software/setup-analyzer/profiles/repository/install"><input type="hidden" name="profile_id" value="{{ item.id }}"><button class="button button-small" type="submit">{{ t('setup_profiles.repository_install') }}</button></form></td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="4">{{ t('setup_profiles.repository_empty') }}</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">{{ t('setup_profiles.repository_not_configured') }}</p>
|
||||
<p><code>ANALYZER_PROFILE_REPOSITORY_URL=https://.../index.json</code></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,7 @@
|
||||
<p class="muted">{{ t('software_packages.detail_subtitle') }}</p>
|
||||
</div>
|
||||
<div class="setup-analyzer-actions">
|
||||
<a class="button button-secondary" href="/software/packages/{{ package.id }}/export">{{ t('software_packages.export_button') }}</a>
|
||||
<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>
|
||||
@@ -51,7 +52,7 @@
|
||||
<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">
|
||||
<input id="package_process_names" name="process_names" value="{{ manifest.process_control.process_names|join(', ') }}" placeholder="ExampleApp.exe">
|
||||
</label>
|
||||
<small class="muted">{{ t('software_packages.process_names_help') }}</small>
|
||||
<label for="package_process_grace_seconds">{{ t('software_packages.process_grace_seconds') }}
|
||||
@@ -74,7 +75,7 @@
|
||||
<span>{{ t('software_packages.start_application') }}</span>
|
||||
</label>
|
||||
<label for="package_start_executable">{{ t('software_packages.start_executable') }}
|
||||
<input id="package_start_executable" name="executable" value="{{ manifest.post_install.executable }}" placeholder="C:\Program Files\Greenshot\Greenshot.exe">
|
||||
<input id="package_start_executable" name="executable" value="{{ manifest.post_install.executable }}" placeholder="C:\Program Files\ExampleApp\ExampleApp.exe">
|
||||
</label>
|
||||
<small class="muted">{{ t('software_packages.start_executable_help') }}</small>
|
||||
<label for="package_start_arguments">{{ t('software_packages.start_arguments') }}
|
||||
|
||||
@@ -7,10 +7,35 @@
|
||||
</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/setup-analyzer/profiles">{{ t('setup_profiles.manage') }}</a>
|
||||
<a class="button button-secondary" href="/software">{{ t('setup_analyzer.back_to_software') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel software-package-import-panel">
|
||||
<h2>{{ t('software_packages.import_title') }}</h2>
|
||||
<p class="muted">{{ t('software_packages.import_help') }}</p>
|
||||
<form method="post" action="/software/packages/import" enctype="multipart/form-data" class="software-package-import-form">
|
||||
<label for="software-package-import-file">{{ t('software_packages.import_file') }}</label>
|
||||
<div class="localized-file-picker" data-file-picker>
|
||||
<input class="localized-file-picker-input" id="software-package-import-file" type="file" name="package_file" accept=".ampkg,.zip" required data-file-picker-input>
|
||||
<button class="button button-secondary localized-file-picker-button" type="button" data-file-picker-button aria-controls="software-package-import-file">{{ t('common.choose_file') }}</button>
|
||||
<span class="localized-file-picker-name" data-file-picker-name data-empty-text="{{ t('common.no_file_selected') }}" aria-live="polite">{{ t('common.no_file_selected') }}</span>
|
||||
</div>
|
||||
<div class="software-package-import-options">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="confirm_scripts" value="1" required>
|
||||
<span>{{ t('software_packages.import_script_warning') }}</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="import_profile" value="1">
|
||||
<span>{{ t('software_packages.import_profile') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="button" type="submit">{{ t('software_packages.import_button') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="table-scroll management-table-scroll">
|
||||
<table class="data-table" data-storage-key="software-packages">
|
||||
@@ -37,8 +62,23 @@
|
||||
<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>
|
||||
<td>{% if row.storage_error %}<span class="job-status job-status-failed" title="{{ row.storage_error }}">{{ 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>
|
||||
{% if row.job_count %}
|
||||
{% set delete_confirm_text = t('software_packages.delete_confirm_with_jobs', package=package.name, count=row.job_count) %}
|
||||
{% else %}
|
||||
{% set delete_confirm_text = t('software_packages.delete_confirm', package=package.name) %}
|
||||
{% endif %}
|
||||
<div class="software-package-row-actions">
|
||||
<a class="button button-secondary button-small" href="/software/packages/{{ package.id }}">{{ t('software.open') }}</a>
|
||||
{% if not row.storage_error %}<a class="button button-secondary button-small" href="/software/packages/{{ package.id }}/export">{{ t('software_packages.export_button') }}</a>{% endif %}
|
||||
<form class="inline-form" method="post" action="/software/packages/{{ package.id }}/delete" onsubmit="return confirm({{ delete_confirm_text|tojson }});">
|
||||
<input type="hidden" name="return_to" value="overview">
|
||||
{% if row.job_count %}<input type="hidden" name="delete_jobs" value="1">{% endif %}
|
||||
<button class="button button-danger button-small" type="submit">{{ t('software_packages.delete_button_short') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">{{ t('software_packages.none') }}</td></tr>
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = "0.5.5.88"
|
||||
APP_VERSION = "0.5.5.90"
|
||||
__version__ = APP_VERSION
|
||||
|
||||
Executable
+33
-16
@@ -27,15 +27,26 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
||||
APP_TITLE: AssetManager
|
||||
APP_CONFIG: /app/config/config.json
|
||||
APPINFO_PATH: /app/config/APPINFO.json
|
||||
BACKUP_DIR: /data/backups
|
||||
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
||||
APP_CONFIG: /assetmanager-data/config/config.json
|
||||
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
||||
UPLOAD_DIR: /assetmanager-data/uploads
|
||||
STANDARD_IMAGE_DIR: /assetmanager-data/uploads/library
|
||||
BACKUP_DIR: /assetmanager-data/backups
|
||||
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-8}
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-3}
|
||||
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
|
||||
SYNC_LOG_DIR: /app/data/logs/sync
|
||||
DIAGNOSTIC_DIR: /app/data/logs/diagnostics
|
||||
SOFTWARE_PACKAGE_DIR: /app/data/software-packages
|
||||
APP_LOG_DIR: /assetmanager-data/logs
|
||||
SYNC_LOG_DIR: /assetmanager-data/logs/sync
|
||||
DIAGNOSTIC_DIR: /assetmanager-data/logs/diagnostics
|
||||
SOFTWARE_PACKAGE_DIR: /assetmanager-data/software-packages
|
||||
ANALYZER_PROFILE_DIR: /assetmanager-data/analyzer-profiles
|
||||
SCRIPT_DIR: /assetmanager-data/scripts
|
||||
ANALYZER_PROFILE_REPOSITORY_URL: ${ANALYZER_PROFILE_REPOSITORY_URL:-}
|
||||
ANALYZER_PROFILE_MAX_BYTES: ${ANALYZER_PROFILE_MAX_BYTES:-2097152}
|
||||
ANALYZER_PROFILE_REPOSITORY_MAX_BYTES: ${ANALYZER_PROFILE_REPOSITORY_MAX_BYTES:-4194304}
|
||||
SOFTWARE_PACKAGE_IMPORT_MAX_MB: ${SOFTWARE_PACKAGE_IMPORT_MAX_MB:-8192}
|
||||
SOFTWARE_PACKAGE_IMPORT_MAX_FILES: ${SOFTWARE_PACKAGE_IMPORT_MAX_FILES:-20000}
|
||||
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
LOCAL_ADMIN_USERNAME: ${LOCAL_ADMIN_USERNAME:-}
|
||||
@@ -46,12 +57,9 @@ services:
|
||||
ports:
|
||||
- "${APP_PORT:-8088}:8000"
|
||||
volumes:
|
||||
- ./data/config:/app/config
|
||||
- ./data/uploads:/app/app/static/uploads
|
||||
- ./data/logs:/app/data/logs
|
||||
- ./data/backups:/data/backups
|
||||
- ./data/scripts:/scripts
|
||||
- ./data/software-packages:/app/data/software-packages
|
||||
# One persistent host root. The entrypoint creates all required
|
||||
# subdirectories below ./data automatically on every start.
|
||||
- ./data:/assetmanager-data
|
||||
|
||||
# Callback-only listener for Internet/DMZ scenarios.
|
||||
# Starts together with AssetManager and exposes only the callback POST endpoint
|
||||
@@ -66,8 +74,18 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
||||
APP_TITLE: AssetManager
|
||||
APP_CONFIG: /app/config/config.json
|
||||
APPINFO_PATH: /app/config/APPINFO.json
|
||||
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
||||
APP_CONFIG: /assetmanager-data/config/config.json
|
||||
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
||||
UPLOAD_DIR: /assetmanager-data/uploads
|
||||
STANDARD_IMAGE_DIR: /assetmanager-data/uploads/library
|
||||
BACKUP_DIR: /assetmanager-data/backups
|
||||
APP_LOG_DIR: /assetmanager-data/logs
|
||||
SYNC_LOG_DIR: /assetmanager-data/logs/sync
|
||||
DIAGNOSTIC_DIR: /assetmanager-data/logs/diagnostics
|
||||
SOFTWARE_PACKAGE_DIR: /assetmanager-data/software-packages
|
||||
ANALYZER_PROFILE_DIR: /assetmanager-data/analyzer-profiles
|
||||
SCRIPT_DIR: /assetmanager-data/scripts
|
||||
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
|
||||
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
@@ -75,8 +93,7 @@ services:
|
||||
ports:
|
||||
- "${CALLBACK_BIND_IP:-127.0.0.1}:${CALLBACK_PORT:-8090}:8001"
|
||||
volumes:
|
||||
- ./data/config:/app/config
|
||||
- ./data/logs:/app/data/logs
|
||||
- ./data:/assetmanager-data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/api/software-callback/health', timeout=3).read()"]
|
||||
interval: 30s
|
||||
|
||||
+85
-10
@@ -1,17 +1,87 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
CONFIG_PATH="${APP_CONFIG:-/app/config/config.json}"
|
||||
APPINFO_PATH="${APPINFO_PATH:-/app/config/APPINFO.json}"
|
||||
DEFAULT_APPINFO="/app/default-config/APPINFO.json"
|
||||
STANDARD_IMAGE_DIR="${STANDARD_IMAGE_DIR:-/app/app/static/uploads/library}"
|
||||
DATA_ROOT="${ASSETMANAGER_DATA_ROOT:-/assetmanager-data}"
|
||||
CONFIG_PATH="${APP_CONFIG:-$DATA_ROOT/config/config.json}"
|
||||
APPINFO_PATH="${APPINFO_PATH:-$DATA_ROOT/config/APPINFO.json}"
|
||||
DEFAULT_APPINFO="${DEFAULT_APPINFO:-/app/default-config/APPINFO.json}"
|
||||
UPLOAD_DIR="${UPLOAD_DIR:-$DATA_ROOT/uploads}"
|
||||
STANDARD_IMAGE_DIR="${STANDARD_IMAGE_DIR:-$UPLOAD_DIR/library}"
|
||||
APP_LOG_DIR="${APP_LOG_DIR:-$DATA_ROOT/logs}"
|
||||
SYNC_LOG_DIR="${SYNC_LOG_DIR:-$APP_LOG_DIR/sync}"
|
||||
DIAGNOSTIC_DIR="${DIAGNOSTIC_DIR:-$APP_LOG_DIR/diagnostics}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-$DATA_ROOT/backups}"
|
||||
SCRIPT_DIR="${SCRIPT_DIR:-$DATA_ROOT/scripts}"
|
||||
SOFTWARE_PACKAGE_DIR="${SOFTWARE_PACKAGE_DIR:-$DATA_ROOT/software-packages}"
|
||||
ANALYZER_PROFILE_DIR="${ANALYZER_PROFILE_DIR:-$DATA_ROOT/analyzer-profiles}"
|
||||
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")" "$(dirname "$APPINFO_PATH")" "$STANDARD_IMAGE_DIR"
|
||||
# Export normalized paths so every application module uses the same runtime root,
|
||||
# even when the container is started without the bundled Compose file.
|
||||
export ASSETMANAGER_DATA_ROOT="$DATA_ROOT"
|
||||
export APP_CONFIG="$CONFIG_PATH" APPINFO_PATH="$APPINFO_PATH"
|
||||
export UPLOAD_DIR="$UPLOAD_DIR" STANDARD_IMAGE_DIR="$STANDARD_IMAGE_DIR"
|
||||
export APP_LOG_DIR="$APP_LOG_DIR" SYNC_LOG_DIR="$SYNC_LOG_DIR" DIAGNOSTIC_DIR="$DIAGNOSTIC_DIR"
|
||||
export BACKUP_DIR="$BACKUP_DIR" SCRIPT_DIR="$SCRIPT_DIR"
|
||||
export SOFTWARE_PACKAGE_DIR="$SOFTWARE_PACKAGE_DIR" ANALYZER_PROFILE_DIR="$ANALYZER_PROFILE_DIR"
|
||||
|
||||
ensure_dir() {
|
||||
path="$1"
|
||||
label="$2"
|
||||
if ! mkdir -p "$path"; then
|
||||
echo "ERROR: Cannot create $label directory: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "ERROR: $label path is not a directory: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
probe="$path/.assetmanager-write-test-${HOSTNAME:-container}-$$"
|
||||
if ! (umask 077; : > "$probe") 2>/dev/null; then
|
||||
echo "ERROR: $label directory is not writable: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$probe"
|
||||
}
|
||||
|
||||
# Keep all persistent runtime data below the single Compose bind mount.
|
||||
# This avoids requiring administrators to create new feature directories by hand.
|
||||
ensure_dir "$DATA_ROOT" "data root"
|
||||
ensure_dir "$(dirname "$CONFIG_PATH")" "configuration"
|
||||
ensure_dir "$(dirname "$APPINFO_PATH")" "application information"
|
||||
ensure_dir "$UPLOAD_DIR" "uploads"
|
||||
ensure_dir "$STANDARD_IMAGE_DIR" "standard image library"
|
||||
ensure_dir "$APP_LOG_DIR" "logs"
|
||||
ensure_dir "$SYNC_LOG_DIR" "sync logs"
|
||||
ensure_dir "$DIAGNOSTIC_DIR" "diagnostics"
|
||||
ensure_dir "$BACKUP_DIR" "backups"
|
||||
ensure_dir "$SCRIPT_DIR" "scripts"
|
||||
ensure_dir "$SOFTWARE_PACKAGE_DIR" "software packages"
|
||||
ensure_dir "$ANALYZER_PROFILE_DIR" "analyzer profiles"
|
||||
ensure_dir "$ANALYZER_PROFILE_DIR/community" "community analyzer profiles"
|
||||
ensure_dir "$ANALYZER_PROFILE_DIR/local" "local analyzer profiles"
|
||||
|
||||
# Preserve the established /scripts path used by existing job definitions while
|
||||
# storing the files inside the common persistent data root.
|
||||
if [ "$SCRIPT_DIR" != "/scripts" ]; then
|
||||
if [ -L /scripts ]; then
|
||||
rm -f /scripts
|
||||
elif [ -e /scripts ]; then
|
||||
if [ -d /scripts ] && [ -z "$(ls -A /scripts 2>/dev/null)" ]; then
|
||||
rmdir /scripts
|
||||
else
|
||||
echo "ERROR: /scripts exists and cannot safely be linked to $SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ln -s "$SCRIPT_DIR" /scripts
|
||||
fi
|
||||
|
||||
echo "AssetManager data root: $DATA_ROOT"
|
||||
echo "Standard image library: $STANDARD_IMAGE_DIR"
|
||||
|
||||
if [ ! -f "$CONFIG_PATH" ]; then
|
||||
echo "Initializing AssetManager configuration: $CONFIG_PATH"
|
||||
python - "$CONFIG_PATH" <<'PY'
|
||||
python - "$CONFIG_PATH" <<'PYCFG'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -19,16 +89,21 @@ from app.config import DEFAULT_CONFIG
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("x", encoding="utf-8") as handle:
|
||||
json.dump(DEFAULT_CONFIG, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
PY
|
||||
try:
|
||||
with path.open("x", encoding="utf-8") as handle:
|
||||
json.dump(DEFAULT_CONFIG, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
except FileExistsError:
|
||||
# app and callback containers may perform first-start initialization in parallel.
|
||||
pass
|
||||
PYCFG
|
||||
else
|
||||
echo "Keeping existing AssetManager configuration: $CONFIG_PATH"
|
||||
fi
|
||||
|
||||
if [ ! -f "$APPINFO_PATH" ]; then
|
||||
echo "Initializing AssetManager APPINFO: $APPINFO_PATH"
|
||||
# cp is safe when app and callback initialize the same fresh installation.
|
||||
cp "$DEFAULT_APPINFO" "$APPINFO_PATH"
|
||||
else
|
||||
echo "Keeping existing AssetManager APPINFO: $APPINFO_PATH"
|
||||
|
||||
@@ -34,7 +34,7 @@ Image removal and replacement only change the database reference. The physical u
|
||||
Version 0.5.5.60 adds a reusable standard image library for assets and categories.
|
||||
|
||||
- Persistent Docker host directory: `./data/uploads/library`
|
||||
- Container directory: `/app/app/static/uploads/library`
|
||||
- Container directory: `/assetmanager-data/uploads/library`
|
||||
- Public application path: `/static/uploads/library/`
|
||||
- Supported formats: PNG, JPG/JPEG, WEBP and GIF
|
||||
- Existing library images can be selected directly in the asset and category forms.
|
||||
|
||||
+36
-34
@@ -4,14 +4,15 @@ This guide describes a Docker image based installation and the first-start behav
|
||||
|
||||
## 1. Prepare the installation directory
|
||||
|
||||
Create a dedicated directory and the persistent data directories:
|
||||
Create only a dedicated installation directory. AssetManager creates its required application subdirectories below `./data` automatically on container start:
|
||||
|
||||
```bash
|
||||
mkdir -p /srv/docker/assetmanager
|
||||
cd /srv/docker/assetmanager
|
||||
mkdir -p data/config data/uploads data/logs data/backups data/scripts data/software-packages data/postgres
|
||||
```
|
||||
|
||||
No manual creation of `data/config`, `data/uploads`, `data/logs`, `data/backups`, `data/scripts`, `data/software-packages` or `data/analyzer-profiles` is required.
|
||||
|
||||
Create a `.env` file. Use strong, unique values for all secrets:
|
||||
|
||||
```env
|
||||
@@ -67,14 +68,21 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
||||
APP_TITLE: AssetManager
|
||||
APP_CONFIG: /app/config/config.json
|
||||
APPINFO_PATH: /app/config/APPINFO.json
|
||||
BACKUP_DIR: /data/backups
|
||||
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
||||
APP_CONFIG: /assetmanager-data/config/config.json
|
||||
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
||||
UPLOAD_DIR: /assetmanager-data/uploads
|
||||
STANDARD_IMAGE_DIR: /assetmanager-data/uploads/library
|
||||
BACKUP_DIR: /assetmanager-data/backups
|
||||
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-8}
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-3}
|
||||
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
|
||||
SYNC_LOG_DIR: /app/data/logs/sync
|
||||
DIAGNOSTIC_DIR: /app/data/logs/diagnostics
|
||||
APP_LOG_DIR: /assetmanager-data/logs
|
||||
SYNC_LOG_DIR: /assetmanager-data/logs/sync
|
||||
DIAGNOSTIC_DIR: /assetmanager-data/logs/diagnostics
|
||||
SOFTWARE_PACKAGE_DIR: /assetmanager-data/software-packages
|
||||
ANALYZER_PROFILE_DIR: /assetmanager-data/analyzer-profiles
|
||||
SCRIPT_DIR: /assetmanager-data/scripts
|
||||
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
LOCAL_ADMIN_USERNAME: ${LOCAL_ADMIN_USERNAME:-}
|
||||
@@ -84,12 +92,7 @@ services:
|
||||
ports:
|
||||
- "${APP_PORT:-8088}:8000"
|
||||
volumes:
|
||||
- ./data/config:/app/config
|
||||
- ./data/uploads:/app/app/static/uploads
|
||||
- ./data/logs:/app/data/logs
|
||||
- ./data/backups:/data/backups
|
||||
- ./data/scripts:/scripts
|
||||
- ./data/software-packages:/app/data/software-packages
|
||||
- ./data:/assetmanager-data
|
||||
|
||||
callback:
|
||||
image: git.jusaro.de/roland/assetmanager:${ASSETMANAGER_VERSION:-0.5.5.68}
|
||||
@@ -101,8 +104,18 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
||||
APP_TITLE: AssetManager
|
||||
APP_CONFIG: /app/config/config.json
|
||||
APPINFO_PATH: /app/config/APPINFO.json
|
||||
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
||||
APP_CONFIG: /assetmanager-data/config/config.json
|
||||
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
||||
UPLOAD_DIR: /assetmanager-data/uploads
|
||||
STANDARD_IMAGE_DIR: /assetmanager-data/uploads/library
|
||||
BACKUP_DIR: /assetmanager-data/backups
|
||||
APP_LOG_DIR: /assetmanager-data/logs
|
||||
SYNC_LOG_DIR: /assetmanager-data/logs/sync
|
||||
DIAGNOSTIC_DIR: /assetmanager-data/logs/diagnostics
|
||||
SOFTWARE_PACKAGE_DIR: /assetmanager-data/software-packages
|
||||
ANALYZER_PROFILE_DIR: /assetmanager-data/analyzer-profiles
|
||||
SCRIPT_DIR: /assetmanager-data/scripts
|
||||
MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-}
|
||||
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
@@ -110,8 +123,7 @@ services:
|
||||
ports:
|
||||
- "${CALLBACK_BIND_IP:-127.0.0.1}:${CALLBACK_PORT:-8090}:8001"
|
||||
volumes:
|
||||
- ./data/config:/app/config
|
||||
- ./data/logs:/app/data/logs
|
||||
- ./data:/assetmanager-data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/api/software-callback/health', timeout=3).read()"]
|
||||
interval: 30s
|
||||
@@ -136,14 +148,14 @@ docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
On the first start, the container creates the following files only when they do not already exist:
|
||||
On every start, the container verifies and creates the required application directories below `./data` when they do not already exist. On the first start it also creates:
|
||||
|
||||
```text
|
||||
data/config/config.json
|
||||
data/config/APPINFO.json
|
||||
```
|
||||
|
||||
Existing files are preserved during subsequent container starts and updates.
|
||||
Existing directories and files are preserved during subsequent container starts and updates.
|
||||
|
||||
## 4. Important: authentication is initially disabled
|
||||
|
||||
@@ -268,17 +280,7 @@ If `config.json` or `APPINFO.json` is absent, restart the application container.
|
||||
|
||||
### Persistent standard image library
|
||||
|
||||
Add the following persistent volume to the application service:
|
||||
|
||||
```yaml
|
||||
- ./data/uploads:/app/app/static/uploads
|
||||
```
|
||||
|
||||
Create the host directory before the first start if desired:
|
||||
|
||||
```bash
|
||||
mkdir -p data/uploads/library
|
||||
```
|
||||
No additional volume or manual directory creation is required. The application container mounts the common `./data` root and creates `data/uploads/library` automatically on start.
|
||||
|
||||
Images uploaded through the AssetManager standard image library are stored there. You can also copy PNG, JPG/JPEG, WEBP or GIF files directly into this directory. They are then offered for selection in asset and category forms after the page is reloaded.
|
||||
|
||||
@@ -291,20 +293,20 @@ Reusable asset/category images are stored persistently on the Docker host in:
|
||||
./data/uploads/library
|
||||
```
|
||||
|
||||
The library is a subdirectory of the already existing uploads volume. No additional Docker mount is required. The existing Compose mapping:
|
||||
The library is a subdirectory of the common persistent data root. The Compose mapping:
|
||||
|
||||
```text
|
||||
./data/uploads -> /app/app/static/uploads
|
||||
./data -> /assetmanager-data
|
||||
```
|
||||
|
||||
therefore exposes the library as:
|
||||
|
||||
```text
|
||||
Host: ./data/uploads/library
|
||||
Container: /app/app/static/uploads/library
|
||||
Container: /assetmanager-data/uploads/library
|
||||
```
|
||||
|
||||
Images may be uploaded from the AssetManager forms or copied directly into `./data/uploads/library` on the Docker host. They remain persistent because `data/uploads` is already part of the standard AssetManager volume layout.
|
||||
Images may be uploaded from the AssetManager forms or copied directly into `./data/uploads/library` on the Docker host. They remain persistent because `data/uploads` is below the standard AssetManager data root.
|
||||
|
||||
For image-based installations use:
|
||||
|
||||
|
||||
+39
-2
@@ -13,7 +13,8 @@ The Setup Analyzer prepares Windows installation files for silent deployment wit
|
||||
- InstallShield
|
||||
- Advanced Installer
|
||||
- Squirrel
|
||||
- common 7-Zip and WinRAR SFX wrappers
|
||||
- ZIP-compatible, 7-Zip and WinRAR/RAR SFX wrappers
|
||||
- vendor profiles for Total Commander SFX and the PDF24 Creator online bootstrapper
|
||||
- unknown EXE fallback
|
||||
|
||||
## Analysis output
|
||||
@@ -50,6 +51,18 @@ Generated installation scripts no longer use `Start-Process -Wait` for the insta
|
||||
|
||||
The optional **Suppress post-install browser launch** setting terminates only browser processes that were newly created inside the installer process tree. Existing browser sessions are not touched. The option is preselected for Greenshot detections because Greenshot installers can open a completion web page after setup.
|
||||
|
||||
## SFX and embedded installers
|
||||
|
||||
Version 0.5.5.89 adds recursive static analysis of supported self-extracting installer containers. AssetManager never executes the uploaded SFX on the server. It first identifies the outer wrapper and then attempts to list and extract the payload with safe path, file-count, expanded-size and recursion limits.
|
||||
|
||||
Supported extraction paths include ZIP-compatible self-extracting files, 7-Zip SFX containers through the 7-Zip command-line tool, and WinRAR/RAR SFX containers through `unar` / `lsar` with 7-Zip as an additional fallback where supported by the installed build.
|
||||
|
||||
After extraction, embedded MSI/MSP/MSIX/AppX/MSU and EXE installer candidates are analyzed with the same static technology detector. Nested SFX candidates can be opened recursively up to the configured depth. Candidate selection is heuristic: known installer technologies, setup-like filenames and usable product metadata increase the score, while uninstallers and common prerequisite redistributables are strongly penalized. The selected candidate and alternatives are shown in the UI.
|
||||
|
||||
When an embedded installer is selected, package creation preserves the complete extracted payload rather than copying only the selected installer. AssetManager stores that payload in an internal ZIP, transfers it as one package file, expands it on the Windows target, and runs the selected embedded installer from its original relative directory. This preserves adjacent CAB files and subdirectories needed by many vendor packages.
|
||||
|
||||
The SFX feature still cannot guarantee that the heuristically selected inner installer is the vendor-supported deployment entry point. Always test the generated package on a designated test asset.
|
||||
|
||||
## Security
|
||||
|
||||
The uploaded installer is stored in a temporary directory and is not executed. Access is restricted to administrators. Old temporary analyses are removed after the configured retention period.
|
||||
@@ -60,7 +73,10 @@ Environment variables:
|
||||
SETUP_ANALYZER_TMP_DIR=/tmp/assetmanager-setup-analyzer
|
||||
SETUP_ANALYZER_MAX_UPLOAD_MB=4096
|
||||
SETUP_ANALYZER_RETENTION_HOURS=24
|
||||
SOFTWARE_PACKAGE_DIR=/app/data/software-packages
|
||||
SETUP_ANALYZER_MAX_EXTRACTED_MB=8192
|
||||
SETUP_ANALYZER_MAX_EXTRACTED_FILES=20000
|
||||
SETUP_ANALYZER_MAX_SFX_DEPTH=2
|
||||
SOFTWARE_PACKAGE_DIR=/assetmanager-data/software-packages
|
||||
```
|
||||
|
||||
## MSI metadata
|
||||
@@ -70,3 +86,24 @@ Standard MSI silent-command generation works without additional system packages.
|
||||
## Important
|
||||
|
||||
Installer technology detection and a suggested command do not guarantee vendor-specific compatibility. Test generated commands on a designated test asset before broad deployment.
|
||||
|
||||
|
||||
## Vendor bootstrapper notes
|
||||
|
||||
Total Commander SFX installers are detected through their embedded `INSTALL.INF`. AssetManager uses `/AH1` for unattended hidden installation and shows `/A1` as the visible automatic alternative.
|
||||
|
||||
The small `pdf24-creator-installer.exe` is treated as an online bootstrapper rather than as the full PDF24 Creator Inno Setup package. It selects an architecture-specific current installer at runtime, so its package version is intentionally left unpinned. The analyzer surfaces `/SILENT` with medium confidence and recommends using the offline EXE or MSI when deterministic deployment is required.
|
||||
|
||||
## Analyzer profiles (0.5.5.90)
|
||||
|
||||
Vendor- and installer-specific detection knowledge is no longer added to the Python analyzer as product-specific branches. The generic analyzer engine loads declarative profiles from:
|
||||
|
||||
- `app/analyzer_profiles/system/` for profiles shipped with AssetManager,
|
||||
- `/assetmanager-data/analyzer-profiles/community/` for imported/community profiles,
|
||||
- `/assetmanager-data/analyzer-profiles/local/` for locally maintained overrides.
|
||||
|
||||
Profiles can contribute static markers, match rules, installer metadata extraction, architecture rules, silent parameters, process-control defaults and post-install defaults. Community and local profiles can override a system profile by using the same stable profile ID. The profile manager is available under **Software -> Setup Analyzer -> Analyzer profiles**.
|
||||
|
||||
Profiles are exchanged as `.amprofile` bundles and contain declarative JSON only. They cannot execute Python code. A separately hosted HTTPS repository can provide a catalog of `.amprofile` bundles through `ANALYZER_PROFILE_REPOSITORY_URL`; administrators explicitly choose profiles to install.
|
||||
|
||||
Software packages can be exported as `.ampkg` and re-imported. If the package was created from an analyzer profile, the export can embed that `.amprofile`; the importing administrator may explicitly choose whether to install the embedded profile as a Community profile.
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
# AssetManager Analyzer Profiles
|
||||
|
||||
AssetManager 0.5.5.90 separates installer-specific knowledge from the analyzer engine.
|
||||
The Python engine performs generic operations such as PE inspection, MSI metadata parsing,
|
||||
marker scanning, safe SFX extraction, embedded-installer selection and profile evaluation.
|
||||
Product/vendor knowledge is stored in declarative JSON profiles.
|
||||
|
||||
## Profile types
|
||||
|
||||
Profiles use schema `assetmanager-analyzer-profile-v1` and profile API `1`.
|
||||
They can be installed from three sources:
|
||||
|
||||
- `system`: shipped with AssetManager under `app/analyzer_profiles/system/`.
|
||||
- `community`: imported manually or installed from a configured community repository.
|
||||
- `local`: locally maintained profiles. Local profiles override profiles with the same ID.
|
||||
|
||||
Imported profiles are stored in `/assetmanager-data/analyzer-profiles` and are included in AssetManager backups.
|
||||
Profiles are declarative data only and cannot contain executable Python code.
|
||||
|
||||
## Exchange format
|
||||
|
||||
Profiles are exported as `.amprofile` files. The file is a ZIP container with:
|
||||
|
||||
- `manifest.json`: bundle schema, profile ID/version/API and SHA-256 of `profile.json`.
|
||||
- `profile.json`: the validated declarative profile definition.
|
||||
|
||||
The import validates paths, size, profile schema/API and SHA-256 before installing the profile.
|
||||
|
||||
## Community repository index
|
||||
|
||||
A repository is an HTTPS-hosted JSON index. Configure it with:
|
||||
|
||||
`ANALYZER_PROFILE_REPOSITORY_URL=https://example.org/assetmanager-profiles/index.json`
|
||||
|
||||
Index format:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-repository-v1",
|
||||
"name": "AssetManager Community Profiles",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "vendor.example-app",
|
||||
"name": "Example App",
|
||||
"version": "1.0.0",
|
||||
"url": "https://example.org/profiles/vendor.example-app.amprofile",
|
||||
"sha256": "<sha256 of the amprofile file>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The AssetManager administrator explicitly loads the catalog and chooses which profile to install.
|
||||
The bundle SHA-256 is verified when the repository provides one.
|
||||
|
||||
## Contributing profiles
|
||||
|
||||
A public profile repository can be maintained independently from the AssetManager application
|
||||
repository. Contributors only need to submit declarative `.amprofile` bundles and index metadata;
|
||||
no AssetManager source-code change is required for ordinary vendor/installer rules.
|
||||
|
||||
Use stable profile IDs. Increase the profile version when rules change. Avoid filename-only matching
|
||||
when stronger static evidence is available. Silent parameters should only be marked high-confidence
|
||||
when they are documented or clearly proven by installer metadata/static analysis.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema": "assetmanager-analyzer-profile-repository-v1",
|
||||
"name": "AssetManager Community Profiles",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "vendor.example-app",
|
||||
"name": "Example App",
|
||||
"version": "1.0.0",
|
||||
"url": "https://example.org/profiles/vendor.example-app.amprofile",
|
||||
"sha256": "replace-with-bundle-sha256"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
- [0.5.5.90](UPDATE-0.5.5.90.md) - modular analyzer profiles, community repository foundation and portable package import/export.
|
||||
- [0.5.5.89](UPDATE-0.5.5.89.md) - recursively analyze supported SFX payloads and package selected embedded installers.
|
||||
- [0.5.5.88](UPDATE-0.5.5.88.md) - keep title/filter rows sticky while preserving unified column resizing.
|
||||
- [0.5.5.87](UPDATE-0.5.5.87.md) - unify interactive table behavior and restore column resizing with sticky two-row headers.
|
||||
- [0.5.5.86](UPDATE-0.5.5.86.md) - fix status filtering so assets without a status remain visible in global software lists.
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
Release notes are stored outside the project root to keep the repository overview compact.
|
||||
|
||||
The current release is **0.5.5.88**.
|
||||
The current release is **0.5.5.90**.
|
||||
|
||||
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
||||
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
# Update 0.5.5.89
|
||||
|
||||
## Setup Analyzer: SFX and embedded installers
|
||||
|
||||
- Detect supported 7-Zip and WinRAR/RAR SFX wrappers as outer containers.
|
||||
- Statically extract supported SFX payloads without executing uploaded installers.
|
||||
- ZIP-compatible SFX files are handled by Python directly.
|
||||
- Container image now includes 7-Zip and `unar` / `lsar` for additional SFX formats.
|
||||
- Recursively analyze embedded installer candidates up to a configurable depth.
|
||||
- Rank embedded MSI and known EXE installer technologies while penalizing uninstallers and common prerequisite packages.
|
||||
- Display the selected embedded installer and alternate candidates in Setup Analyzer.
|
||||
- Preserve the complete selected payload tree in an internal deployment ZIP so CAB files and subdirectories remain available.
|
||||
- Software-package install scripts expand the payload on the target and run the selected installer from its original relative directory.
|
||||
- Add extraction safety limits for total expanded size, file count, path traversal, symbolic links and recursion depth.
|
||||
|
||||
## New environment settings
|
||||
|
||||
```text
|
||||
SETUP_ANALYZER_MAX_EXTRACTED_MB=8192
|
||||
SETUP_ANALYZER_MAX_EXTRACTED_FILES=20000
|
||||
SETUP_ANALYZER_MAX_SFX_DEPTH=2
|
||||
```
|
||||
|
||||
Embedded-installer selection remains heuristic and should be verified on a test asset before broad deployment.
|
||||
|
||||
## Software package cleanup
|
||||
|
||||
- Added a Delete button directly to every row of the software-package overview.
|
||||
- Broken packages that show a storage error can be deleted without opening their detail page.
|
||||
- If associated software jobs exist, the overview confirmation explicitly states that those jobs will be deleted too.
|
||||
- Package summary handling now keeps the overview usable when either the manifest or package-size scan fails.
|
||||
## Architecture detection fix
|
||||
|
||||
- Distinguishes installer-launcher PE architecture from the target software architecture.
|
||||
- Known setup wrappers such as NSIS/Inno no longer classify a package as x86 merely because their launcher stub is PE32.
|
||||
- Explicit x64/x86/ARM64 package filename hints are used as target-architecture evidence.
|
||||
- The analyzer shows the launcher architecture and architecture source separately in technical details.
|
||||
- Verified with `npp.8.9.8.Installer.x64.exe`: NSIS launcher x86, target package x64.
|
||||
|
||||
## Dispatcher ACL retry fix
|
||||
|
||||
- Upload retry no longer reapplies directory ACLs recursively to files already uploaded into the job directory.
|
||||
- Windows job-directory ACLs are now applied only to the directory itself; uploaded files inherit the directory permissions normally.
|
||||
- Package-file retry removes only the failed target file and leaves `run.ps1` and other package files untouched.
|
||||
- A remote script preflight now logs existence, size, readability and Windows ACLs before execution.
|
||||
- If the uploaded `run.ps1` is not readable, the dispatcher stops with a dedicated diagnostic error before trying to launch PowerShell.
|
||||
|
||||
## Total Commander SFX profile
|
||||
|
||||
- Recognize Total Commander's self-extracting ZIP installer through its embedded `INSTALL.INF`.
|
||||
- Read product name, version, publisher, target architecture and running-process hint from embedded installer metadata.
|
||||
- Use `/AH1` as the unattended default (automatic + hidden installation) and expose `/A1` as the visible automatic alternative.
|
||||
- Keep the outer SFX executable as the deployment installer; its embedded CAB/INF files are installation data, not a replacement setup executable.
|
||||
- Recognize architecture suffixes attached directly to version numbers such as `tcmd1156x64.exe`.
|
||||
|
||||
## PDF24 online bootstrapper profile
|
||||
|
||||
- Detect the small `pdf24-creator-installer.exe` bootstrapper through multiple vendor-specific static markers instead of reporting an unknown EXE at 25%.
|
||||
- Distinguish the bootstrapper from the full PDF24 Creator Inno Setup package.
|
||||
- Report `PDF24 Creator`, `geek software GmbH` and the bootstrapper's architecture-selecting behavior (`x86+x64+arm64`).
|
||||
- Do not misreport the bootstrapper's own `1.0.0` file version as the PDF24 Creator version, because the bootstrapper downloads the current Creator release at deployment time.
|
||||
- Surface `/SILENT` with medium command confidence and warn that this online bootstrapper is network-dependent and version-unpinned; prefer the offline EXE/MSI for reproducible managed deployment.
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
# Update 0.5.5.90
|
||||
|
||||
## Modular Setup Analyzer profiles
|
||||
|
||||
- Installer/vendor-specific Setup Analyzer knowledge is moved into declarative analyzer profiles.
|
||||
- System, Community and Local profile sources are supported.
|
||||
- `.amprofile` import/export validates schema, profile API and SHA-256 and contains no executable plugin code.
|
||||
- Profiles can be enabled/disabled; imported Community/Local profiles can be deleted without modifying application source.
|
||||
- Existing Total Commander, PDF24 and Greenshot-specific analyzer behavior is supplied as system profiles instead of Python special cases.
|
||||
- Generic technology signatures for Inno Setup, NSIS, WiX Burn, InstallShield, Advanced Installer, Squirrel, 7-Zip SFX and WinRAR SFX are also supplied as profiles.
|
||||
|
||||
## Community repository foundation
|
||||
|
||||
- Optional HTTPS profile repository support via `ANALYZER_PROFILE_REPOSITORY_URL`.
|
||||
- Repository index schema `assetmanager-analyzer-profile-repository-v1`.
|
||||
- Admins explicitly load the repository catalog and select profiles to install.
|
||||
- Optional repository SHA-256 is checked before a bundle is imported.
|
||||
- Repository format/contribution documentation is included under `docs/analyzer-profiles/`.
|
||||
|
||||
## Portable software packages
|
||||
|
||||
- Stored software packages can now be exported as `.ampkg` bundles.
|
||||
- Package overview can import `.ampkg` bundles and compatible Setup Analyzer ZIP exports.
|
||||
- Imports validate ZIP paths, symlinks, required package files and available SHA-256 metadata.
|
||||
- Import UI requires acknowledgement that software packages may contain executable PowerShell scripts.
|
||||
- Imported analyzer profiles are included in AssetManager backup/restore.
|
||||
|
||||
## Automatic persistent directory initialization
|
||||
|
||||
- Docker Compose now mounts one persistent application root (`./data` -> `/assetmanager-data`) instead of requiring a separate host bind directory for every feature.
|
||||
- The container entrypoint verifies and creates all required application subdirectories on every start, including analyzer profiles and software packages.
|
||||
- Existing host data remains in the same `./data/...` layout; no data migration is required.
|
||||
- The established `/scripts` container path remains available for existing job definitions.
|
||||
- Uploaded images remain available under their existing `/static/uploads/...` URLs while being stored below the persistent data root.
|
||||
|
||||
## Import form and generic installer metadata fixes
|
||||
|
||||
- Software-package import checkboxes are rendered left-aligned, vertically stacked, and no longer inherit full-width input sizing.
|
||||
- EXE analyzer metadata now uses a conservative dotted-version fallback from the installer filename when MSI/MSIX/PE metadata has no product version.
|
||||
- Signed PE installers can use the Authenticode code-signing certificate organization/common name as a manufacturer fallback.
|
||||
- The Authenticode publisher is only a fallback; explicit MSI/MSIX/PE manufacturer metadata still has higher priority.
|
||||
- The generic metadata enrichment contains no VLC-specific Python logic; product-specific naming is supplied by the declarative VLC analyzer profile.
|
||||
|
||||
|
||||
## Follow-up: VLC metadata, localized file picker and client job retention
|
||||
|
||||
- Added declarative VLC analyzer profile so NSIS VLC packages receive the product name `VLC media player` without product-specific Python code.
|
||||
- Replaced browser-native file selector text in Setup Analyzer, analyzer-profile import and software-package import with translated AssetManager controls.
|
||||
- Added configurable stale client job-directory cleanup with a default retention of 24 hours. Cleanup is limited to AssetManager job directories and runs before a new file-based job is dispatched to that client.
|
||||
|
||||
## Follow-up: reliable client job retention cleanup
|
||||
|
||||
- Stale client cleanup now uses the job-directory creation time on Windows instead of the mutable last-write timestamp.
|
||||
- Current/active job directories are explicitly excluded from cleanup.
|
||||
- Legacy numeric job directories and current `JobID-Attempt` directories are both recognized below the dedicated AssetManager job root.
|
||||
- If deletion of an expired directory fails because of legacy/broken ACLs, AssetManager repairs permissions only inside that already-expired job directory and retries deletion.
|
||||
- Cleanup still runs before new file-based jobs and now also runs periodically for online managed clients with job history, so stale files do not depend on a later deployment to be removed.
|
||||
- Cleanup diagnostics now report found, eligible, removed, failed, young, active and ignored directory counts.
|
||||
@@ -10,3 +10,4 @@ ldap3
|
||||
passlib[bcrypt]
|
||||
openpyxl
|
||||
pefile==2024.8.26
|
||||
cryptography==46.0.4
|
||||
|
||||
Reference in New Issue
Block a user