software package profiles implemeted, job deletion optimized

This commit is contained in:
2026-09-26 13:07:23 +02:00
parent 87a5041162
commit 7d314057e4
54 changed files with 3492 additions and 342 deletions
+630
View File
@@ -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")
+11
View File
@@ -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
View File
@@ -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"]}
}
+45
View File
@@ -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"
}
}
+17
View File
@@ -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
View File
@@ -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"]}
}
+12
View File
@@ -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
View File
@@ -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"
]
}
}
+10
View File
@@ -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
View File
@@ -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"
}
}
+24
View File
@@ -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"
}
}
}
+11
View File
@@ -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"]}
}
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+168 -10
View File
@@ -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
View File
@@ -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
+98
View File
@@ -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;
}
+24
View File
@@ -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();
})();
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 %}
+33
View File
@@ -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>
+57 -4
View File
@@ -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>
+111
View File
@@ -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 %}
+3 -2
View File
@@ -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') }}
+42 -2
View File
@@ -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
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.88"
APP_VERSION = "0.5.5.90"
__version__ = APP_VERSION