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.
Files changed (84) hide show
  1. astrapi_admin-26.9.1/.github/workflows/publish.yml +31 -0
  2. astrapi_admin-26.9.1/.gitignore +7 -0
  3. astrapi_admin-26.9.1/PKG-INFO +16 -0
  4. astrapi_admin-26.9.1/astrapi_admin/__init__.py +0 -0
  5. astrapi_admin-26.9.1/astrapi_admin/_app.py +72 -0
  6. astrapi_admin-26.9.1/astrapi_admin/_cli.py +15 -0
  7. astrapi_admin-26.9.1/astrapi_admin/_paths.py +9 -0
  8. astrapi_admin-26.9.1/astrapi_admin/api/__init__.py +0 -0
  9. astrapi_admin-26.9.1/astrapi_admin/api/agent.py +278 -0
  10. astrapi_admin-26.9.1/astrapi_admin/api/auth.py +56 -0
  11. astrapi_admin-26.9.1/astrapi_admin/api/fastapi_app.py +28 -0
  12. astrapi_admin-26.9.1/astrapi_admin/app.yaml +23 -0
  13. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/__init__.py +50 -0
  14. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/config/modul.yaml +2 -0
  15. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/config/schema.yaml +33 -0
  16. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/icons/icon-outline.svg +1 -0
  17. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/icons/icon.svg +1 -0
  18. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/ui/__init__.py +1 -0
  19. astrapi_admin-26.9.1/astrapi_admin/modules/host_groups/ui/crud.py +70 -0
  20. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/__init__.py +111 -0
  21. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/agent_settings.py +28 -0
  22. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/config/modul.yaml +18 -0
  23. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/config/schema.yaml +61 -0
  24. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/config/settings.yaml +86 -0
  25. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/dialogs/pair/modal.html +38 -0
  26. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/dialogs/user_inventory/modal.html +72 -0
  27. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/icons/icon-outline.svg +1 -0
  28. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/icons/icon.svg +1 -0
  29. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/mirror_client.py +67 -0
  30. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/mirror_repos.py +72 -0
  31. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/pairing_store.py +45 -0
  32. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/proxmox_client.py +162 -0
  33. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/__init__.py +1 -0
  34. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/crud.py +183 -0
  35. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/pairing.py +49 -0
  36. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/updates.py +123 -0
  37. astrapi_admin-26.9.1/astrapi_admin/modules/hosts/ui/user_inventory.py +78 -0
  38. astrapi_admin-26.9.1/astrapi_admin/modules/policies/__init__.py +50 -0
  39. astrapi_admin-26.9.1/astrapi_admin/modules/policies/config/modul.yaml +7 -0
  40. astrapi_admin-26.9.1/astrapi_admin/modules/policies/dialogs/edit/modal.html +266 -0
  41. astrapi_admin-26.9.1/astrapi_admin/modules/policies/dialogs/import/modal.html +61 -0
  42. astrapi_admin-26.9.1/astrapi_admin/modules/policies/engine.py +175 -0
  43. astrapi_admin-26.9.1/astrapi_admin/modules/policies/icons/icon-outline.svg +1 -0
  44. astrapi_admin-26.9.1/astrapi_admin/modules/policies/icons/icon.svg +1 -0
  45. astrapi_admin-26.9.1/astrapi_admin/modules/policies/ui/__init__.py +362 -0
  46. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/__init__.py +41 -0
  47. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/config/modul.yaml +2 -0
  48. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/dialogs/edit/modal.html +120 -0
  49. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/engine.py +86 -0
  50. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/icons/icon-outline.svg +1 -0
  51. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/icons/icon.svg +1 -0
  52. astrapi_admin-26.9.1/astrapi_admin/modules/user_policies/ui/__init__.py +216 -0
  53. astrapi_admin-26.9.1/astrapi_admin/navigation.yaml +10 -0
  54. astrapi_admin-26.9.1/astrapi_admin/overrides/system.py +90 -0
  55. astrapi_admin-26.9.1/astrapi_admin.egg-info/PKG-INFO +16 -0
  56. astrapi_admin-26.9.1/astrapi_admin.egg-info/SOURCES.txt +82 -0
  57. astrapi_admin-26.9.1/astrapi_admin.egg-info/dependency_links.txt +1 -0
  58. astrapi_admin-26.9.1/astrapi_admin.egg-info/entry_points.txt +2 -0
  59. astrapi_admin-26.9.1/astrapi_admin.egg-info/requires.txt +11 -0
  60. astrapi_admin-26.9.1/astrapi_admin.egg-info/scm_file_list.json +78 -0
  61. astrapi_admin-26.9.1/astrapi_admin.egg-info/scm_version.json +8 -0
  62. astrapi_admin-26.9.1/astrapi_admin.egg-info/top_level.txt +1 -0
  63. astrapi_admin-26.9.1/pyproject.toml +56 -0
  64. astrapi_admin-26.9.1/requirements.txt +37 -0
  65. astrapi_admin-26.9.1/setup.cfg +4 -0
  66. astrapi_admin-26.9.1/tests/test_agent_api.py +449 -0
  67. astrapi_admin-26.9.1/tests/test_agent_settings.py +42 -0
  68. astrapi_admin-26.9.1/tests/test_hosts_locked_options.py +131 -0
  69. astrapi_admin-26.9.1/tests/test_hosts_logs.py +91 -0
  70. astrapi_admin-26.9.1/tests/test_hosts_resolve_labels.py +28 -0
  71. astrapi_admin-26.9.1/tests/test_hosts_user_inventory.py +174 -0
  72. astrapi_admin-26.9.1/tests/test_mirror_client.py +102 -0
  73. astrapi_admin-26.9.1/tests/test_mirror_repos.py +231 -0
  74. astrapi_admin-26.9.1/tests/test_options_fetchers.py +55 -0
  75. astrapi_admin-26.9.1/tests/test_pairing_proxmox.py +99 -0
  76. astrapi_admin-26.9.1/tests/test_policies_engine.py +168 -0
  77. astrapi_admin-26.9.1/tests/test_policies_export_import.py +178 -0
  78. astrapi_admin-26.9.1/tests/test_policies_list_display.py +33 -0
  79. astrapi_admin-26.9.1/tests/test_policies_secret_field.py +157 -0
  80. astrapi_admin-26.9.1/tests/test_proxmox_client.py +193 -0
  81. astrapi_admin-26.9.1/tests/test_trigger_update_dialog.py +108 -0
  82. astrapi_admin-26.9.1/tests/test_trigger_update_snapshot.py +131 -0
  83. astrapi_admin-26.9.1/tests/test_user_policies_engine.py +110 -0
  84. 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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ var/
6
+ app.db
7
+ pip_*.txt
@@ -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,2 @@
1
+ label: Gruppen
2
+ nav_group: Admin
@@ -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