astrapi-admin 26.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrapi_admin-26.9.1/.github/workflows/publish.yml +31 -0
- astrapi_admin-26.9.1/.gitignore +7 -0
- astrapi_admin-26.9.1/PKG-INFO +16 -0
- astrapi_admin-26.9.1/astrapi_admin/__init__.py +0 -0
- astrapi_admin-26.9.1/astrapi_admin/_app.py +72 -0
- astrapi_admin-26.9.1/astrapi_admin/_cli.py +15 -0
- astrapi_admin-26.9.1/astrapi_admin/_paths.py +9 -0
- astrapi_admin-26.9.1/astrapi_admin/api/__init__.py +0 -0
- astrapi_admin-26.9.1/astrapi_admin/api/agent.py +278 -0
- astrapi_admin-26.9.1/astrapi_admin/api/auth.py +56 -0
- astrapi_admin-26.9.1/astrapi_admin/api/fastapi_app.py +28 -0
- astrapi_admin-26.9.1/astrapi_admin/app.yaml +23 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/__init__.py +50 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/config/modul.yaml +2 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/config/schema.yaml +33 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/icons/icon-outline.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/icons/icon.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/ui/__init__.py +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/ui/crud.py +70 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/__init__.py +111 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/agent_settings.py +28 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/config/modul.yaml +18 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/config/schema.yaml +61 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/config/settings.yaml +86 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/dialogs/pair/modal.html +38 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/dialogs/user_inventory/modal.html +72 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/icons/icon-outline.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/icons/icon.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/mirror_client.py +67 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/mirror_repos.py +72 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/pairing_store.py +45 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/proxmox_client.py +162 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/__init__.py +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/crud.py +183 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/pairing.py +49 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/updates.py +123 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/user_inventory.py +78 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/__init__.py +50 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/config/modul.yaml +7 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/dialogs/edit/modal.html +266 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/dialogs/import/modal.html +61 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/engine.py +175 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/icons/icon-outline.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/icons/icon.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/policies/ui/__init__.py +362 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/__init__.py +41 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/config/modul.yaml +2 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/dialogs/edit/modal.html +120 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/engine.py +86 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/icons/icon-outline.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/icons/icon.svg +1 -0
- astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/ui/__init__.py +216 -0
- astrapi_admin-26.9.1/astrapi_admin/navigation.yaml +10 -0
- astrapi_admin-26.9.1/astrapi_admin/overrides/system.py +90 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/PKG-INFO +16 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/SOURCES.txt +82 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/dependency_links.txt +1 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/entry_points.txt +2 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/requires.txt +11 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/scm_file_list.json +78 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/scm_version.json +8 -0
- astrapi_admin-26.9.1/astrapi_admin.egg-info/top_level.txt +1 -0
- astrapi_admin-26.9.1/pyproject.toml +56 -0
- astrapi_admin-26.9.1/requirements.txt +37 -0
- astrapi_admin-26.9.1/setup.cfg +4 -0
- astrapi_admin-26.9.1/tests/test_agent_api.py +449 -0
- astrapi_admin-26.9.1/tests/test_agent_settings.py +42 -0
- astrapi_admin-26.9.1/tests/test_hosts_locked_options.py +131 -0
- astrapi_admin-26.9.1/tests/test_hosts_logs.py +91 -0
- astrapi_admin-26.9.1/tests/test_hosts_resolve_labels.py +28 -0
- astrapi_admin-26.9.1/tests/test_hosts_user_inventory.py +174 -0
- astrapi_admin-26.9.1/tests/test_mirror_client.py +102 -0
- astrapi_admin-26.9.1/tests/test_mirror_repos.py +231 -0
- astrapi_admin-26.9.1/tests/test_options_fetchers.py +55 -0
- astrapi_admin-26.9.1/tests/test_pairing_proxmox.py +99 -0
- astrapi_admin-26.9.1/tests/test_policies_engine.py +168 -0
- astrapi_admin-26.9.1/tests/test_policies_export_import.py +178 -0
- astrapi_admin-26.9.1/tests/test_policies_list_display.py +33 -0
- astrapi_admin-26.9.1/tests/test_policies_secret_field.py +157 -0
- astrapi_admin-26.9.1/tests/test_proxmox_client.py +193 -0
- astrapi_admin-26.9.1/tests/test_trigger_update_dialog.py +108 -0
- astrapi_admin-26.9.1/tests/test_trigger_update_snapshot.py +131 -0
- astrapi_admin-26.9.1/tests/test_user_policies_engine.py +110 -0
- astrapi_admin-26.9.1/tests/test_user_policies_list_display.py +41 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # für Trusted Publishing (optional, aber empfohlen)
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0 # nötig für setuptools-scm
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Build
|
|
24
|
+
run: |
|
|
25
|
+
pip install --quiet build
|
|
26
|
+
python -m build --wheel --sdist
|
|
27
|
+
|
|
28
|
+
- name: Publish to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
30
|
+
with:
|
|
31
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astrapi-admin
|
|
3
|
+
Version: 26.9.1
|
|
4
|
+
Summary: astrapi-admin – Configuration-Management für die eigene Host-Flotte (Pakete, Config-Dateien, Services)
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: fastapi==0.141.1
|
|
7
|
+
Requires-Dist: uvicorn[standard]==0.52.4
|
|
8
|
+
Requires-Dist: jinja2==3.1.6
|
|
9
|
+
Requires-Dist: pyyaml==6.0.3
|
|
10
|
+
Requires-Dist: python-multipart==0.0.32
|
|
11
|
+
Requires-Dist: cryptography==50.0.0
|
|
12
|
+
Requires-Dist: psutil==7.2.2
|
|
13
|
+
Requires-Dist: apispec==6.10.0
|
|
14
|
+
Requires-Dist: webauthn==3.0.0
|
|
15
|
+
Requires-Dist: httpx==0.28.1
|
|
16
|
+
Requires-Dist: astrapi-core>=26.8.37
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""astrapi_admin._app – ASGI-App-Factory.
|
|
2
|
+
|
|
3
|
+
Start:
|
|
4
|
+
uvicorn astrapi_admin._app:app
|
|
5
|
+
astrapi-admin --work-dir /opt/astrapi-admin --port 5005
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from astrapi_core.system.paths import configure as _configure_paths
|
|
11
|
+
|
|
12
|
+
_configure_paths("astrapi-admin")
|
|
13
|
+
|
|
14
|
+
from astrapi_core.modules.settings.engine import configure as configure_settings
|
|
15
|
+
from astrapi_core.modules.system.engine import configure_updater
|
|
16
|
+
from astrapi_core.system.health import register_health
|
|
17
|
+
from astrapi_core.system.systemd import sd_notify, start_watchdog
|
|
18
|
+
from astrapi_core.system.version import get_display_name
|
|
19
|
+
from astrapi_core.ui import create as create_ui
|
|
20
|
+
from astrapi_core.ui.module_registry import load_modules
|
|
21
|
+
from astrapi_core.ui.settings_registry import init as settings_init
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
from fastapi.staticfiles import StaticFiles
|
|
24
|
+
|
|
25
|
+
from astrapi_admin._paths import db_path, package_dir, work_dir
|
|
26
|
+
from astrapi_admin.api.fastapi_app import create as create_api
|
|
27
|
+
|
|
28
|
+
_START_TIME = time.time()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _db_check() -> tuple[bool, dict]:
|
|
32
|
+
from astrapi_core.system.db import _conn
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
_conn().execute("SELECT 1").fetchone()
|
|
36
|
+
return True, {"db": True}
|
|
37
|
+
except Exception:
|
|
38
|
+
return False, {"db": False}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_app() -> FastAPI:
|
|
42
|
+
_pkg = package_dir()
|
|
43
|
+
configure_settings(health_fn=_db_check, app_name=get_display_name(_pkg))
|
|
44
|
+
configure_updater(_pkg)
|
|
45
|
+
|
|
46
|
+
from astrapi_core.system.db import configure as _configure_db
|
|
47
|
+
from astrapi_core.system.db import create_all_registered_tables
|
|
48
|
+
|
|
49
|
+
_configure_db(db_path())
|
|
50
|
+
create_all_registered_tables()
|
|
51
|
+
|
|
52
|
+
settings_init(work_dir())
|
|
53
|
+
|
|
54
|
+
modules, _ = load_modules(_pkg)
|
|
55
|
+
api = create_api(modules=modules)
|
|
56
|
+
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
import astrapi_core.ui
|
|
60
|
+
|
|
61
|
+
core_static = Path(astrapi_core.ui.__file__).parent / "static"
|
|
62
|
+
api.mount("/static", StaticFiles(directory=str(core_static)), name="static")
|
|
63
|
+
|
|
64
|
+
create_ui(api, app_root=_pkg, modules=modules)
|
|
65
|
+
|
|
66
|
+
register_health(api, check_fn=_db_check, start_time=_START_TIME)
|
|
67
|
+
start_watchdog(check_fn=lambda: _db_check()[0])
|
|
68
|
+
sd_notify("READY=1")
|
|
69
|
+
return api
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app = create_app()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""astrapi_admin._cli – Console-Script-Einstiegspunkt.
|
|
2
|
+
|
|
3
|
+
Start:
|
|
4
|
+
astrapi-admin --work-dir /opt/astrapi-admin --port 5005
|
|
5
|
+
astrapi-admin --work-dir /opt/astrapi-admin --port 5005 --debug
|
|
6
|
+
"""
|
|
7
|
+
from astrapi_core.system.paths import run_app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
run_app("astrapi_admin._app:app", "astrapi-admin", default_port=5005)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# astrapi_admin/_paths.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from astrapi_core.system.paths import db_path, log_dir, work_dir # noqa: F401 – re-export
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def package_dir() -> Path:
|
|
8
|
+
"""Pfad zum installierten Package – für app.yaml, Templates, Modul-YAMLs."""
|
|
9
|
+
return Path(__file__).resolve().parent
|
|
File without changes
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""astrapi_admin.api.agent – Agent-seitige API (Pairing, Policy-Abruf, Report)."""
|
|
2
|
+
import logging
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from astrapi_admin.api.auth import hash_token, require_host
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_REPORT_STATUSES = {"ok", "drift", "error", "conflict"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PairRequest(BaseModel):
|
|
19
|
+
token: str
|
|
20
|
+
hostname: str = ""
|
|
21
|
+
os_type: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReportRequest(BaseModel):
|
|
25
|
+
status: str = "ok"
|
|
26
|
+
summary: str = ""
|
|
27
|
+
details: dict = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_proxmox_vmid(hostname: str) -> int:
|
|
31
|
+
"""Best-effort: sucht beim Pairing/Neu-Verbinden nach einem passenden
|
|
32
|
+
Proxmox-LXC (E-009) -- ein nicht konfiguriertes/kurz nicht erreichbares
|
|
33
|
+
Proxmox darf das Pairing selbst nie zum Scheitern bringen, deshalb hier
|
|
34
|
+
breit try/except statt den Fehler durchzureichen."""
|
|
35
|
+
try:
|
|
36
|
+
from astrapi_admin.modules.hosts import proxmox_client
|
|
37
|
+
|
|
38
|
+
found = proxmox_client.find_lxc_by_hostname(hostname)
|
|
39
|
+
return found["vmid"] if found else -1
|
|
40
|
+
except Exception as e:
|
|
41
|
+
log.warning("proxmox: LXC-Erkennung für '%s' fehlgeschlagen: %s", hostname, e)
|
|
42
|
+
return -1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/pair")
|
|
46
|
+
def pair(payload: PairRequest):
|
|
47
|
+
from astrapi_core.system.activity_log import log_activity
|
|
48
|
+
|
|
49
|
+
from astrapi_admin.modules.hosts.pairing_store import redeem_pairing_token
|
|
50
|
+
from astrapi_admin.modules.hosts.ui.crud import store as hosts_store
|
|
51
|
+
|
|
52
|
+
pairing_info = redeem_pairing_token(payload.token)
|
|
53
|
+
if pairing_info is None:
|
|
54
|
+
raise HTTPException(400, "Ungültiger oder abgelaufener Pairing-Code")
|
|
55
|
+
|
|
56
|
+
host_token = secrets.token_urlsafe(32)
|
|
57
|
+
existing_host_id = pairing_info.get("host_id")
|
|
58
|
+
|
|
59
|
+
if existing_host_id is not None:
|
|
60
|
+
# Neu verbinden: nur das Token ersetzen, Gruppen/Policies/Label
|
|
61
|
+
# bleiben unangetastet (siehe hosts/ui/pairing.py::reconnect_dialog).
|
|
62
|
+
# proxmox_vmid wird trotzdem neu ermittelt -- so bekommen auch vor
|
|
63
|
+
# E-009 gepairte Bestandshosts (LXC01/LXC02) die Erkennung nachträglich,
|
|
64
|
+
# ohne eigenen Zusatz-Workflow.
|
|
65
|
+
existing = hosts_store.get(existing_host_id)
|
|
66
|
+
if existing is None:
|
|
67
|
+
raise HTTPException(404, "Host wurde inzwischen gelöscht")
|
|
68
|
+
updates = {"token_hash": hash_token(host_token)}
|
|
69
|
+
updates["proxmox_vmid"] = _detect_proxmox_vmid(existing.get("hostname") or "")
|
|
70
|
+
hosts_store.update(existing_host_id, updates)
|
|
71
|
+
log_activity(
|
|
72
|
+
log_type="job",
|
|
73
|
+
module="hosts",
|
|
74
|
+
item_id=str(existing_host_id),
|
|
75
|
+
description=f"Host „{existing.get('label') or existing.get('hostname') or existing_host_id}“ neu verbunden",
|
|
76
|
+
status="ok",
|
|
77
|
+
)
|
|
78
|
+
return {
|
|
79
|
+
"host_id": existing_host_id,
|
|
80
|
+
"host_token": host_token,
|
|
81
|
+
"group_ids": existing.get("group_ids") or [],
|
|
82
|
+
"policy_ids": existing.get("policy_ids") or [],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
hostname = payload.hostname or "unbekannt"
|
|
86
|
+
item_id = hosts_store.create(
|
|
87
|
+
None,
|
|
88
|
+
{
|
|
89
|
+
"hostname": hostname,
|
|
90
|
+
"label": hostname,
|
|
91
|
+
"os_type": payload.os_type or "",
|
|
92
|
+
"group_ids": [],
|
|
93
|
+
"policy_ids": [],
|
|
94
|
+
"token_hash": hash_token(host_token),
|
|
95
|
+
"last_seen": "",
|
|
96
|
+
"last_report": "",
|
|
97
|
+
"last_status": "",
|
|
98
|
+
"proxmox_vmid": _detect_proxmox_vmid(hostname),
|
|
99
|
+
"enabled": True,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
log_activity(
|
|
103
|
+
log_type="job",
|
|
104
|
+
module="hosts",
|
|
105
|
+
item_id=str(item_id),
|
|
106
|
+
description=f"Neuer Host „{hostname}“ gepairt",
|
|
107
|
+
status="ok",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"host_id": item_id,
|
|
112
|
+
"host_token": host_token,
|
|
113
|
+
"group_ids": [],
|
|
114
|
+
"policy_ids": [],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.get("/policy")
|
|
119
|
+
def get_policy(host_data=Depends(require_host)):
|
|
120
|
+
from astrapi_admin.modules.hosts.agent_settings import poll_interval_minutes
|
|
121
|
+
from astrapi_admin.modules.hosts.mirror_repos import resolve_mirror_config_files
|
|
122
|
+
from astrapi_admin.modules.policies.engine import resolve_policy_for_host
|
|
123
|
+
from astrapi_admin.modules.user_policies.engine import resolve_users_for_host
|
|
124
|
+
|
|
125
|
+
_host_id, host = host_data
|
|
126
|
+
result = resolve_policy_for_host(host)
|
|
127
|
+
resolve_mirror_config_files(host, result)
|
|
128
|
+
# E-007: rein informativ fuer den Agenten, welche einmalige Aktion
|
|
129
|
+
# der Admin explizit angefordert hat (aktuell nur "update") -- NICHT
|
|
130
|
+
# automatisch/Policy-getrieben, siehe hosts/ui/updates.py.
|
|
131
|
+
result["pending_action"] = host.get("pending_action") or ""
|
|
132
|
+
# E-011: Poll-Intervall server-seitig einstellbar (Einstellungen >
|
|
133
|
+
# Agent) -- der Agent gleicht seinen eigenen systemd-Timer danach ab.
|
|
134
|
+
result["poll_interval_minutes"] = poll_interval_minutes()
|
|
135
|
+
# E-012: Nutzer-Policies separat aufgeloest (eigenes Modul, kein
|
|
136
|
+
# Gruppen-Erbe), Konflikte in dieselbe Liste eingehaengt.
|
|
137
|
+
user_result = resolve_users_for_host(host)
|
|
138
|
+
result["users"] = user_result["users"]
|
|
139
|
+
result["conflicts"].extend(user_result["conflicts"])
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _notify_new_updates(host_id, host: dict, details: dict) -> None:
|
|
144
|
+
"""Benachrichtigt (falls in astrapi-core::notify konfiguriert) nur
|
|
145
|
+
wenn sich die gemeldete Update-Anzahl gegenueber dem zuletzt
|
|
146
|
+
bekannten Stand ERHOEHT hat (E-008) -- sonst wuerde bei jedem
|
|
147
|
+
15-Minuten-Zyklus dieselbe unveraenderte Zahl erneut gemeldet.
|
|
148
|
+
Sicherheitsrelevante Updates gehen als WARNING raus (hoehere
|
|
149
|
+
Prioritaet, siehe notify.engine._PRIORITY_MAP), rein normale als
|
|
150
|
+
INFO -- pro Report nur eine der beiden, nicht beide gleichzeitig
|
|
151
|
+
(WARNING schliesst den Normalfall bereits mit ein)."""
|
|
152
|
+
label = host.get("label") or host.get("hostname") or str(host_id)
|
|
153
|
+
new_total = details.get("updates_available", 0)
|
|
154
|
+
new_security = details.get("security_updates_available")
|
|
155
|
+
old_total = host.get("updates_available", -1)
|
|
156
|
+
old_security = host.get("security_updates_available", -1)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
from astrapi_core.modules.notify import engine as notify_engine
|
|
160
|
+
|
|
161
|
+
if new_security is not None and new_security > 0 and new_security > max(old_security, 0):
|
|
162
|
+
notify_engine.send(
|
|
163
|
+
title=f"{label}: sicherheitsrelevante Updates verfügbar",
|
|
164
|
+
message=f"{new_security} von {new_total} verfügbaren Updates sind sicherheitsrelevant.",
|
|
165
|
+
event=notify_engine.WARNING,
|
|
166
|
+
source="hosts",
|
|
167
|
+
tags=["update", "security"],
|
|
168
|
+
)
|
|
169
|
+
elif new_total > 0 and new_total > max(old_total, 0):
|
|
170
|
+
notify_engine.send(
|
|
171
|
+
title=f"{label}: Updates verfügbar",
|
|
172
|
+
message=f"{new_total} Update{'s' if new_total != 1 else ''} verfügbar.",
|
|
173
|
+
event=notify_engine.INFO,
|
|
174
|
+
source="hosts",
|
|
175
|
+
tags=["update"],
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
log.warning("notify: Benachrichtigung für Update-Status fehlgeschlagen (Host %s): %s", host_id, e)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.post("/report")
|
|
182
|
+
def post_report(payload: ReportRequest, host_data=Depends(require_host)):
|
|
183
|
+
import json
|
|
184
|
+
|
|
185
|
+
from astrapi_core.system.activity_log import log_activity
|
|
186
|
+
|
|
187
|
+
from astrapi_admin.modules.hosts.ui.crud import store as hosts_store
|
|
188
|
+
|
|
189
|
+
host_id, host = host_data
|
|
190
|
+
status = payload.status if payload.status in _REPORT_STATUSES else "error"
|
|
191
|
+
|
|
192
|
+
import time
|
|
193
|
+
|
|
194
|
+
updates = {
|
|
195
|
+
"last_report": json.dumps({"status": status, "summary": payload.summary, "details": payload.details}),
|
|
196
|
+
"last_status": status,
|
|
197
|
+
}
|
|
198
|
+
if "updates_available" in payload.details:
|
|
199
|
+
updates["updates_available"] = payload.details["updates_available"]
|
|
200
|
+
updates["updates_checked_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
201
|
+
# T-284-ADMIN: Paketnamen (nicht nur die Anzahl) mitspeichern, damit
|
|
202
|
+
# "Update anstoßen" vor der Freigabe zeigen kann, WAS betroffen ist.
|
|
203
|
+
updates["updates_package_list"] = payload.details.get("upgradable_packages") or []
|
|
204
|
+
if "security_updates_available" in payload.details:
|
|
205
|
+
updates["security_updates_available"] = payload.details["security_updates_available"]
|
|
206
|
+
updates["security_updates_package_list"] = payload.details.get("security_upgradable_packages") or []
|
|
207
|
+
_notify_new_updates(host_id, host, payload.details)
|
|
208
|
+
if "update_result" in payload.details:
|
|
209
|
+
# E-007: die angeforderte Aktion wurde versucht (egal ob
|
|
210
|
+
# erfolgreich) -- pending_action zuruecksetzen, sonst bliebe ein
|
|
211
|
+
# fehlgeschlagenes Update fuer immer "pending" und wuerde bei
|
|
212
|
+
# jedem Zyklus stumpf wiederholt.
|
|
213
|
+
updates["pending_action"] = ""
|
|
214
|
+
if "reboot_required" in payload.details:
|
|
215
|
+
# Nur uebernehmen, wenn der Agent das Feld ueberhaupt mitschickt --
|
|
216
|
+
# ein aelterer, noch nicht aktualisierter Agent kennt es nicht, ein
|
|
217
|
+
# unconditionales False wuerde einen echten anstehenden Neustart
|
|
218
|
+
# sonst faelschlich zuruecksetzen.
|
|
219
|
+
updates["reboot_required"] = bool(payload.details["reboot_required"])
|
|
220
|
+
if "user_inventory" in payload.details:
|
|
221
|
+
# E-012: rein informative Bestandsaufnahme -- ueberschreibt NIE
|
|
222
|
+
# eine user_policies-Zuweisung, macht auch keinen Account
|
|
223
|
+
# "verwaltet" (das entscheidet ausschliesslich der Agent selbst
|
|
224
|
+
# ueber sein eigenes managed_users). JSON-Text wie last_report,
|
|
225
|
+
# kein list_field (Eintraege sind strukturierte Dicts, keine
|
|
226
|
+
# einfachen Strings).
|
|
227
|
+
updates["user_inventory"] = json.dumps(payload.details["user_inventory"])
|
|
228
|
+
updates["user_inventory_checked_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
229
|
+
|
|
230
|
+
hosts_store.update(host_id, updates)
|
|
231
|
+
|
|
232
|
+
label = host.get("label") or host.get("hostname") or host_id
|
|
233
|
+
log_id = log_activity(
|
|
234
|
+
log_type="job",
|
|
235
|
+
module="hosts",
|
|
236
|
+
item_id=str(host_id),
|
|
237
|
+
description=payload.summary or f"Policy-Report von „{label}“",
|
|
238
|
+
status=status,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if "update_result" in payload.details:
|
|
242
|
+
# T-284-ADMIN: im Log sichtbar machen, WAS ein angestoßenes Update
|
|
243
|
+
# tatsaechlich veraendert hat -- sonst zeigt der Log-Eintrag nur die
|
|
244
|
+
# kurze Zusammenfassung ("aktualisiert"/"Update fehlgeschlagen"),
|
|
245
|
+
# nicht die konkreten Pakete/die rohe apt-/pacman-Ausgabe.
|
|
246
|
+
from astrapi_core.system.activity_log import append_log_line
|
|
247
|
+
|
|
248
|
+
ur = payload.details["update_result"]
|
|
249
|
+
pkgs = ur.get("packages") or []
|
|
250
|
+
if pkgs:
|
|
251
|
+
append_log_line(log_id, f"Aktualisierte Pakete ({len(pkgs)}): " + ", ".join(pkgs))
|
|
252
|
+
else:
|
|
253
|
+
append_log_line(log_id, "Update angestoßen, aber keine Pakete betroffen.")
|
|
254
|
+
for line in (ur.get("detail") or "").splitlines():
|
|
255
|
+
if line.strip():
|
|
256
|
+
append_log_line(log_id, line)
|
|
257
|
+
|
|
258
|
+
return {"ok": True}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@router.get("/proxmox-pending-updates")
|
|
262
|
+
def proxmox_pending_updates():
|
|
263
|
+
"""Read-only, bewusst UNAUTHENTIFIZIERT (E-010, Nutzerentscheidung
|
|
264
|
+
2026-09-01: "erstmal offen, im Vault vermerken dass ein Token
|
|
265
|
+
sinnvoll waere" -- kein Geheimnis wird preisgegeben, nur welche
|
|
266
|
+
Proxmox-VMIDs gerade ein pending_action=update haben). Genutzt von
|
|
267
|
+
astrapi-backups proxmox_lxc-Modul, um kein Backup zu starten,
|
|
268
|
+
waehrend hier ein Update laeuft -- die Rueckrichtung
|
|
269
|
+
(astrapi_admin.modules.hosts.proxmox_client.is_vzdump_running())
|
|
270
|
+
prueft entsprechend vor jedem Snapshot-Versuch."""
|
|
271
|
+
from astrapi_admin.modules.hosts.ui.crud import store as hosts_store
|
|
272
|
+
|
|
273
|
+
vmids = [
|
|
274
|
+
h["proxmox_vmid"]
|
|
275
|
+
for h in hosts_store.list().values()
|
|
276
|
+
if h.get("pending_action") == "update" and (h.get("proxmox_vmid") or -1) >= 0
|
|
277
|
+
]
|
|
278
|
+
return {"vmids": vmids}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# astrapi_admin/api/auth.py
|
|
2
|
+
"""Host-Authentifizierung fuer die Agent-API.
|
|
3
|
+
|
|
4
|
+
astrapi-core kennt keinerlei Auth-Konzept -- komplett neu, 1:1 nach
|
|
5
|
+
astrapi_sync/api/auth.py portiert (SHA-256-Hash + hmac.compare_digest,
|
|
6
|
+
Bearer-Token, last_seen wird bei jedem authentifizierten Call
|
|
7
|
+
aktualisiert statt ueber einen eigenen Heartbeat-Endpunkt). Anders als
|
|
8
|
+
bei sync gibt es keinen folder_id-Sonderfall -- ein Host holt immer nur
|
|
9
|
+
seine eigene Policy, keine Pfad-Scopes noetig.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from fastapi import Header, HTTPException
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hash_token(token: str) -> str:
|
|
19
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_host_by_token(token: str) -> tuple[str, dict] | None:
|
|
23
|
+
from astrapi_admin.modules.hosts.ui.crud import store as hosts_store
|
|
24
|
+
|
|
25
|
+
token_hash = hash_token(token)
|
|
26
|
+
for host_id, host in hosts_store.list().items():
|
|
27
|
+
# hmac.compare_digest() statt "==" -- schliesst Timing-Seitenkanaele
|
|
28
|
+
# beim Hash-Vergleich aus (gleiche Lektion wie astrapi-sync).
|
|
29
|
+
if hmac.compare_digest(host.get("token_hash") or "", token_hash):
|
|
30
|
+
return host_id, host
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def authenticate(token: str) -> tuple[str, dict]:
|
|
35
|
+
"""Prueft ein rohes Token (ohne "Bearer "-Praefix), aktualisiert last_seen.
|
|
36
|
+
|
|
37
|
+
Gibt (host_id, host_dict) zurueck oder wirft HTTPException.
|
|
38
|
+
"""
|
|
39
|
+
from astrapi_admin.modules.hosts.ui.crud import store as hosts_store
|
|
40
|
+
|
|
41
|
+
found = get_host_by_token(token)
|
|
42
|
+
if found is None:
|
|
43
|
+
raise HTTPException(401, "Ungültiges Host-Token")
|
|
44
|
+
host_id, host = found
|
|
45
|
+
if not host.get("enabled", True):
|
|
46
|
+
raise HTTPException(403, "Host ist deaktiviert")
|
|
47
|
+
|
|
48
|
+
hosts_store.update(host_id, {"last_seen": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
49
|
+
return host_id, host
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_host(authorization: str = Header(default="")) -> tuple[str, dict]:
|
|
53
|
+
"""Dependency fuer alle /api/agent/...-Endpunkte (ausser pair)."""
|
|
54
|
+
if not authorization.startswith("Bearer "):
|
|
55
|
+
raise HTTPException(401, "Kein Host-Token angegeben")
|
|
56
|
+
return authenticate(authorization.removeprefix("Bearer ").strip())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""astrapi_admin.api.fastapi_app – FastAPI-Factory."""
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from astrapi_core.system.version import get_app_version
|
|
4
|
+
|
|
5
|
+
from astrapi_admin._paths import package_dir
|
|
6
|
+
|
|
7
|
+
APP_ROOT = package_dir()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create(modules: list | None = None) -> FastAPI:
|
|
11
|
+
_version = get_app_version(APP_ROOT, default="0.0.1")
|
|
12
|
+
app = FastAPI(
|
|
13
|
+
title="astrapi-admin API",
|
|
14
|
+
version=_version,
|
|
15
|
+
docs_url="/api/docs",
|
|
16
|
+
redoc_url="/api/redoc",
|
|
17
|
+
openapi_url="/api/openapi.json",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from astrapi_core.ui.module_registry import load_modules, register_fastapi_modules
|
|
21
|
+
if modules is None:
|
|
22
|
+
modules, _ = load_modules(APP_ROOT)
|
|
23
|
+
register_fastapi_modules(app, modules)
|
|
24
|
+
|
|
25
|
+
from astrapi_admin.api.agent import router as agent_router
|
|
26
|
+
app.include_router(agent_router)
|
|
27
|
+
|
|
28
|
+
return app
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
version: 0.0.1
|
|
2
|
+
name: astrapi-admin
|
|
3
|
+
display_name: Admin
|
|
4
|
+
|
|
5
|
+
# Der periodische Anteil laeuft auf den Agenten (systemd-Timer pro Host),
|
|
6
|
+
# nicht serverseitig -- Scheduler-Modul wird (noch) nicht gebraucht, kein
|
|
7
|
+
# APScheduler als Abhaengigkeit noetig.
|
|
8
|
+
disabled_modules:
|
|
9
|
+
- scheduler
|
|
10
|
+
|
|
11
|
+
# Passkey-Login-Pflicht fuer die UI (siehe astrapi-core system/auth.py,
|
|
12
|
+
# Hintergrund [[E-003]]) -- ohne das koennte jeder im LAN eine Policy
|
|
13
|
+
# anlegen, die auf jedem gepaarten Host als root ausgefuehrt wird.
|
|
14
|
+
# rp_id/rp_name sind nur Startwerte, spaeter ueber die Settings-Seite
|
|
15
|
+
# aenderbar, sobald der echte Deployment-Hostname feststeht.
|
|
16
|
+
# /api/agent bleibt ausgenommen -- das ist die eigene Bearer-Token-API der
|
|
17
|
+
# Agenten, keine Browser-Session.
|
|
18
|
+
auth:
|
|
19
|
+
enabled: true
|
|
20
|
+
rp_id: admin.simpsons.lan
|
|
21
|
+
rp_name: astrapi-admin
|
|
22
|
+
exempt_prefixes:
|
|
23
|
+
- /api/agent
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from astrapi_core.system.db import register_table
|
|
4
|
+
from astrapi_core.ui.module_loader import load_modul
|
|
5
|
+
|
|
6
|
+
_KEY = Path(__file__).parent.name
|
|
7
|
+
|
|
8
|
+
_DDL = """
|
|
9
|
+
CREATE TABLE IF NOT EXISTS host_groups (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
name TEXT NOT NULL DEFAULT '',
|
|
12
|
+
description TEXT NOT NULL DEFAULT '',
|
|
13
|
+
policy_ids TEXT NOT NULL DEFAULT '',
|
|
14
|
+
mirror_repos TEXT NOT NULL DEFAULT '',
|
|
15
|
+
enabled INTEGER NOT NULL DEFAULT 1
|
|
16
|
+
)"""
|
|
17
|
+
|
|
18
|
+
register_table(_KEY, _DDL, list_fields=["policy_ids", "mirror_repos"])
|
|
19
|
+
|
|
20
|
+
from astrapi_core.ui.controls import Col, ContentTable # noqa: E402
|
|
21
|
+
from astrapi_core.ui.field_resolver import register_options_fetcher as _reg # noqa: E402
|
|
22
|
+
|
|
23
|
+
from astrapi_admin.modules.host_groups.ui.crud import api_router as router # noqa: E402
|
|
24
|
+
from astrapi_admin.modules.host_groups.ui.crud import groups_for_select # noqa: E402
|
|
25
|
+
from astrapi_admin.modules.host_groups.ui.crud import router as ui_router # noqa: E402
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _groups_options_fetcher(endpoint: str) -> list:
|
|
29
|
+
return groups_for_select()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Ohne diese Registrierung bleibt hosts.group_ids (options_endpoint:
|
|
33
|
+
# /api/host_groups/for-select) dauerhaft leer -- siehe gleiche
|
|
34
|
+
# Begruendung in modules/policies/__init__.py, T-270-ADMIN.
|
|
35
|
+
_reg("/api/host_groups/for-select", _groups_options_fetcher)
|
|
36
|
+
|
|
37
|
+
module = load_modul(
|
|
38
|
+
Path(__file__).parent,
|
|
39
|
+
_KEY,
|
|
40
|
+
router,
|
|
41
|
+
ui_router,
|
|
42
|
+
ui_content=ContentTable(
|
|
43
|
+
has_run_buttons=False,
|
|
44
|
+
has_status=False,
|
|
45
|
+
columns=[
|
|
46
|
+
Col.trunc("description_text", "Beschreibung"),
|
|
47
|
+
Col.join("policy_names", "Policies", css="col-trunc"),
|
|
48
|
+
],
|
|
49
|
+
),
|
|
50
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
fields:
|
|
2
|
+
- name: name
|
|
3
|
+
type: text
|
|
4
|
+
label: Name
|
|
5
|
+
placeholder: "z.B. LXCs"
|
|
6
|
+
max: 80
|
|
7
|
+
column: 1
|
|
8
|
+
row: 1
|
|
9
|
+
|
|
10
|
+
- name: description
|
|
11
|
+
type: text
|
|
12
|
+
label: Beschreibung
|
|
13
|
+
placeholder: "z.B. alle Proxmox-LXC-Container"
|
|
14
|
+
max: 200
|
|
15
|
+
column: 1
|
|
16
|
+
row: 2
|
|
17
|
+
colspan: 2
|
|
18
|
+
|
|
19
|
+
- name: policy_ids
|
|
20
|
+
type: multiselect
|
|
21
|
+
label: Policies
|
|
22
|
+
options_endpoint: /api/policies/for-select
|
|
23
|
+
column: 1
|
|
24
|
+
row: 3
|
|
25
|
+
colspan: 2
|
|
26
|
+
|
|
27
|
+
- name: mirror_repos
|
|
28
|
+
type: multiselect
|
|
29
|
+
label: Mirror-Quellen (Debian)
|
|
30
|
+
options_endpoint: /api/hosts/mirror-repos-for-select
|
|
31
|
+
column: 1
|
|
32
|
+
row: 4
|
|
33
|
+
colspan: 2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-account-group-outline" viewBox="0 0 24 24"><path d="M12,5A3.5,3.5 0 0,0 8.5,8.5A3.5,3.5 0 0,0 12,12A3.5,3.5 0 0,0 15.5,8.5A3.5,3.5 0 0,0 12,5M12,7A1.5,1.5 0 0,1 13.5,8.5A1.5,1.5 0 0,1 12,10A1.5,1.5 0 0,1 10.5,8.5A1.5,1.5 0 0,1 12,7M5.5,8A2.5,2.5 0 0,0 3,10.5C3,11.44 3.53,12.25 4.29,12.68C4.65,12.88 5.06,13 5.5,13C5.94,13 6.35,12.88 6.71,12.68C7.08,12.47 7.39,12.17 7.62,11.81C6.89,10.86 6.5,9.7 6.5,8.5C6.5,8.41 6.5,8.31 6.5,8.22C6.2,8.08 5.86,8 5.5,8M18.5,8C18.14,8 17.8,8.08 17.5,8.22C17.5,8.31 17.5,8.41 17.5,8.5C17.5,9.7 17.11,10.86 16.38,11.81C16.5,12 16.63,12.15 16.78,12.3C16.94,12.45 17.1,12.58 17.29,12.68C17.65,12.88 18.06,13 18.5,13C18.94,13 19.35,12.88 19.71,12.68C20.47,12.25 21,11.44 21,10.5A2.5,2.5 0 0,0 18.5,8M12,14C9.66,14 5,15.17 5,17.5V19H19V17.5C19,15.17 14.34,14 12,14M4.71,14.55C2.78,14.78 0,15.76 0,17.5V19H3V17.07C3,16.06 3.69,15.22 4.71,14.55M19.29,14.55C20.31,15.22 21,16.06 21,17.07V19H24V17.5C24,15.76 21.22,14.78 19.29,14.55M12,16C13.53,16 15.24,16.5 16.23,17H7.77C8.76,16.5 10.47,16 12,16Z" /></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-account-group" viewBox="0 0 24 24"><path d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" /></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from astrapi_admin.modules.host_groups.ui.crud import api_router, router
|